mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-26 19:00:22 -06:00
Rewrite Person Detail Page to Server Components (#609)
* feat: migration /[personId] page to server side * feat: decouple components in person page * fix: ZDisplaysWithSurveyName now extends the ZDisplay type * feat: drop custom service and use existing service for survey and response * run pnpm format * shift data fetching to component level but still server side * rename event to action * move special person services to activity service * remove activityFeedItem type in ActivityFeed * simplify TResponseWithSurvey --------- Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
committed by
GitHub
parent
5c9605f4af
commit
fdb1aa2299
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
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({
|
||||
|
||||
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>;
|
||||
|
||||
Reference in New Issue
Block a user