diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/ResponseNote.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/ResponseNote.tsx new file mode 100644 index 0000000000..9d5c77efdc --- /dev/null +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/ResponseNote.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { timeSince } from "@formbricks/lib/time"; +import { EyeSlashIcon, PlusIcon } from "@heroicons/react/24/solid"; +import { useState } from "react"; +import toast from "react-hot-toast"; +import { useResponses } from "@/lib/responses/responses"; +import { Button } from "@formbricks/ui"; +import clsx from "clsx"; +import { addResponseNote } from "@/lib/responseNote/responsesNote"; +import { FormEvent } from "react"; +import { OpenTextSummaryProps } from "@/app/environments/[environmentId]/surveys/[surveyId]/responses/SingleResponse"; + +export default function ResponseNote({ data, environmentId, surveyId, isOpen, setIsOpen }: OpenTextSummaryProps & { isOpen: boolean, setIsOpen: (isOpen: boolean) => void}) { + const [noteText, setNoteText] = useState(''); + const [isCreatingNote, setIsCreatingNote] = useState(false); + const { mutateResponses } = useResponses(environmentId, surveyId); + const responseNotes = data?.responseNote; + const handleNoteSubmission = async (e: FormEvent) => { + e.preventDefault() + setIsCreatingNote(true) + try { + await addResponseNote(environmentId, surveyId, data?.id, noteText) + mutateResponses() + setIsCreatingNote(false) + setNoteText('') + } catch (e) { + toast.error("An error occurred creating a new note"); + setIsCreatingNote(false) + } + } + + return ( +
{ + if(!isOpen) setIsOpen(true) + }} + > + {!isOpen ? +
+
+ {!responseNotes.length ? +
+
+

Note

+
+
: null} +
+ {!responseNotes.length ? +
+ +
: null} +
: +
+
+
+
+

Note

+
+
{ + setIsOpen(!isOpen) + }} + > + +
+
+
+
+ {responseNotes.map((note) => ( +
+ + {note.user.name} wrote {" "} + + + {note.text} +
+ ))} +
+
+
+
+ +
+
+ +
+
+
+
} +
+ ); +} diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/SingleResponse.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/SingleResponse.tsx index 3b3d52c767..91d50d1efe 100644 --- a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/SingleResponse.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/SingleResponse.tsx @@ -10,8 +10,10 @@ import { useState } from "react"; import toast from "react-hot-toast"; import { RatingResponse } from "../RatingResponse"; import { deleteSubmission, useResponses } from "@/lib/responses/responses"; +import clsx from "clsx"; +import ResponseNote from "@/app/environments/[environmentId]/surveys/[surveyId]/responses/ResponseNote"; -interface OpenTextSummaryProps { +export interface OpenTextSummaryProps { data: { id: string; personId: string; @@ -23,6 +25,15 @@ interface OpenTextSummaryProps { environmentId: string; attributes: []; }; + responseNote: { + updatedAt: string; + createdAt: string; + id: string; + text: string; + user: { + name: string + } + }[] value: string; updatedAt: string; finished: boolean; @@ -50,6 +61,7 @@ export default function SingleResponse({ data, environmentId, surveyId }: OpenTe const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const { mutateResponses } = useResponses(environmentId, surveyId); const [isDeleting, setIsDeleting] = useState(false); + const [isOpen, setIsOpen] = useState(false) const handleDeleteSubmission = async () => { setIsDeleting(true); @@ -61,68 +73,71 @@ export default function SingleResponse({ data, environmentId, surveyId }: OpenTe }; return ( -
-
-
- {data.personId ? ( - - -

- {displayIdentifier} -

- - ) : ( -
- -

Anonymous

-
- )} - -
- {data.finished && ( - - Completed - +
+
+
+
+ {data.personId ? ( + + +

+ {displayIdentifier} +

+ + ) : ( +
+ +

Anonymous

+
)} - - + +
+ {data.finished && ( + + Completed + + )} + + +
-
-
- {data.responses.map((response, idx) => ( -
-

{response.question}

- {typeof response.answer !== "object" ? ( - response.type === "rating" ? ( -
- -
+
+ {data.responses.map((response, idx) => ( +
+

{response.question}

+ {typeof response.answer !== "object" ? ( + response.type === "rating" ? ( +
+ +
+ ) : ( +

{response.answer}

+ ) ) : ( -

{response.answer}

- ) - ) : ( -

{response.answer.join(", ")}

- )} -
- ))} +

{response.answer.join(", ")}

+ )} +
+ ))} +
+
- +
); } diff --git a/apps/web/lib/responseNote/responsesNote.ts b/apps/web/lib/responseNote/responsesNote.ts new file mode 100644 index 0000000000..90b9c0af96 --- /dev/null +++ b/apps/web/lib/responseNote/responsesNote.ts @@ -0,0 +1,21 @@ +export const addResponseNote = async ( + environmentId: string, + surveyId: string, + responseId: string, + text: string +) => { + try { + const res = await fetch( + `/api/v1/environments/${environmentId}/surveys/${surveyId}/responses/${responseId}/responsesNote`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(text), + } + ); + return await res.json(); + } catch (error) { + console.error(error); + throw Error(`createResponseNote: unable to create responseNote: ${error.message}`); + } +}; diff --git a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/[submissionId]/responsesNote/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/[submissionId]/responsesNote/index.ts new file mode 100644 index 0000000000..979650388b --- /dev/null +++ b/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/[submissionId]/responsesNote/index.ts @@ -0,0 +1,86 @@ +import { captureTelemetry } from "@/../../packages/lib/telemetry"; +import { hasEnvironmentAccess, getSessionUser } from "@/lib/api/apiHelper"; +import { prisma } from "@formbricks/database"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { responses } from "@/lib/api/response"; + +export default async function handle(req: NextApiRequest, res: NextApiResponse) { + const environmentId = req.query.environmentId?.toString(); + const responseId = req.query.submissionId?.toString(); + const surveyId = req.query.surveyId?.toString(); + + // Check Authentication + const currentUser: any = await getSessionUser(req, res); + if (!currentUser) { + return res.status(401).json({ message: "Not authenticated" }); + } + + // Check environmentId + if (environmentId === undefined) { + return res.status(400).json({ message: "Missing environmentId" }); + } + + // Check responseId + if (responseId === undefined) { + return res.status(400).json({ message: "Missing responseId" }); + } + + // Check surveyId + if (surveyId === undefined) { + return res.status(400).json({ message: "Missing surveyId" }); + } + + // Check whether user has access to the environment + const hasAccess = await hasEnvironmentAccess(req, res, environmentId); + if (!hasAccess) { + return res.status(403).json({ message: "Not authorized" }); + } + + // GET /api/environments[environmentId]/survey[surveyId]/responses/[responseId]/responsesNote + // Create a note to a response + if (req.method === "POST") { + const currentResponse = await prisma.response.findUnique({ + where: { + id: responseId, + }, + select: { + data: true, + survey: { + select: { + environmentId: true, + }, + }, + }, + }); + + if (!currentResponse) { + return responses.notFoundResponse("Response", responseId, true); + } + const responseNote = { + data: { + createdAt: new Date(), + updatedAt: new Date(), + response: { + connect: { + id: responseId, + }, + }, + user: { + connect: { + id: currentUser.id, + }, + }, + text: req.body, + }, + }; + + const newResponseNote = await prisma.responseNote.create(responseNote); + captureTelemetry("responseNote created"); + return res.json(newResponseNote); + } + + // Unknown HTTP Method + else { + throw new Error(`The HTTP ${req.method} method is not supported by this route.`); + } +} diff --git a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/index.ts index f7e748dc5b..e667525b9f 100644 --- a/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/index.ts +++ b/apps/web/pages/api/v1/environments/[environmentId]/surveys/[surveyId]/responses/index.ts @@ -49,6 +49,12 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse) }, }, }, + responseNote: { + include: { + response: true, + user: true, + }, + }, }, }); diff --git a/packages/database/prisma/migrations/20230605074319_add_model_response_note/migration.sql b/packages/database/prisma/migrations/20230605074319_add_model_response_note/migration.sql new file mode 100644 index 0000000000..5a5873c163 --- /dev/null +++ b/packages/database/prisma/migrations/20230605074319_add_model_response_note/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "ResponseNote" ( + "id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "responseId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "text" TEXT NOT NULL, + + CONSTRAINT "ResponseNote_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "ResponseNote" ADD CONSTRAINT "ResponseNote_responseId_fkey" FOREIGN KEY ("responseId") REFERENCES "Response"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ResponseNote" ADD CONSTRAINT "ResponseNote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index b925a6ba27..39285c61d2 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -85,20 +85,32 @@ model Person { } model Response { - id String @id @default(cuid()) - createdAt DateTime @default(now()) @map(name: "created_at") - updatedAt DateTime @updatedAt @map(name: "updated_at") - finished Boolean @default(false) - survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade) - surveyId String - person Person? @relation(fields: [personId], references: [id], onDelete: Cascade) - personId String? + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + finished Boolean @default(false) + survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade) + surveyId String + person Person? @relation(fields: [personId], references: [id], onDelete: Cascade) + personId String? + responseNote ResponseNote[] /// [ResponseData] data Json @default("{}") /// [ResponseMeta] meta Json @default("{}") } +model ResponseNote { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + response Response @relation(fields: [responseId], references: [id], onDelete: Cascade) + responseId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + text String +} + enum SurveyStatus { draft inProgress @@ -394,6 +406,7 @@ model User { identityProviderAccountId String? memberships Membership[] accounts Account[] + responseNote ResponseNote[] groupId String? invitesCreated Invite[] @relation("inviteCreatedBy") invitesAccepted Invite[] @relation("inviteAcceptedBy")