mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-30 19:09:48 -06:00
Merge branch 'main' of https://github.com/formbricks/formbricks into feature/delete-team
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { deletePerson } from "@formbricks/lib/services/person";
|
||||
|
||||
export const deletePersonAction = async (personId: string) => {
|
||||
await deletePerson(personId);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -223,6 +223,15 @@ export default function MultipleChoiceMultiForm({
|
||||
Add "Other"
|
||||
</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
|
||||
|
||||
@@ -223,6 +223,15 @@ export default function MultipleChoiceSingleForm({
|
||||
Add "Other"
|
||||
</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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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'll do it later
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
25
apps/web/app/api/v1/teams/[teamId]/add_demo_product/route.ts
Normal file
25
apps/web/app/api/v1/teams/[teamId]/add_demo_product/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -27,7 +27,9 @@ export const SignupForm = () => {
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSigningUp(true);
|
||||
|
||||
try {
|
||||
await createUser(
|
||||
e.target.elements.name.value,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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!");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "*",
|
||||
|
||||
78
packages/lib/services/activity.tsx
Normal file
78
packages/lib/services/activity.tsx
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
1101
packages/lib/utils/createDemoProductHelpers.ts
Normal file
1101
packages/lib/utils/createDemoProductHelpers.ts
Normal file
File diff suppressed because it is too large
Load Diff
16
packages/types/v1/activity.ts
Normal file
16
packages/types/v1/activity.ts
Normal 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>;
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user