diff --git a/apps/app/src/actions/policies/create-policy-comment.ts b/apps/app/src/actions/policies/create-policy-comment.ts
new file mode 100644
index 0000000000..d896b50112
--- /dev/null
+++ b/apps/app/src/actions/policies/create-policy-comment.ts
@@ -0,0 +1,38 @@
+"use server";
+
+import { db } from "@bubba/db";
+import { revalidatePath, revalidateTag } from "next/cache";
+import { authActionClient } from "../safe-action";
+import { createPolicyCommentSchema } from "../schema";
+
+export const createPolicyCommentAction = authActionClient
+ .schema(createPolicyCommentSchema)
+ .metadata({
+ name: "create-policy-comment",
+ track: {
+ event: "create-policy-comment",
+ channel: "server",
+ },
+ })
+ .action(async ({ parsedInput, ctx }) => {
+ const { policyId, content } = parsedInput;
+ const { user } = ctx;
+
+ if (!user.id || !user.organizationId) {
+ throw new Error("Invalid user input");
+ }
+
+ await db.policyComments.create({
+ data: {
+ organizationPolicyId: policyId,
+ content,
+ ownerId: user.id,
+ organizationId: user.organizationId,
+ },
+ });
+
+ revalidatePath(`/policies/all/${policyId}`);
+ revalidateTag(`policy_${user.organizationId}`);
+
+ return { success: true };
+ });
diff --git a/apps/app/src/actions/schema.ts b/apps/app/src/actions/schema.ts
index e12ed67f11..6afefa635b 100644
--- a/apps/app/src/actions/schema.ts
+++ b/apps/app/src/actions/schema.ts
@@ -309,3 +309,17 @@ export const apiKeySchema = z.object({
.max(64, { message: "Name must be less than 64 characters" }),
expiresAt: z.enum(["30days", "90days", "1year", "never"]),
});
+
+export const createPolicyCommentSchema = z.object({
+ policyId: z.string().min(1, {
+ message: "Policy ID is required",
+ }),
+ content: z
+ .string()
+ .min(1, {
+ message: "Comment content is required",
+ })
+ .max(1000, {
+ message: "Comment content should be at most 1000 characters",
+ }),
+});
diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/[policyId]/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/[policyId]/page.tsx
index 86d7e4c5be..89227fc5b3 100644
--- a/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/[policyId]/page.tsx
+++ b/apps/app/src/app/[locale]/(app)/(dashboard)/policies/all/[policyId]/page.tsx
@@ -1,4 +1,5 @@
import { auth } from "@/auth";
+import { PolicyComment } from "@/components/policies/policy-comments";
import { PolicyOverview } from "@/components/policies/policy-overview";
import { getI18n } from "@/locales/server";
import { db } from "@bubba/db";
@@ -8,71 +9,73 @@ import { unstable_cache } from "next/cache";
import { redirect } from "next/navigation";
export default async function PolicyDetails({
- params,
+ params,
}: {
- params: Promise<{ locale: string; policyId: string }>;
+ params: Promise<{ locale: string; policyId: string }>;
}) {
- const { locale, policyId } = await params;
+ const { locale, policyId } = await params;
- setStaticParamsLocale(locale);
+ setStaticParamsLocale(locale);
- const session = await auth();
- const organizationId = session?.user.organizationId;
+ const session = await auth();
+ const organizationId = session?.user.organizationId;
- if (!organizationId) {
- redirect("/");
- }
+ if (!organizationId) {
+ redirect("/");
+ }
- const policy = await getPolicy(policyId, organizationId);
- const users = await getUsers(organizationId);
+ const policy = await getPolicy(policyId, organizationId);
+ const users = await getUsers(organizationId);
- if (!policy) {
- redirect("/policies");
- }
+ if (!policy) {
+ redirect("/policies");
+ }
- return (
-
- );
+ return (
+
+ );
}
export async function generateMetadata({
- params,
+ params,
}: {
- params: Promise<{ locale: string; policyId: string }>;
+ params: Promise<{ locale: string; policyId: string }>;
}): Promise {
- const { locale } = await params;
+ const { locale } = await params;
- setStaticParamsLocale(locale);
- const t = await getI18n();
+ setStaticParamsLocale(locale);
+ const t = await getI18n();
- return {
- title: t("sub_pages.policies.policy_details"),
- };
+ return {
+ title: t("sub_pages.policies.policy_details"),
+ };
}
const getPolicy = unstable_cache(
- async (policyId: string, organizationId: string) => {
- const policy = await db.organizationPolicy.findUnique({
- where: { id: policyId, organizationId },
- include: {
- policy: true,
- },
- });
- return policy;
- },
- ["policy-details"],
- { tags: ["policies", "policy-details"] },
+ async (policyId: string, organizationId: string) => {
+ const policy = await db.organizationPolicy.findUnique({
+ where: { id: policyId, organizationId },
+ include: {
+ policy: true,
+ PolicyComments: true,
+ },
+ });
+ return policy;
+ },
+ ["policy-details"],
+ { tags: ["policies", "policy-details"] },
);
const getUsers = unstable_cache(
- async (organizationId: string) => {
- const users = await db.user.findMany({
- where: { organizationId: organizationId },
- });
+ async (organizationId: string) => {
+ const users = await db.user.findMany({
+ where: { organizationId: organizationId },
+ });
- return users;
- },
- ["users-cache"],
+ return users;
+ },
+ ["users-cache"],
);
diff --git a/apps/app/src/components/forms/policies/create-policy-comment-form.tsx b/apps/app/src/components/forms/policies/create-policy-comment-form.tsx
new file mode 100644
index 0000000000..48bd56c97c
--- /dev/null
+++ b/apps/app/src/components/forms/policies/create-policy-comment-form.tsx
@@ -0,0 +1,108 @@
+"use client";
+
+import { createPolicyCommentAction } from "@/actions/policies/create-policy-comment";
+import { createPolicyCommentSchema } from "@/actions/schema";
+import { useI18n } from "@/locales/client";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@bubba/ui/accordion";
+import { Button } from "@bubba/ui/button";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormMessage,
+} from "@bubba/ui/form";
+import { Textarea } from "@bubba/ui/textarea";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { ArrowRightIcon } from "lucide-react";
+import { useAction } from "next-safe-action/hooks";
+import { useParams } from "next/navigation";
+import { useQueryState } from "nuqs";
+import React from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import type { z } from "zod";
+
+export function CreatePolicyCommentForm() {
+ const t = useI18n();
+ const [_, setPolicyCommentSheet] = useQueryState("policy-comment-sheet");
+ const params = useParams<{ policyId: string }>();
+
+ const createPolicyComment = useAction(createPolicyCommentAction, {
+ onSuccess: () => {
+ toast.success(t("common.comments.success"));
+ setPolicyCommentSheet(null);
+ },
+ onError: () => {
+ toast.error(t("common.comments.error"));
+ },
+ });
+
+ const form = useForm>({
+ resolver: zodResolver(createPolicyCommentSchema),
+ defaultValues: {
+ content: "",
+ policyId: params.policyId,
+ },
+ });
+
+ const onSubmit = (data: z.infer) => {
+ createPolicyComment.execute(data);
+ };
+
+ return (
+
+
+ );
+}
diff --git a/apps/app/src/components/policies/policy-comments.tsx b/apps/app/src/components/policies/policy-comments.tsx
new file mode 100644
index 0000000000..49ee77876b
--- /dev/null
+++ b/apps/app/src/components/policies/policy-comments.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { AssignedUser } from "@/components/assigned-user";
+import { PolicyCommentSheet } from "@/components/sheets/policy-comment-sheet";
+import { useI18n } from "@/locales/client";
+import type { OrganizationPolicy, User } from "@bubba/db";
+import { Button } from "@bubba/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@bubba/ui/card";
+import { EmptyCard } from "@bubba/ui/empty-card";
+import { format } from "date-fns";
+import { MessageSquare } from "lucide-react";
+import { useQueryState } from "nuqs";
+import React from "react";
+
+export function PolicyComment({
+ policy,
+ users,
+}: {
+ policy: OrganizationPolicy & { PolicyComments: any[] };
+ users: User[];
+}) {
+ const [_, setOpen] = useQueryState("policy-comment-sheet");
+ const t = useI18n();
+
+ return (
+
+
+
+
+ {t("common.comments.title")}
+
+
+
+
+
+ {policy.PolicyComments.length > 0 ? (
+
+ {policy.PolicyComments.map((comment) => (
+
+
{comment.content}
+
+
+
user.id === comment.ownerId)?.name
+ }
+ avatarUrl={
+ users.find((user) => user.id === comment.ownerId)?.image
+ }
+ />
+
+ ({format(comment.createdAt, "MMM d, yyyy")})
+
+
+
+
+ ))}
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/app/src/components/policies/policy-overview.tsx b/apps/app/src/components/policies/policy-overview.tsx
index c284df52e4..e20ceb941f 100644
--- a/apps/app/src/components/policies/policy-overview.tsx
+++ b/apps/app/src/components/policies/policy-overview.tsx
@@ -12,51 +12,53 @@ import { PolicyOverviewSheet } from "./sheets/policy-overview-sheet";
import { UpdatePolicyOverview } from "../forms/policies/policy-overview";
export function PolicyOverview({
- policy,
- users,
+ policy,
+ users,
}: {
- policy: OrganizationPolicy & { policy: Policy };
- users: User[];
+ policy: OrganizationPolicy & {
+ policy: Policy;
+ };
+ users: User[];
}) {
- const t = useI18n();
- const [open, setOpen] = useQueryState("policy-overview-sheet");
+ const t = useI18n();
+ const [open, setOpen] = useQueryState("policy-overview-sheet");
- return (
-
-
-
-
-
- {policy.policy.name}
-
-
-
-
- {policy.policy.description}
-
-
+ return (
+
+
+
+
+
+ {policy.policy.name}
+
+
+
+
+ {policy.policy.description}
+
+
-
-
-
-
- {t("policies.overview.title")}
-
-
-
-
-
-
-
+
+
+
+
+ {t("policies.overview.title")}
+
+
+
+
+
+
+
-
-
- );
+
+
+ );
}
diff --git a/apps/app/src/components/sheets/policy-comment-sheet.tsx b/apps/app/src/components/sheets/policy-comment-sheet.tsx
new file mode 100644
index 0000000000..d6401a0eba
--- /dev/null
+++ b/apps/app/src/components/sheets/policy-comment-sheet.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { useI18n } from "@/locales/client";
+import { Button } from "@bubba/ui/button";
+import { Drawer, DrawerContent, DrawerTitle } from "@bubba/ui/drawer";
+import { useMediaQuery } from "@bubba/ui/hooks";
+import { ScrollArea } from "@bubba/ui/scroll-area";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@bubba/ui/sheet";
+import { X } from "lucide-react";
+import { useQueryState } from "nuqs";
+import React from "react";
+
+import type { OrganizationPolicy } from "@bubba/db";
+import { CreatePolicyCommentForm } from "../forms/policies/create-policy-comment-form";
+
+export function PolicyCommentSheet({
+ policy,
+}: {
+ policy: OrganizationPolicy;
+}) {
+ const t = useI18n();
+
+ const isDesktop = useMediaQuery("(min-width: 768px)");
+ const [open, setOpen] = useQueryState("policy-comment-sheet");
+ const isOpen = Boolean(open);
+
+ const handleOpenChange = (open: boolean) => {
+ setOpen(open ? "true" : null);
+ };
+
+ if (isDesktop) {
+ return (
+
+
+
+
+ {t("common.comments.title")}
+
+
{" "}
+
+ {t("common.comments.description")}
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {t("common.comments.title")}
+
+
+
+
+ );
+}
diff --git a/packages/db/prisma/migrations/20250304193025_add_policy_comments/migration.sql b/packages/db/prisma/migrations/20250304193025_add_policy_comments/migration.sql
new file mode 100644
index 0000000000..f54f4b56ca
--- /dev/null
+++ b/packages/db/prisma/migrations/20250304193025_add_policy_comments/migration.sql
@@ -0,0 +1,27 @@
+-- CreateTable
+CREATE TABLE "PolicyComments" (
+ "id" TEXT NOT NULL,
+ "organizationPolicyId" TEXT NOT NULL,
+ "ownerId" TEXT NOT NULL,
+ "organizationId" TEXT NOT NULL,
+ "content" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "PolicyComments_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "PolicyComments_organizationPolicyId_idx" ON "PolicyComments"("organizationPolicyId");
+
+-- CreateIndex
+CREATE INDEX "PolicyComments_organizationId_idx" ON "PolicyComments"("organizationId");
+
+-- AddForeignKey
+ALTER TABLE "PolicyComments" ADD CONSTRAINT "PolicyComments_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "PolicyComments" ADD CONSTRAINT "PolicyComments_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "PolicyComments" ADD CONSTRAINT "PolicyComments_organizationPolicyId_fkey" FOREIGN KEY ("organizationPolicyId") REFERENCES "OrganizationPolicy"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index ca26e26e3e..f69d845813 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -63,6 +63,7 @@ model User {
sessions Session[]
TaskAttachment TaskAttachment[]
TaskComments TaskComments[]
+ PolicyComments PolicyComments[]
organization Organization? @relation("CurrentOrganization", fields: [organizationId], references: [id])
VendorComment VendorComment[]
VendorMitigationTask VendorMitigationTask[]
@@ -102,6 +103,7 @@ model Organization {
RiskMitigationTask RiskMitigationTask[]
TaskAttachment TaskAttachment[]
TaskComments TaskComments[]
+ PolicyComments PolicyComments[]
users User[] @relation("CurrentOrganization")
VendorComment VendorComment[]
VendorContact VendorContact[]
@@ -738,6 +740,7 @@ model OrganizationPolicy {
frequency Frequency?
lastPublishedAt DateTime?
OrganizationControlRequirement OrganizationControlRequirement[]
+ PolicyComments PolicyComments[]
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
policy Policy @relation(fields: [policyId], references: [id], onDelete: Cascade)
ownerId String?
@@ -882,6 +885,22 @@ model OrganizationApiKey {
@@index([key])
}
+model PolicyComments {
+ id String @id @default(cuid())
+ organizationPolicyId String
+ ownerId String
+ organizationId String
+ content String
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+ owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
+ organizationPolicy OrganizationPolicy @relation(fields: [organizationPolicyId], references: [id], onDelete: Cascade)
+
+ @@index([organizationPolicyId])
+ @@index([organizationId])
+}
+
enum Tier {
free
pro
diff --git a/turbo.json b/turbo.json
index 5d34ab0486..c398ca858f 100644
--- a/turbo.json
+++ b/turbo.json
@@ -1,6 +1,8 @@
{
"$schema": "https://turborepo.org/schema.json",
- "globalDependencies": ["**/.env"],
+ "globalDependencies": [
+ "**/.env"
+ ],
"ui": "stream",
"tasks": {
"clean-react": {
@@ -14,7 +16,6 @@
"DATABASE_URL",
"OPENAI_API_KEY",
"RESEND_API_KEY",
- "OPENPANEL_SECRET_KEY",
"UPSTASH_REDIS_REST_URL",
"UPSTASH_REDIS_REST_TOKEN",
"NEXT_PUBLIC_OPENPANEL_CLIENT_ID",
@@ -22,7 +23,6 @@
"STRIPE_WEBHOOK_SECRET",
"RESEND_API_KEY",
"RESEND_AUDIENCE_ID",
- "UPLOADTHING_TOKEN",
"NEXT_PUBLIC_GOOGLE_TAG_ID",
"DISCORD_WEBHOOK_URL",
"TRIGGER_SECRET_KEY",
@@ -39,9 +39,20 @@
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY"
],
- "inputs": ["$TURBO_DEFAULT$", ".env"],
- "dependsOn": ["^build", "^db:generate", "clean-react"],
- "outputs": [".next/**", "!.next/cache/**", "next-env.d.ts"]
+ "inputs": [
+ "$TURBO_DEFAULT$",
+ ".env"
+ ],
+ "dependsOn": [
+ "^build",
+ "^db:generate",
+ "clean-react"
+ ],
+ "outputs": [
+ ".next/**",
+ "!.next/cache/**",
+ "next-env.d.ts"
+ ]
},
"db:generate": {
"cache": false
@@ -56,17 +67,28 @@
"cache": false
},
"dev": {
- "inputs": ["$TURBO_DEFAULT$", ".env"],
- "dependsOn": ["^db:generate"],
+ "inputs": [
+ "$TURBO_DEFAULT$",
+ ".env"
+ ],
+ "dependsOn": [
+ "^db:generate"
+ ],
"persistent": true,
"cache": false
},
"lint": {
- "dependsOn": ["^topo"]
+ "dependsOn": [
+ "^topo"
+ ]
},
"typecheck": {
- "dependsOn": ["^topo"],
- "outputs": ["node_modules/.cache/tsbuildinfo.json"]
+ "dependsOn": [
+ "^topo"
+ ],
+ "outputs": [
+ "node_modules/.cache/tsbuildinfo.json"
+ ]
}
}
-}
+}
\ No newline at end of file