Merge branch 'main' of https://github.com/formbricks/formbricks into feature/delete-team

This commit is contained in:
Piyush Gupta
2023-08-06 14:58:59 +05:30
41 changed files with 2214 additions and 1626 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import { ActivityItemContent, ActivityItemIcon, ActivityItemPopover } from "./ActivityItemComponents";
import { TActivityFeedItem } from "@formbricks/types/v1/activity";
interface ActivityFeedProps {
activities: TActivityFeedItem[];
sortByDate: boolean;
environmentId: string;
}
export default function ActivityFeed({ activities, sortByDate, environmentId }: ActivityFeedProps) {
const sortedActivities: TActivityFeedItem[] = activities.sort((a, b) =>
sortByDate
? new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
: new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
return (
<>
{sortedActivities.length === 0 ? (
<EmptySpaceFiller type={"event"} environmentId={environmentId} />
) : (
<div>
{sortedActivities.map((activityItem) => (
<li key={activityItem.id} className="list-none">
<div className="relative pb-12">
<span className="absolute left-6 top-4 -ml-px h-full w-0.5 bg-slate-200" aria-hidden="true" />
<div className="relative">
<ActivityItemPopover activityItem={activityItem}>
<div className="flex space-x-3 text-left">
<ActivityItemIcon activityItem={activityItem} />
<ActivityItemContent activityItem={activityItem} />
</div>
</ActivityItemPopover>
</div>
</div>
</li>
))}
</div>
)}
</>
);
}

View File

@@ -1,5 +1,5 @@
import { capitalizeFirstLetter } from "@/lib/utils";
import { timeSince } from "@formbricks/lib/time";
import { TActivityFeedItem } from "@formbricks/types/v1/activity";
import { Label, Popover, PopoverContent, PopoverTrigger } from "@formbricks/ui";
import {
CodeBracketIcon,
@@ -9,9 +9,9 @@ import {
SparklesIcon,
TagIcon,
} from "@heroicons/react/24/solid";
import { ActivityFeedItem } from "./ActivityFeed"; // Import the ActivityFeedItem type from the main file
import { formatDistance } from "date-fns";
export const ActivityItemIcon = ({ activityItem }: { activityItem: ActivityFeedItem }) => (
export const ActivityItemIcon = ({ activityItem }: { activityItem: TActivityFeedItem }) => (
<div className="h-12 w-12 rounded-full bg-white p-3 text-slate-500 duration-100 ease-in-out group-hover:scale-110 group-hover:text-slate-600">
{activityItem.type === "attribute" ? (
<TagIcon />
@@ -19,9 +19,9 @@ export const ActivityItemIcon = ({ activityItem }: { activityItem: ActivityFeedI
<EyeIcon />
) : activityItem.type === "event" ? (
<div>
{activityItem.eventType === "code" && <CodeBracketIcon />}
{activityItem.eventType === "noCode" && <CursorArrowRaysIcon />}
{activityItem.eventType === "automatic" && <SparklesIcon />}
{activityItem.actionType === "code" && <CodeBracketIcon />}
{activityItem.actionType === "noCode" && <CursorArrowRaysIcon />}
{activityItem.actionType === "automatic" && <SparklesIcon />}
</div>
) : (
<QuestionMarkCircleIcon />
@@ -29,7 +29,7 @@ export const ActivityItemIcon = ({ activityItem }: { activityItem: ActivityFeedI
</div>
);
export const ActivityItemContent = ({ activityItem }: { activityItem: ActivityFeedItem }) => (
export const ActivityItemContent = ({ activityItem }: { activityItem: TActivityFeedItem }) => (
<div>
<div className="font-semibold text-slate-700">
{activityItem.type === "attribute" ? (
@@ -37,40 +37,36 @@ export const ActivityItemContent = ({ activityItem }: { activityItem: ActivityFe
) : activityItem.type === "display" ? (
<p>Seen survey</p>
) : activityItem.type === "event" ? (
<p>{activityItem.eventLabel} triggered</p>
<p>{activityItem.actionLabel} triggered</p>
) : (
<p>Unknown Activity</p>
)}
</div>
<div className="text-sm text-slate-400">
<time dateTime={timeSince(activityItem.createdAt)}>{timeSince(activityItem.createdAt)}</time>
<time
dateTime={formatDistance(activityItem.createdAt, new Date(), {
addSuffix: true,
})}>
{formatDistance(activityItem.createdAt, new Date(), {
addSuffix: true,
})}
</time>
</div>
</div>
);
export const ActivityItemPopover = ({
activityItem,
responses,
children,
}: {
activityItem: ActivityFeedItem;
responses: any[];
activityItem: TActivityFeedItem;
children: React.ReactNode;
}) => {
function findMatchingSurveyName(responses, surveyId) {
for (const response of responses) {
if (response.survey.id === surveyId) {
return response.survey.name;
}
return null; // Return null if no match is found
}
}
return (
<Popover>
<PopoverTrigger className="group">{children}</PopoverTrigger>
<PopoverContent className="bg-white">
<div className="">
<div>
{activityItem.type === "attribute" ? (
<div>
<Label className="font-normal text-slate-400">Attribute Label</Label>
@@ -81,26 +77,24 @@ export const ActivityItemPopover = ({
) : activityItem.type === "display" ? (
<div>
<Label className="font-normal text-slate-400">Survey Name</Label>
<p className=" mb-2 text-sm font-medium text-slate-900">
{findMatchingSurveyName(responses, activityItem.displaySurveyId)}
</p>
<p className=" mb-2 text-sm font-medium text-slate-900">{activityItem.displaySurveyName}</p>
</div>
) : activityItem.type === "event" ? (
<div>
<div>
<Label className="font-normal text-slate-400">Event Display Name</Label>
<p className=" mb-2 text-sm font-medium text-slate-900">{activityItem.eventLabel}</p>{" "}
<Label className="font-normal text-slate-400">Event Description</Label>
<Label className="font-normal text-slate-400">Action Display Name</Label>
<p className=" mb-2 text-sm font-medium text-slate-900">{activityItem.actionLabel}</p>{" "}
<Label className="font-normal text-slate-400">Action Description</Label>
<p className=" mb-2 text-sm font-medium text-slate-900">
{activityItem.eventDescription ? (
<span>{activityItem.eventDescription}</span>
{activityItem.actionDescription ? (
<span>{activityItem.actionDescription}</span>
) : (
<span>-</span>
)}
</p>
<Label className="font-normal text-slate-400">Event Type</Label>
<Label className="font-normal text-slate-400">Action Type</Label>
<p className="text-sm font-medium text-slate-900">
{capitalizeFirstLetter(activityItem.eventType)}
{capitalizeFirstLetter(activityItem.actionType)}
</p>
</div>
</div>

View File

@@ -0,0 +1,18 @@
import ActivityTimeline from "@/app/(app)/environments/[environmentId]/people/[personId]/(activitySection)/ActivityTimeline";
import { getActivityTimeline } from "@formbricks/lib/services/activity";
export default async function ActivitySection({
environmentId,
personId,
}: {
environmentId: string;
personId: string;
}) {
const activities = await getActivityTimeline(personId);
return (
<div className="md:col-span-1">
<ActivityTimeline environmentId={environmentId} activities={activities} />
</div>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import ActivityFeed from "@/app/(app)/environments/[environmentId]/people/[personId]/(activitySection)/ActivityFeed";
import { TActivityFeedItem } from "@formbricks/types/v1/activity";
import { ArrowsUpDownIcon } from "@heroicons/react/24/outline";
import { useState } from "react";
export default function ActivityTimeline({
environmentId,
activities,
}: {
environmentId: string;
activities: TActivityFeedItem[];
}) {
const [activityAscending, setActivityAscending] = useState(true);
const toggleSortActivity = () => {
setActivityAscending(!activityAscending);
};
return (
<>
<div className="flex items-center justify-between pb-6">
<h2 className="text-lg font-bold text-slate-700">Activity Timeline</h2>
<div className="text-right">
<button
onClick={toggleSortActivity}
className="hover:text-brand-dark flex items-center px-1 text-slate-800">
<ArrowsUpDownIcon className="inline h-4 w-4" />
</button>
</div>
</div>
<ActivityFeed activities={activities} sortByDate={activityAscending} environmentId={environmentId} />
</>
);
}

View File

@@ -0,0 +1,68 @@
export const revalidate = REVALIDATION_INTERVAL;
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { capitalizeFirstLetter } from "@/lib/utils";
import { getPerson } from "@formbricks/lib/services/person";
import { getResponsesByPersonId } from "@formbricks/lib/services/response";
import { getSessionCount } from "@formbricks/lib/services/session";
export default async function AttributesSection({ personId }: { personId: string }) {
const person = await getPerson(personId);
if (!person) {
throw new Error("No such person found");
}
const numberOfSessions = await getSessionCount(personId);
const responses = await getResponsesByPersonId(personId);
const numberOfResponses = responses?.length || 0;
return (
<div className="space-y-6">
<h2 className="text-lg font-bold text-slate-700">Attributes</h2>
<div>
<dt className="text-sm font-medium text-slate-500">Email</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{person.attributes.email ? (
<span>{person.attributes.email}</span>
) : (
<span className="text-slate-300">Not provided</span>
)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">User Id</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{person.attributes.userId ? (
<span>{person.attributes.userId}</span>
) : (
<span className="text-slate-300">Not provided</span>
)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">Formbricks Id (internal)</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">{person.id}</dd>
</div>
{Object.entries(person.attributes)
.filter(([key, _]) => key !== "email" && key !== "userId")
.map(([key, value]) => (
<div key={key}>
<dt className="text-sm font-medium text-slate-500">{capitalizeFirstLetter(key.toString())}</dt>
<dd className="mt-1 text-sm text-slate-900">{value}</dd>
</div>
))}
<hr />
<div>
<dt className="text-sm font-medium text-slate-500">Sessions</dt>
<dd className="mt-1 text-sm text-slate-900">{numberOfSessions}</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">Responses</dt>
<dd className="mt-1 text-sm text-slate-900">{numberOfResponses}</dd>
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import ResponseTimeline from "@/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponseTimeline";
import { getResponsesByPersonId } from "@formbricks/lib/services/response";
import { getSurveys } from "@formbricks/lib/services/survey";
import { TResponseWithSurvey } from "@formbricks/types/v1/responses";
import { TSurvey } from "@formbricks/types/v1/surveys";
export default async function ResponseSection({
environmentId,
personId,
}: {
environmentId: string;
personId: string;
}) {
const responses = await getResponsesByPersonId(personId);
const surveyIds = responses?.map((response) => response.surveyId) || [];
const surveys: TSurvey[] = surveyIds.length === 0 ? [] : (await getSurveys(environmentId)) ?? [];
const responsesWithSurvey: TResponseWithSurvey[] =
responses?.reduce((acc: TResponseWithSurvey[], response) => {
const thisSurvey = surveys.find((survey) => survey?.id === response.surveyId);
if (thisSurvey) {
acc.push({
...response,
survey: thisSurvey,
});
}
return acc;
}, []) || [];
return <ResponseTimeline environmentId={environmentId} responses={responsesWithSurvey} />;
}

View File

@@ -0,0 +1,35 @@
"use client";
import ResponseFeed from "@/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponsesFeed";
import { TResponseWithSurvey } from "@formbricks/types/v1/responses";
import { ArrowsUpDownIcon } from "@heroicons/react/24/outline";
import { useState } from "react";
export default function ResponseTimeline({
environmentId,
responses,
}: {
environmentId: string;
responses: TResponseWithSurvey[];
}) {
const [responsesAscending, setResponsesAscending] = useState(true);
const toggleSortResponses = () => {
setResponsesAscending(!responsesAscending);
};
return (
<div className="md:col-span-2">
<div className="flex items-center justify-between pb-6">
<h2 className="text-lg font-bold text-slate-700">Responses</h2>
<div className="text-right">
<button
onClick={toggleSortResponses}
className="hover:text-brand-dark flex items-center px-1 text-slate-800">
<ArrowsUpDownIcon className="inline h-4 w-4" />
</button>
</div>
</div>
<ResponseFeed responses={responses} sortByDate={responsesAscending} environmentId={environmentId} />
</div>
);
}

View File

@@ -1,26 +1,35 @@
import { formatDistance } from "date-fns";
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator";
import { timeSince } from "@formbricks/lib/time";
import { TResponseWithSurvey } from "@formbricks/types/v1/responses";
import Link from "next/link";
export default function ResponseFeed({ person, sortByDate, environmentId }) {
export default function ResponseFeed({
responses,
sortByDate,
environmentId,
}: {
responses: TResponseWithSurvey[];
sortByDate: boolean;
environmentId: string;
}) {
return (
<>
{person.responses.length === 0 ? (
{responses.length === 0 ? (
<EmptySpaceFiller type="response" environmentId={environmentId} />
) : (
<div>
{person.responses
{responses
.slice()
.sort((a, b) =>
sortByDate
? new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
: new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
)
.map((response, responseIdx) => (
<li key={response.createdAt} className="list-none">
.map((response: TResponseWithSurvey, responseIdx) => (
<li key={response.id} className="list-none">
<div className="relative pb-8">
{responseIdx !== person.responses.length - 1 ? (
{responseIdx !== responses.length - 1 ? (
<span
className="absolute left-4 top-4 -ml-px h-full w-0.5 bg-slate-200"
aria-hidden="true"
@@ -31,8 +40,14 @@ export default function ResponseFeed({ person, sortByDate, environmentId }) {
<div className="px-4 py-5 sm:p-6">
<div className="flex w-full justify-between">
<div className="text-sm text-slate-400">
<time className="text-slate-700" dateTime={timeSince(response.createdAt)}>
{timeSince(response.createdAt)}
<time
className="text-slate-700"
dateTime={formatDistance(response.createdAt, new Date(), {
addSuffix: true,
})}>
{formatDistance(response.createdAt, new Date(), {
addSuffix: true,
})}
</time>
</div>
<div className="flex items-center justify-center space-x-2 rounded-full bg-slate-50 px-3 py-1 text-sm text-slate-600">
@@ -52,8 +67,8 @@ export default function ResponseFeed({ person, sortByDate, environmentId }) {
<div key={question.id}>
<p className="text-sm text-slate-500">{question.headline}</p>
<p className="ph-no-capture my-1 text-lg font-semibold text-slate-700">
{response.data[question.id] instanceof Array
? response.data[question.id].join(", ")
{Array.isArray(response.data[question.id])
? (response.data[question.id] as string[]).join(", ")
: response.data[question.id]}
</p>
</div>

View File

@@ -1,114 +0,0 @@
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import { useMemo } from "react";
import { ActivityItemContent, ActivityItemIcon, ActivityItemPopover } from "./ActivityItemComponents";
interface ActivityFeedProps {
sessions: any[];
attributes: any[];
displays: any[];
responses: any[];
sortByDate: boolean;
environmentId: string;
}
export type ActivityFeedItem = {
id: string;
type: "event" | "attribute" | "display";
createdAt: string;
updatedAt?: string;
attributeLabel?: string;
attributeValue?: string;
displaySurveyId?: string;
eventLabel?: string;
eventDescription?: string;
eventType?: string;
};
export default function ActivityFeed({
sessions,
attributes,
displays,
responses,
sortByDate,
environmentId,
}: ActivityFeedProps) {
// Convert Attributes into unified format
const unifiedAttributes = useMemo(() => {
if (attributes) {
return attributes.map((attribute) => ({
id: attribute.id,
type: "attribute",
createdAt: attribute.createdAt,
updatedAt: attribute.updatedAt,
attributeLabel: attribute.attributeClass.name,
attributeValue: attribute.value,
}));
}
return [];
}, [attributes]);
// Convert Displays into unified format
const unifiedDisplays = useMemo(() => {
if (displays) {
return displays.map((display) => ({
id: display.id,
type: "display",
createdAt: display.createdAt,
updatedAt: display.updatedAt,
displaySurveyId: display.surveyId,
}));
}
return [];
}, [displays]);
// Convert Events into unified format
const unifiedEvents = useMemo(() => {
if (sessions) {
return sessions.flatMap((session) =>
session.events.map((event) => ({
id: event.id,
type: "event",
eventType: event.eventClass.type,
createdAt: event.createdAt,
eventLabel: event.eventClass.name,
eventDescription: event.eventClass.description,
}))
);
}
return [];
}, [sessions]);
const unifiedList = useMemo<ActivityFeedItem[]>(() => {
return [...unifiedAttributes, ...unifiedDisplays, ...unifiedEvents].sort((a, b) =>
sortByDate
? new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
: new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
}, [unifiedAttributes, unifiedDisplays, unifiedEvents, sortByDate]);
return (
<>
{unifiedList.length === 0 ? (
<EmptySpaceFiller type={"event"} environmentId={environmentId} />
) : (
<div>
{unifiedList.map((activityItem) => (
<li key={activityItem.id} className="list-none">
<div className="relative pb-12">
<span className="absolute left-6 top-4 -ml-px h-full w-0.5 bg-slate-200" aria-hidden="true" />
<div className="relative">
<ActivityItemPopover activityItem={activityItem} responses={responses}>
<div className="flex space-x-3 text-left">
<ActivityItemIcon activityItem={activityItem} />
<ActivityItemContent activityItem={activityItem} />
</div>
</ActivityItemPopover>
</div>
</div>
</li>
))}
</div>
)}
</>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import DeleteDialog from "@/components/shared/DeleteDialog";
import GoBackButton from "@/components/shared/GoBackButton";
import { deletePersonAction } from "./actions";
import { TPerson } from "@formbricks/types/v1/people";
import { TrashIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
export default function HeadingSection({
environmentId,
person,
}: {
environmentId: string;
person: TPerson;
}) {
const router = useRouter();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const handleDeletePerson = async () => {
await deletePersonAction(person.id);
router.push(`/environments/${environmentId}/people`);
toast.success("Person deleted successfully.");
};
return (
<>
<GoBackButton />
<div className="flex items-baseline justify-between border-b border-slate-200 pb-6 pt-4">
<h1 className="ph-no-capture text-4xl font-bold tracking-tight text-slate-900">
<span>{person.attributes.email || person.id}</span>
</h1>
<div className="flex items-center space-x-3">
<button
onClick={() => {
setDeleteDialogOpen(true);
}}>
<TrashIcon className="h-5 w-5 text-slate-500 hover:text-red-700" />
</button>
</div>
</div>
<DeleteDialog
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
deleteWhat="person"
onDelete={handleDeletePerson}
/>
</>
);
}

View File

@@ -1,183 +0,0 @@
"use client";
import DeleteDialog from "@/components/shared/DeleteDialog";
import GoBackButton from "@/components/shared/GoBackButton";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { deletePerson, usePerson } from "@/lib/people/people";
import { capitalizeFirstLetter } from "@/lib/utils";
import { ErrorComponent } from "@formbricks/ui";
import { ArrowsUpDownIcon, TrashIcon } from "@heroicons/react/24/outline";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import ActivityFeed from "./ActivityFeed";
import ResponseFeed from "./ResponsesFeed";
interface PersonDetailsProps {
environmentId: string;
personId: string;
}
export default function PersonDetails({ environmentId, personId }: PersonDetailsProps) {
const router = useRouter();
const { person, isLoadingPerson, isErrorPerson } = usePerson(environmentId, personId);
const [responsesAscending, setResponsesAscending] = useState(true);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [activityAscending, setActivityAscending] = useState(true);
const personEmail = useMemo(
() => person?.attributes?.find((attribute) => attribute.attributeClass.name === "email"),
[person]
);
const personUserId = useMemo(
() => person?.attributes?.find((attribute) => attribute.attributeClass.name === "userId"),
[person]
);
const otherAttributes = useMemo(
() =>
person?.attributes?.filter(
(attribute) =>
attribute.attributeClass.name !== "email" &&
attribute.attributeClass.name !== "userId" &&
!attribute.attributeClass.archived
) as any[],
[person]
);
const toggleSortResponses = () => {
setResponsesAscending(!responsesAscending);
};
const handleDeletePerson = async () => {
await deletePerson(environmentId, personId);
router.push(`/environments/${environmentId}/people`);
toast.success("Person deleted successfully.");
};
const toggleSortActivity = () => {
setActivityAscending(!activityAscending);
};
if (isLoadingPerson) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (isErrorPerson) {
return <ErrorComponent />;
}
return (
<>
<GoBackButton />
<div className="flex items-baseline justify-between border-b border-slate-200 pb-6 pt-4">
<h1 className="ph-no-capture text-4xl font-bold tracking-tight text-slate-900">
{personEmail ? <span>{personEmail.value}</span> : <span>{person.id}</span>}
</h1>
<div className="flex items-center space-x-3">
<button
onClick={() => {
setDeleteDialogOpen(true);
}}>
<TrashIcon className="h-5 w-5 text-slate-500 hover:text-red-700" />
</button>
</div>
</div>
<section className="pb-24 pt-6">
<div className="grid grid-cols-1 gap-x-8 md:grid-cols-4">
<div className="space-y-6">
<h2 className="text-lg font-bold text-slate-700">Attributes</h2>
<div>
<dt className="text-sm font-medium text-slate-500">Email</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{personEmail ? (
<span>{personEmail?.value}</span>
) : (
<span className="text-slate-300">Not provided</span>
)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">User Id</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{personUserId ? (
<span>{personUserId?.value}</span>
) : (
<span className="text-slate-300">Not provided</span>
)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">Formbricks Id (internal)</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">{person.id}</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">Sessions</dt>
<dd className="mt-1 text-sm text-slate-900">{person.sessions.length}</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">Responses</dt>
<dd className="mt-1 text-sm text-slate-900">{person.responses.length}</dd>
</div>
{otherAttributes.map((attribute) => (
<div key={attribute.attributeClass.name}>
<dt className="text-sm font-medium text-slate-500">
{capitalizeFirstLetter(attribute.attributeClass.name)}
</dt>
<dd className="mt-1 text-sm text-slate-900">{attribute.value}</dd>
</div>
))}
</div>
<div className="md:col-span-2">
<div className="flex items-center justify-between pb-6">
<h2 className="text-lg font-bold text-slate-700">Responses</h2>
<div className="text-right">
<button
onClick={toggleSortResponses}
className="hover:text-brand-dark flex items-center px-1 text-slate-800">
<ArrowsUpDownIcon className="inline h-4 w-4" />
</button>
</div>
</div>
<ResponseFeed person={person} sortByDate={responsesAscending} environmentId={environmentId} />
</div>
<div className="md:col-span-1">
<div className="flex items-center justify-between pb-6">
<h2 className="text-lg font-bold text-slate-700">Activity Timeline</h2>
<div className="text-right">
<button
onClick={toggleSortActivity}
className="hover:text-brand-dark flex items-center px-1 text-slate-800">
<ArrowsUpDownIcon className="inline h-4 w-4" />
</button>
</div>
</div>
<ActivityFeed
sessions={person.sessions}
attributes={person.attributes}
displays={person.displays}
responses={person.responses}
sortByDate={activityAscending}
environmentId={environmentId}
/>
</div>
</div>
</section>
<DeleteDialog
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
deleteWhat="person"
onDelete={handleDeletePerson}
/>
</>
);
}

View File

@@ -0,0 +1,7 @@
"use server";
import { deletePerson } from "@formbricks/lib/services/person";
export const deletePersonAction = async (personId: string) => {
await deletePerson(personId);
};

View File

@@ -0,0 +1,145 @@
import { ArrowsUpDownIcon, TrashIcon } from "@heroicons/react/24/outline";
import {
ActivityItemPopover,
ActivityItemIcon,
} from "@/app/(app)/environments/[environmentId]/people/[personId]/(activitySection)/ActivityItemComponents";
import { BackIcon } from "@formbricks/ui";
import { TActivityFeedItem } from "@formbricks/types/v1/activity";
export default function Loading() {
const unifiedList: TActivityFeedItem[] = [
{
id: "clk9o7gnu000kz8kw4nb26o21",
type: "event",
actionType: "noCode",
createdAt: new Date(),
actionLabel: "Loading User Acitivity",
updatedAt: null,
attributeLabel: null,
attributeValue: null,
actionDescription: null,
displaySurveyName: null,
},
{
id: "clk9o7fwc000iz8kw4s0ha0ql",
type: "event",
actionType: "automatic",
createdAt: new Date(),
actionLabel: "Loading User Session Info",
updatedAt: null,
attributeLabel: null,
attributeValue: null,
actionDescription: null,
displaySurveyName: null,
},
];
return (
<div>
<main className="mx-auto px-4 sm:px-6 lg:px-8">
<div className="pointer-events-none animate-pulse cursor-not-allowed select-none">
<button className="inline-flex pt-5 text-sm text-slate-500">
<BackIcon className="mr-2 h-5 w-5" />
Back
</button>
</div>
<div className="flex items-baseline justify-between border-b border-slate-200 pb-6 pt-4">
<h1 className="ph-no-capture text-4xl font-bold tracking-tight text-slate-900">
<span className="animate-pulse rounded-full">Fetching user</span>
</h1>
<div className="flex items-center space-x-3">
<button className="pointer-events-none animate-pulse cursor-not-allowed select-none">
<TrashIcon className="h-5 w-5 text-slate-500 hover:text-red-700" />
</button>
</div>
</div>
<section className="pb-24 pt-6">
<div className="grid grid-cols-1 gap-x-8 md:grid-cols-4">
<div className="space-y-6">
<h2 className="text-lg font-bold text-slate-700">Attributes</h2>
<div>
<dt className="text-sm font-medium text-slate-500">Email</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
<span className="animate-pulse text-slate-300">Loading</span>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">User Id</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
<span className="animate-pulse text-slate-300">Loading</span>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">Formbricks Id (internal)</dt>
<dd className="mt-1 animate-pulse text-sm text-slate-300">Loading</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">Sessions</dt>
<dd className="mt-1 animate-pulse text-sm text-slate-300">Loading</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">Responses</dt>
<dd className="mt-1 animate-pulse text-sm text-slate-300">Loading</dd>
</div>
</div>
<div className="md:col-span-2">
<div className="flex items-center justify-between pb-6">
<h2 className="text-lg font-bold text-slate-700">Responses</h2>
<div className="text-right">
<button className="hover:text-brand-dark pointer-events-none flex animate-pulse cursor-not-allowed select-none items-center px-1 text-slate-800">
<ArrowsUpDownIcon className="inline h-4 w-4" />
</button>
</div>
</div>
<div className="group space-y-4 rounded-lg bg-white p-6 ">
<div className="flex items-center space-x-4">
<div className="h-12 w-12 flex-shrink-0 rounded-full bg-slate-100"></div>
<div className=" h-6 w-full rounded-full bg-slate-100"></div>
</div>
<div className="space-y-4">
<div className="h-12 w-full rounded-full bg-slate-100"></div>
<div className=" flex h-12 w-full items-center justify-center rounded-full bg-slate-50 text-sm text-slate-500 hover:bg-slate-100">
<span className="animate-pulse text-center">Loading user responses</span>
</div>
<div className="h-12 w-full rounded-full bg-slate-50/50"></div>
</div>
</div>
</div>
<div className="md:col-span-1">
<div className="flex items-center justify-between pb-6">
<h2 className="text-lg font-bold text-slate-700">Activity Timeline</h2>
<div className="text-right">
<button className="hover:text-brand-dark pointer-events-none flex animate-pulse cursor-not-allowed select-none items-center px-1 text-slate-800">
<ArrowsUpDownIcon className="inline h-4 w-4" />
</button>
</div>
</div>
<div>
{unifiedList.map((activityItem) => (
<li key={activityItem.id} className="list-none">
<div className="relative pb-12">
<span
className="absolute left-6 top-4 -ml-px h-full w-0.5 bg-slate-200"
aria-hidden="true"
/>
<div className="relative animate-pulse cursor-not-allowed select-none">
<ActivityItemPopover activityItem={activityItem}>
<div className="flex cursor-not-allowed select-none items-center space-x-3">
<ActivityItemIcon activityItem={activityItem} />
<div className="font-semibold text-slate-700">Loading</div>
</div>
</ActivityItemPopover>
</div>
</div>
</li>
))}
</div>
</div>
</div>
</section>
</main>
</div>
);
}

View File

@@ -1,10 +1,31 @@
import PersonDetails from "./PersonDetails";
export const revalidate = REVALIDATION_INTERVAL;
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { getPerson } from "@formbricks/lib/services/person";
import AttributesSection from "@/app/(app)/environments/[environmentId]/people/[personId]/(attributeSection)/AttributesSection";
import ActivitySection from "@/app/(app)/environments/[environmentId]/people/[personId]/(activitySection)/ActivitySection";
import HeadingSection from "@/app/(app)/environments/[environmentId]/people/[personId]/HeadingSection";
import ResponseSection from "@/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponseSection";
export default async function PersonPage({ params }) {
const person = await getPerson(params.personId);
if (!person) {
throw new Error("No such person found");
}
export default function PersonPage({ params }) {
return (
<div>
<main className="mx-auto px-4 sm:px-6 lg:px-8">
<PersonDetails personId={params.personId} environmentId={params.environmentId} />
<>
<HeadingSection environmentId={params.environmentId} person={person} />
<section className="pb-24 pt-6">
<div className="grid grid-cols-1 gap-x-8 md:grid-cols-4">
<AttributesSection personId={params.personId} />
<ResponseSection environmentId={params.environmentId} personId={params.personId} />
<ActivitySection environmentId={params.environmentId} personId={params.personId} />
</div>
</section>
</>
</main>
</div>
);

View File

@@ -223,6 +223,15 @@ export default function MultipleChoiceMultiForm({
Add &quot;Other&quot;
</Button>
)}
<Button
size="sm"
variant="minimal"
type="button"
onClick={() => {
updateQuestion(questionIdx, { type: "multipleChoiceSingle" });
}}>
Convert to Single Select
</Button>
<div className="flex flex-1 items-center justify-end gap-2">
<Select

View File

@@ -223,6 +223,15 @@ export default function MultipleChoiceSingleForm({
Add &quot;Other&quot;
</Button>
)}
<Button
size="sm"
variant="minimal"
type="button"
onClick={() => {
updateQuestion(questionIdx, { type: "multipleChoiceMulti" });
}}>
Convert to Multi Select
</Button>
<div className="flex flex-1 items-center justify-end gap-2">
<Select

View File

@@ -29,7 +29,9 @@ export default function Onboarding({ session }: OnboardingProps) {
error: isErrorEnvironment,
isLoading: isLoadingEnvironment,
} = useSWR(`/api/v1/environments/find-first`, fetcher);
const { profile } = useProfile();
const { triggerProfileMutate } = useProfileMutation();
const [formbricksResponseId, setFormbricksResponseId] = useState<ResponseId | undefined>();
const [currentStep, setCurrentStep] = useState(1);
@@ -56,28 +58,32 @@ export default function Onboarding({ session }: OnboardingProps) {
setCurrentStep(currentStep + 1);
};
const doLater = () => {
const doLater = async () => {
setCurrentStep(4);
};
const next = () => {
if (currentStep < MAX_STEPS) {
setCurrentStep((value) => value + 1);
return;
}
};
const done = async () => {
setIsLoading(true);
try {
const updatedProfile = { ...profile, onboardingCompleted: true };
await triggerProfileMutate(updatedProfile);
if (environment) {
router.push(`/environments/${environment.id}/surveys`);
return;
}
} catch (e) {
toast.error("An error occured saving your settings.");
setIsLoading(false);
console.error(e);
}
};

View File

@@ -57,6 +57,11 @@ const Product: React.FC<Product> = ({ done, isLoading, environmentId }) => {
toast.error("An error occured saving your settings");
console.error(e);
}
done();
};
const handleLaterClick = async () => {
done();
};
@@ -138,7 +143,7 @@ const Product: React.FC<Product> = ({ done, isLoading, environmentId }) => {
</div>
</div>
<div className="flex items-center justify-end">
<Button size="lg" className="mr-2" variant="minimal" id="product-skip" onClick={done}>
<Button size="lg" className="mr-2" variant="minimal" id="product-skip" onClick={handleLaterClick}>
I&apos;ll do it later
</Button>
<Button

View File

@@ -2,6 +2,7 @@ import { env } from "@/env.mjs";
import { verifyPassword } from "@/lib/auth";
import { verifyToken } from "@/lib/jwt";
import { prisma } from "@formbricks/database";
import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
import type { IdentityProvider } from "@prisma/client";
import type { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
@@ -254,7 +255,7 @@ export const authOptions: NextAuthOptions = {
return "/auth/login?error=A%20user%20with%20this%20email%20exists%20already.";
}
await prisma.user.create({
const createdUser = await prisma.user.create({
data: {
name: user.name,
email: user.email,
@@ -362,8 +363,21 @@ export const authOptions: NextAuthOptions = {
],
},
},
include: {
memberships: true,
},
});
const teamId = createdUser.memberships?.[0]?.teamId;
if (teamId) {
fetch(`${WEBAPP_URL}/api/v1/teams/${teamId}/add_demo_product`, {
method: "POST",
headers: {
"x-api-key": INTERNAL_SECRET,
},
});
}
return true;
}

View File

@@ -3,7 +3,7 @@ import { responses } from "@/lib/api/response";
import { transformErrorToDetails } from "@/lib/api/validator";
import { prisma } from "@formbricks/database";
import { getActionClasses } from "@formbricks/lib/services/actionClass";
import { getPerson, select, transformPrismaPerson } from "@formbricks/lib/services/person";
import { getPerson, selectPerson, transformPrismaPerson } from "@formbricks/lib/services/person";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { extendSession } from "@formbricks/lib/services/session";
import { TJsState, ZJsPeopleAttributeInput } from "@formbricks/types/v1/js";
@@ -94,7 +94,7 @@ export async function POST(req: Request, { params }): Promise<NextResponse> {
},
select: {
person: {
select,
select: selectPerson,
},
},
});

View File

@@ -3,7 +3,7 @@ import { responses } from "@/lib/api/response";
import { transformErrorToDetails } from "@/lib/api/validator";
import { prisma } from "@formbricks/database";
import { getActionClasses } from "@formbricks/lib/services/actionClass";
import { deletePerson, select, transformPrismaPerson } from "@formbricks/lib/services/person";
import { deletePerson, selectPerson, transformPrismaPerson } from "@formbricks/lib/services/person";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { extendSession } from "@formbricks/lib/services/session";
import { TJsState, ZJsPeopleUserIdInput } from "@formbricks/types/v1/js";
@@ -45,7 +45,7 @@ export async function POST(req: Request, { params }): Promise<NextResponse> {
},
},
},
select,
select: selectPerson,
});
// if person exists, reconnect session and delete old user
if (existingPerson) {
@@ -87,7 +87,7 @@ export async function POST(req: Request, { params }): Promise<NextResponse> {
},
},
},
select,
select: selectPerson,
});
}

View File

@@ -0,0 +1,25 @@
import { INTERNAL_SECRET } from "@formbricks/lib/constants";
import { createDemoProduct } from "@formbricks/lib/services/team";
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { responses } from "@/lib/api/response";
export async function POST(_: Request, { params }: { params: { teamId: string } }) {
// Check Authentication
if (headers().get("x-api-key") !== INTERNAL_SECRET) {
return responses.notAuthenticatedResponse();
}
const teamId = params.teamId;
if (teamId === undefined) {
return responses.badRequestResponse("Missing teamId");
}
try {
const demoProduct = await createDemoProduct(teamId);
return NextResponse.json(demoProduct);
} catch (err) {
throw new Error(err);
}
}

View File

@@ -4,6 +4,8 @@ import { populateEnvironment } from "@/lib/populate";
import { prisma } from "@formbricks/database";
import { NextResponse } from "next/server";
import { env } from "@/env.mjs";
import { Prisma } from "@prisma/client";
import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
export async function POST(request: Request) {
let { inviteToken, ...user } = await request.json();
@@ -15,7 +17,7 @@ export async function POST(request: Request) {
let inviteId;
try {
let data;
let data: Prisma.UserCreateArgs;
let invite;
if (inviteToken) {
@@ -89,7 +91,26 @@ export async function POST(request: Request) {
};
}
const userData = await prisma.user.create(data);
type UserWithMemberships = Prisma.UserGetPayload<{ include: { memberships: true } }>;
const userData = (await prisma.user.create({
...data,
include: {
memberships: true,
},
// TODO: This is a hack to get the correct types (casting), we should find a better way to do this
})) as UserWithMemberships;
const teamId = userData.memberships[0].teamId;
if (teamId) {
fetch(`${WEBAPP_URL}/api/v1/teams/${teamId}/add_demo_product`, {
method: "POST",
headers: {
"x-api-key": INTERNAL_SECRET,
},
});
}
if (inviteId) {
sendInviteAcceptedEmail(invite.creator.name, user.name, invite.creator.email);

View File

@@ -27,7 +27,9 @@ export const SignupForm = () => {
if (!isValid) {
return;
}
setSigningUp(true);
try {
await createUser(
e.target.elements.name.value,

View File

@@ -39,7 +39,8 @@ export default function MultipleChoiceMultiQuestion({
.map((choice) => choice.label);
useEffect(() => {
const nonOtherSavedChoices = storedResponseValue?.filter((answer) =>
if(Array.isArray(storedResponseValue)){
const nonOtherSavedChoices = storedResponseValue?.filter((answer) =>
nonOtherChoiceLabels.includes(answer)
);
const savedOtherSpecified = storedResponseValue?.find((answer) => !nonOtherChoiceLabels.includes(answer));
@@ -50,6 +51,7 @@ export default function MultipleChoiceMultiQuestion({
setOtherSpecified(savedOtherSpecified);
setShowOther(true);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storedResponseValue, question.id]);

View File

@@ -25,6 +25,7 @@ export default function CreateTeamModal({ open, setOpen }: CreateTeamModalProps)
const submitTeam = async (data) => {
setLoading(true);
const newTeam = await createTeam(data.name, (profile as any).id);
const newMemberships = await mutateMemberships();
changeEnvironmentByTeam(newTeam.id, newMemberships, router);
toast.success("Team created successfully!");

View File

@@ -1,4 +1,7 @@
export function capitalizeFirstLetter(string = "") {
export function capitalizeFirstLetter(string: string | null = "") {
if (string === null) {
return "";
}
return string.charAt(0).toUpperCase() + string.slice(1);
}

View File

@@ -19,7 +19,8 @@
"markdown-it": "^13.0.1",
"posthog-node": "^3.1.1",
"server-only": "^0.0.1",
"tailwind-merge": "^1.14.0"
"tailwind-merge": "^1.14.0",
"@paralleldrive/cuid2": "^2.2.1"
},
"devDependencies": {
"@formbricks/tsconfig": "*",

View File

@@ -0,0 +1,78 @@
import { prisma } from "@formbricks/database";
import { TActivityFeedItem } from "@formbricks/types/v1/activity";
export const getActivityTimeline = async (personId: string): Promise<TActivityFeedItem[]> => {
const person = await prisma.person.findUnique({
where: {
id: personId,
},
include: {
attributes: {
include: {
attributeClass: true,
},
},
displays: {
include: {
survey: true,
},
},
sessions: {
include: {
events: {
include: {
eventClass: true,
},
},
},
},
},
});
if (!person) {
throw new Error("No such person found");
}
const { attributes, displays, sessions } = person;
const unifiedAttributes: TActivityFeedItem[] = attributes.map((attribute) => ({
id: attribute.id,
type: "attribute",
createdAt: attribute.createdAt,
updatedAt: attribute.updatedAt,
attributeLabel: attribute.attributeClass.name,
attributeValue: attribute.value,
actionLabel: null,
actionDescription: null,
actionType: null,
displaySurveyName: null,
}));
const unifiedDisplays: TActivityFeedItem[] = displays.map((display) => ({
id: display.id,
type: "display",
createdAt: display.createdAt,
updatedAt: display.updatedAt,
attributeLabel: null,
attributeValue: null,
actionLabel: null,
actionDescription: null,
actionType: null,
displaySurveyName: display.survey.name,
}));
const unifiedEvents: TActivityFeedItem[] = sessions.flatMap((session) =>
session.events.map((event) => ({
id: event.id,
type: "event",
createdAt: event.createdAt,
updatedAt: null,
attributeLabel: null,
attributeValue: null,
actionLabel: event.eventClass?.name || null,
actionDescription: event.eventClass?.description || null,
actionType: event.eventClass?.type || null,
displaySurveyName: null,
}))
);
const unifiedList: TActivityFeedItem[] = [...unifiedAttributes, ...unifiedDisplays, ...unifiedEvents];
return unifiedList;
};

View File

@@ -1,5 +1,5 @@
import { prisma } from "@formbricks/database";
import { TDisplay, TDisplayInput } from "@formbricks/types/v1/displays";
import { TDisplay, TDisplayInput, TDisplaysWithSurveyName } from "@formbricks/types/v1/displays";
import { Prisma } from "@prisma/client";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors";
import { transformPrismaPerson } from "./person";
@@ -98,3 +98,52 @@ export const markDisplayResponded = async (displayId: string): Promise<TDisplay>
throw error;
}
};
export const getDisplaysOfPerson = async (personId: string): Promise<TDisplaysWithSurveyName[] | null> => {
try {
const displaysPrisma = await prisma.display.findMany({
where: {
personId: personId,
},
select: {
id: true,
createdAt: true,
updatedAt: true,
surveyId: true,
survey: {
select: {
name: true,
},
},
status: true,
},
});
if (!displaysPrisma) {
throw new ResourceNotFoundError("Display from PersonId", personId);
}
let displays: TDisplaysWithSurveyName[] = [];
displaysPrisma.forEach((displayPrisma) => {
const display: TDisplaysWithSurveyName = {
id: displayPrisma.id,
createdAt: displayPrisma.createdAt,
updatedAt: displayPrisma.updatedAt,
person: null,
status: displayPrisma.status,
surveyId: displayPrisma.surveyId,
surveyName: displayPrisma.survey.name,
};
displays.push(display);
});
return displays;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
};

View File

@@ -1,14 +1,21 @@
import "server-only";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors";
import { TPerson } from "@formbricks/types/v1/people";
import { Prisma } from "@prisma/client";
import { cache } from "react";
export const select = {
export const selectPerson = {
id: true,
createdAt: true,
updatedAt: true,
attributes: {
where: {
attributeClass: {
archived: false,
},
},
select: {
value: true,
attributeClass: {
@@ -52,7 +59,7 @@ export const getPerson = async (personId: string): Promise<TPerson | null> => {
where: {
id: personId,
},
select,
select: selectPerson,
});
if (!personPrisma) {
@@ -77,7 +84,7 @@ export const getPeople = cache(async (environmentId: string): Promise<TPerson[]>
where: {
environmentId: environmentId,
},
select,
select: selectPerson,
});
if (!personsPrisma) {
throw new ResourceNotFoundError("Persons", "All Persons");
@@ -107,7 +114,7 @@ export const createPerson = async (environmentId: string): Promise<TPerson> => {
},
},
},
select,
select: selectPerson,
});
const person = transformPrismaPerson(personPrisma);

View File

@@ -1,7 +1,7 @@
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors";
import { TPerson } from "@formbricks/types/v1/people";
import { TResponse, TResponseInput, TResponseUpdateInput } from "@formbricks/types/v1/responses";
import { TPerson } from "@formbricks/types/v1/people";
import { TTag } from "@formbricks/types/v1/tags";
import { Prisma } from "@prisma/client";
import { cache } from "react";
@@ -63,6 +63,39 @@ const responseSelection = {
},
};
export const getResponsesByPersonId = async (personId: string): Promise<Array<TResponse> | null> => {
try {
const responsePrisma = await prisma.response.findMany({
where: {
personId,
},
select: responseSelection,
});
if (!responsePrisma) {
throw new ResourceNotFoundError("Response from PersonId", personId);
}
let responses: Array<TResponse> = [];
responsePrisma.forEach((response) => {
responses.push({
...response,
person: response.person ? transformPrismaPerson(response.person) : null,
tags: response.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
});
});
return responses;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
};
export const createResponse = async (responseInput: Partial<TResponseInput>): Promise<TResponse> => {
try {
let person: TPerson | null = null;

View File

@@ -1,7 +1,11 @@
"use server";
import "server-only";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/errors";
import { TSession } from "@formbricks/types/v1/sessions";
import { TSession, TSessionWithActions } from "@formbricks/types/v1/sessions";
import { Prisma } from "@prisma/client";
import { cache } from "react";
const select = {
id: true,
@@ -32,6 +36,58 @@ export const getSession = async (sessionId: string): Promise<TSession | null> =>
}
};
export const getSessionWithActionsOfPerson = async (
personId: string
): Promise<TSessionWithActions[] | null> => {
try {
const sessionsWithActionsForPerson = await prisma.session.findMany({
where: {
personId,
},
select: {
id: true,
events: {
select: {
id: true,
createdAt: true,
eventClass: {
select: {
name: true,
description: true,
type: true,
},
},
},
},
},
});
if (!sessionsWithActionsForPerson) return null;
return sessionsWithActionsForPerson;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
};
export const getSessionCount = cache(async (personId: string): Promise<number> => {
try {
const sessionCount = await prisma.session.count({
where: {
personId,
},
});
return sessionCount;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
});
export const createSession = async (personId: string): Promise<TSession> => {
try {
const session = await prisma.session.create({

View File

@@ -1,8 +1,27 @@
import { cache } from "react";
import { prisma } from "@formbricks/database";
import { Prisma } from "@prisma/client";
import { DatabaseError } from "@formbricks/errors";
import { TTeam } from "@formbricks/types/v1/teams";
import { Prisma } from "@prisma/client";
import { cache } from "react";
import { createId } from "@paralleldrive/cuid2";
import {
ChurnResponses,
ChurnSurvey,
DEMO_COMPANIES,
DEMO_NAMES,
EASResponses,
EASSurvey,
InterviewPromptResponses,
InterviewPromptSurvey,
OnboardingResponses,
OnboardingSurvey,
PMFResponses,
PMFSurvey,
generateAttributeValue,
generateResponsesAndDisplays,
populateEnvironment,
updateEnvironmentArgs,
} from "../utils/createDemoProductHelpers";
export const select = {
id: true,
@@ -38,3 +57,183 @@ export const getTeamByEnvironmentId = cache(async (environmentId: string): Promi
throw error;
}
});
export const createDemoProduct = cache(async (teamId: string) => {
const productWithEnvironment = Prisma.validator<Prisma.ProductArgs>()({
include: {
environments: true,
},
});
type ProductWithEnvironment = Prisma.ProductGetPayload<typeof productWithEnvironment>;
const demoProduct: ProductWithEnvironment = await prisma.product.create({
data: {
name: "Demo Product",
team: {
connect: {
id: teamId,
},
},
environments: {
create: [
{
type: "production",
...populateEnvironment,
},
{
type: "development",
...populateEnvironment,
},
],
},
},
include: {
environments: true,
},
});
const prodEnvironment = demoProduct.environments.find((environment) => environment.type === "production");
// add attributes to each environment of the product
// dont add dev environment
const updatedEnvironment = await prisma.environment.update({
where: { id: prodEnvironment?.id },
data: {
...updateEnvironmentArgs,
},
include: {
attributeClasses: true, // include attributeClasses
eventClasses: true, // include eventClasses
},
});
const eventClasses = updatedEnvironment.eventClasses;
// check if updatedEnvironment exists and it has attributeClasses
if (!updatedEnvironment || !updatedEnvironment.attributeClasses) {
throw new Error("Attribute classes could not be created");
}
const attributeClasses = updatedEnvironment.attributeClasses;
// create an array for all the events that will be created
const eventPromises: {
eventClassId: string;
sessionId: string;
}[] = [];
// create an array for all the attributes that will be created
const generatedAttributes: {
attributeClassId: string;
value: string;
personId: string;
}[] = [];
// create an array containing all the person ids to be created
const personIds = Array.from({ length: 20 }).map((_) => createId());
// create an array containing all the session ids to be created
const sessionIds = Array.from({ length: 20 }).map((_) => createId());
// loop over the person ids and create attributes for each person
personIds.forEach((personId, i: number) => {
generatedAttributes.push(
...attributeClasses.map((attributeClass) => {
let value = generateAttributeValue(
attributeClass.name,
DEMO_NAMES[i],
DEMO_COMPANIES[i],
`${DEMO_COMPANIES[i].toLowerCase().split(" ").join("")}.com`,
i
);
return {
attributeClassId: attributeClass.id,
value: value,
personId,
};
})
);
});
sessionIds.forEach((sessionId) => {
for (let eventClass of eventClasses) {
// create a random number of events for each event class
const eventCount = Math.floor(Math.random() * 5) + 1;
for (let j = 0; j < eventCount; j++) {
eventPromises.push({
eventClassId: eventClass.id,
sessionId,
});
}
}
});
// create the people, sessions, attributes, and events in a transaction
// the order of the queries is important because of foreign key constraints
try {
await prisma.$transaction([
prisma.person.createMany({
data: personIds.map((personId) => ({
id: personId,
environmentId: demoProduct.environments[0].id,
})),
}),
prisma.session.createMany({
data: sessionIds.map((sessionId, idx) => ({
id: sessionId,
personId: personIds[idx],
})),
}),
prisma.attribute.createMany({
data: generatedAttributes,
}),
prisma.event.createMany({
data: eventPromises.map((eventPromise) => ({
eventClassId: eventPromise.eventClassId,
sessionId: eventPromise.sessionId,
})),
}),
]);
} catch (err: any) {
throw new Error(err);
}
// Create a function that creates a survey
const createSurvey = async (surveyData: any, responses: any, displays: any) => {
return await prisma.survey.create({
data: {
...surveyData,
environment: { connect: { id: demoProduct.environments[0].id } },
questions: surveyData.questions as any,
responses: { create: responses },
displays: { create: displays },
},
});
};
const people = personIds.map((personId) => ({ id: personId }));
const PMFResults = generateResponsesAndDisplays(people, PMFResponses);
const OnboardingResults = generateResponsesAndDisplays(people, OnboardingResponses);
const ChurnResults = generateResponsesAndDisplays(people, ChurnResponses);
const EASResults = generateResponsesAndDisplays(people, EASResponses);
const InterviewPromptResults = generateResponsesAndDisplays(people, InterviewPromptResponses);
// Create the surveys
await createSurvey(PMFSurvey, PMFResults.responses, PMFResults.displays);
await createSurvey(OnboardingSurvey, OnboardingResults.responses, OnboardingResults.displays);
await createSurvey(ChurnSurvey, ChurnResults.responses, ChurnResults.displays);
await createSurvey(EASSurvey, EASResults.responses, EASResults.displays);
await createSurvey(
InterviewPromptSurvey,
InterviewPromptResults.responses,
InterviewPromptResults.displays
);
return demoProduct;
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
import { z } from "zod";
export const ZActivityFeedItem = z.object({
id: z.string().cuid2(),
type: z.enum(["event", "attribute", "display"]),
createdAt: z.date(),
updatedAt: z.date().nullable(),
attributeLabel: z.string().nullable(),
attributeValue: z.string().nullable(),
actionLabel: z.string().nullable(),
actionDescription: z.string().nullable(),
actionType: z.string().nullable(),
displaySurveyName: z.string().nullable(),
});
export type TActivityFeedItem = z.infer<typeof ZActivityFeedItem>;

View File

@@ -18,3 +18,9 @@ export const ZDisplayInput = z.object({
});
export type TDisplayInput = z.infer<typeof ZDisplayInput>;
export const ZDisplaysWithSurveyName = ZDisplay.extend({
surveyName: z.string(),
});
export type TDisplaysWithSurveyName = z.infer<typeof ZDisplaysWithSurveyName>;

View File

@@ -1,5 +1,6 @@
import { z } from "zod";
import { ZPersonAttributes } from "./people";
import { ZSurvey } from "./surveys";
import { ZTag } from "./tags";
export const ZResponseData = z.record(z.union([z.string(), z.number(), z.array(z.string())]));
@@ -88,3 +89,9 @@ export const ZResponseUpdateInput = z.object({
});
export type TResponseUpdateInput = z.infer<typeof ZResponseUpdateInput>;
export const ZResponseWithSurvey = ZResponse.extend({
survey: ZSurvey,
});
export type TResponseWithSurvey = z.infer<typeof ZResponseWithSurvey>;

View File

@@ -9,3 +9,22 @@ export const ZSession = z.object({
});
export type TSession = z.infer<typeof ZSession>;
export const ZSessionWithActions = z.object({
id: z.string().cuid2(),
events: z.array(
z.object({
id: z.string().cuid2(),
createdAt: z.date(),
eventClass: z
.object({
name: z.string(),
description: z.union([z.string(), z.null()]),
type: z.enum(["code", "noCode", "automatic"]),
})
.nullable(),
})
),
});
export type TSessionWithActions = z.infer<typeof ZSessionWithActions>;

3
pnpm-lock.yaml generated
View File

@@ -440,6 +440,9 @@ importers:
'@formbricks/types':
specifier: '*'
version: link:../types
'@paralleldrive/cuid2':
specifier: ^2.2.1
version: 2.2.1
date-fns:
specifier: ^2.30.0
version: 2.30.0