From 77e9e74ef03283d51acdbfe56965ef88252e107a Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 3 Apr 2025 12:47:32 -0400 Subject: [PATCH 1/5] refactor: update policy management to use assigneeId instead of ownerId, implement SelectAssignee component for better user experience, and enhance form validation --- .../app/src/actions/organization/lib/utils.ts | 15 +- .../policies/update-policy-form-action.ts | 4 +- apps/app/src/actions/schema.ts | 2 +- .../SelectAssignee.tsx} | 80 +++-- .../[id]/components/ReviewSection.tsx | 6 +- .../[orgId]/policies/(overview)/page.tsx | 38 ++- .../[orgId]/policies/all/[policyId]/page.tsx | 93 ++++-- .../forms/policies/UpdatePolicyOverview.tsx | 311 ++++++++++++++++++ .../forms/policies/policy-overview.tsx | 287 ---------------- .../components/policies/policy-overview.tsx | 201 ++++++----- apps/app/src/locales/features/policies.ts | 2 + .../migration.sql | 15 + packages/db/prisma/schema/auth.prisma | 2 +- packages/db/prisma/schema/policy.prisma | 23 +- 14 files changed, 595 insertions(+), 484 deletions(-) rename apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/{evidence/[id]/components/EvidenceAssigneeSection.tsx => components/SelectAssignee.tsx} (66%) create mode 100644 apps/app/src/components/forms/policies/UpdatePolicyOverview.tsx delete mode 100644 apps/app/src/components/forms/policies/policy-overview.tsx create mode 100644 packages/db/prisma/migrations/20250403161933_policy_assignee_fix/migration.sql diff --git a/apps/app/src/actions/organization/lib/utils.ts b/apps/app/src/actions/organization/lib/utils.ts index a8a9cea596..09823ce6a7 100644 --- a/apps/app/src/actions/organization/lib/utils.ts +++ b/apps/app/src/actions/organization/lib/utils.ts @@ -292,6 +292,19 @@ export async function createOrganizationPolicies( continue; } + // Find the member record for the user in this organization + const memberRecord = await db.member.findFirst({ + where: { + organizationId, + userId, + }, + }); + + if (!memberRecord) { + console.warn(`Member record not found for user: ${userId}`); + continue; + } + try { // Create the policy record using template data - include only fields in the schema const policy = await db.policy.create({ @@ -301,7 +314,7 @@ export async function createOrganizationPolicies( description: policyTemplate.metadata.description, status: "draft" as PolicyStatus, content: policyTemplate.content as InputJsonValue[], - ownerId: userId, + assigneeId: memberRecord?.id || null, frequency: policyTemplate.metadata.frequency, department: policyTemplate.metadata.department, }, diff --git a/apps/app/src/actions/policies/update-policy-form-action.ts b/apps/app/src/actions/policies/update-policy-form-action.ts index f7e3e067fe..0d328a6665 100644 --- a/apps/app/src/actions/policies/update-policy-form-action.ts +++ b/apps/app/src/actions/policies/update-policy-form-action.ts @@ -46,7 +46,7 @@ export const updatePolicyFormAction = authActionClient const { id, status, - ownerId, + assigneeId, department, review_frequency, review_date, @@ -90,7 +90,7 @@ export const updatePolicyFormAction = authActionClient }, data: { status, - ownerId, + assigneeId, department, frequency: review_frequency, reviewDate, diff --git a/apps/app/src/actions/schema.ts b/apps/app/src/actions/schema.ts index 63a582952b..46dd7c69fb 100644 --- a/apps/app/src/actions/schema.ts +++ b/apps/app/src/actions/schema.ts @@ -270,7 +270,7 @@ export const updatePolicyOverviewSchema = z.object({ export const updatePolicyFormSchema = z.object({ id: z.string(), status: z.nativeEnum(PolicyStatus), - ownerId: z.string(), + assigneeId: z.string().nullable(), department: z.nativeEnum(Departments), review_frequency: z.nativeEnum(Frequency), review_date: z.date(), diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceAssigneeSection.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/components/SelectAssignee.tsx similarity index 66% rename from apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceAssigneeSection.tsx rename to apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/components/SelectAssignee.tsx index d34dc2930d..1e4b6c861e 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/EvidenceAssigneeSection.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/components/SelectAssignee.tsx @@ -1,36 +1,46 @@ -"use client"; - -import { Member, User } from "@bubba/db/types"; -import { Avatar, AvatarFallback, AvatarImage } from "@bubba/ui/avatar"; +import { Avatar, AvatarImage, AvatarFallback } from "@bubba/ui/avatar"; import { Select, + SelectTrigger, SelectContent, SelectItem, - SelectTrigger, SelectValue, } from "@bubba/ui/select"; -import { User as UserIcon } from "lucide-react"; -import { useState } from "react"; +import { UserIcon } from "lucide-react"; +import { Member, User } from "@bubba/db/types"; +import { useState, useEffect } from "react"; -interface AssigneeSectionProps { - onAssigneeChange: (value: string | null) => void; +interface SelectAssigneeProps { assigneeId: string | null; - assignees: (Member & { - user: User; - })[]; disabled?: boolean; + assignees: (Member & { user: User })[]; + onAssigneeChange: (value: string | null) => void; + withTitle?: boolean; } -export function EvidenceAssigneeSection({ +export const SelectAssignee = ({ + assigneeId, + disabled, assignees, onAssigneeChange, - assigneeId, - disabled = false, -}: AssigneeSectionProps) { + withTitle = true, +}: SelectAssigneeProps) => { const [selectedAssignee, setSelectedAssignee] = useState< (Member & { user: User }) | null >(null); + // Initialize selectedAssignee based on assigneeId prop + useEffect(() => { + if (assigneeId && assignees) { + const assignee = assignees.find((a) => a.id === assigneeId); + if (assignee) { + setSelectedAssignee(assignee); + } + } else { + setSelectedAssignee(null); + } + }, [assigneeId, assignees]); + const handleAssigneeChange = (value: string) => { const newAssigneeId = value === "none" ? null : value; onAssigneeChange(newAssigneeId); @@ -38,10 +48,7 @@ export function EvidenceAssigneeSection({ if (newAssigneeId && assignees) { const assignee = assignees.find((a) => a.id === newAssigneeId); if (assignee) { - setSelectedAssignee({ - ...assignee, - user: assignee.user, - }); + setSelectedAssignee(assignee); } else { setSelectedAssignee(null); } @@ -56,7 +63,6 @@ export function EvidenceAssigneeSection({ // If image is a relative URL, ensure it's properly formed if (image.startsWith("/")) { - // This handles the case where the URL might need to be prefixed with the base URL return image; } @@ -65,10 +71,14 @@ export function EvidenceAssigneeSection({ return (
-
- -

ASSIGNEE

-
+ {withTitle && ( +
+ +

+ ASSIGNEE +

+
+ )}
); -} +}; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/ReviewSection.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/ReviewSection.tsx index 596985b6f8..358c1199e4 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/ReviewSection.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[id]/components/ReviewSection.tsx @@ -17,11 +17,11 @@ import { useAction } from "next-safe-action/hooks"; import { useState } from "react"; import { toast } from "sonner"; import { updateEvidenceDetails } from "../actions/updateEvidenceDetails"; -import { EvidenceAssigneeSection } from "./EvidenceAssigneeSection"; import { EvidenceDepartmentSection } from "./EvidenceDepartmentSection"; import { EvidenceFrequencySection } from "./EvidenceFrequencySection"; import { EvidenceNextReviewSection } from "./EvidenceNextReviewSection"; import { EvidenceStatusSection } from "./EvidenceStatusSection"; +import { SelectAssignee } from "../../../components/SelectAssignee"; interface ReviewSectionProps { evidence: Evidence & { @@ -106,10 +106,10 @@ export function ReviewSection({
- diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(overview)/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(overview)/page.tsx index 912132bb66..783387afca 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(overview)/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(overview)/page.tsx @@ -82,24 +82,28 @@ const getPoliciesOverview = async () => { }, }), tx.policy.groupBy({ - by: ["ownerId"], + by: ["assigneeId"], _count: true, where: { organizationId, - ownerId: { not: null }, + assigneeId: { not: null }, }, }), tx.policy.findMany({ where: { organizationId, - ownerId: { not: null }, + assigneeId: { not: null }, }, select: { status: true, - owner: { + assignee: { select: { id: true, - name: true, + user: { + select: { + name: true, + }, + }, }, }, }, @@ -111,13 +115,13 @@ const getPoliciesOverview = async () => { const policyDataByOwner = new Map(); for (const policy of policiesByAssigneeStatus) { - if (!policy.owner) continue; + if (!policy.assignee) continue; - const ownerId = policy.owner.id; - if (!policyDataByOwner.has(ownerId)) { - policyDataByOwner.set(ownerId, { - id: ownerId, - name: policy.owner.name || "Unknown", + const assigneeId = policy.assignee.id; + if (!policyDataByOwner.has(assigneeId)) { + policyDataByOwner.set(assigneeId, { + id: assigneeId, + name: policy.assignee.user.name || "Unknown", total: 0, published: 0, draft: 0, @@ -126,8 +130,8 @@ const getPoliciesOverview = async () => { }); } - const ownerData = policyDataByOwner.get(ownerId); - ownerData.total += 1; + const assigneeData = policyDataByOwner.get(assigneeId); + assigneeData.total += 1; // Handle each status type explicitly const status = policy.status as @@ -135,10 +139,10 @@ const getPoliciesOverview = async () => { | "draft" | "archived" | "needs_review"; - if (status === "published") ownerData.published += 1; - else if (status === "draft") ownerData.draft += 1; - else if (status === "archived") ownerData.archived += 1; - else if (status === "needs_review") ownerData.needs_review += 1; + if (status === "published") assigneeData.published += 1; + else if (status === "draft") assigneeData.draft += 1; + else if (status === "archived") assigneeData.archived += 1; + else if (status === "needs_review") assigneeData.needs_review += 1; } const assigneeData = Array.from(policyDataByOwner.values()); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/[policyId]/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/[policyId]/page.tsx index 3e3fea54fb..a9a443aa0b 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/[policyId]/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/[policyId]/page.tsx @@ -9,54 +9,81 @@ import { headers } from "next/headers"; 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; - setStaticParamsLocale(locale); - const policy = await getPolicy(policyId); - - return ( -
- -
- ); + const { locale, policyId } = await params; + setStaticParamsLocale(locale); + const policy = await getPolicy(policyId); + const assignees = await getAssignees(); + + 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("policies.overview.title"), - }; + return { + title: t("policies.overview.title"), + }; } const getPolicy = cache(async (policyId: string) => { - const session = await auth.api.getSession({ - headers: await headers(), - }); + const session = await auth.api.getSession({ + headers: await headers(), + }); + + const organizationId = session?.session.activeOrganizationId; + + if (!organizationId) { + return null; + } + + const policy = await db.policy.findUnique({ + where: { id: policyId, organizationId }, + }); + + if (!policy) { + return null; + } + + return policy; +}); - const organizationId = session?.session.activeOrganizationId; +const getAssignees = cache(async () => { + const session = await auth.api.getSession({ + headers: await headers(), + }); - if (!organizationId) { - return null; - } + const organizationId = session?.session.activeOrganizationId; - const policy = await db.policy.findUnique({ - where: { id: policyId, organizationId }, - }); + if (!organizationId) { + return []; + } - if (!policy) { - return null; - } + const assignees = await db.member.findMany({ + where: { + organizationId, + role: { + notIn: ["employee"], + }, + }, + include: { + user: true, + }, + }); - return policy; + return assignees; }); diff --git a/apps/app/src/components/forms/policies/UpdatePolicyOverview.tsx b/apps/app/src/components/forms/policies/UpdatePolicyOverview.tsx new file mode 100644 index 0000000000..0dee910c2c --- /dev/null +++ b/apps/app/src/components/forms/policies/UpdatePolicyOverview.tsx @@ -0,0 +1,311 @@ +"use client"; + +import { updatePolicyFormAction } from "@/actions/policies/update-policy-form-action"; +import { updatePolicyFormSchema } from "@/actions/schema"; +import { SelectAssignee } from "@/app/[locale]/(app)/(dashboard)/[orgId]/components/SelectAssignee"; +import { StatusPolicies, type StatusType } from "@/components/status-policies"; +import { useI18n } from "@/locales/client"; +import { + Departments, + Frequency, + Member, + User, + type Policy, + type PolicyStatus, +} from "@bubba/db/types"; +import { Button } from "@bubba/ui/button"; +import { Calendar } from "@bubba/ui/calendar"; +import { cn } from "@bubba/ui/cn"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@bubba/ui/form"; +import { Popover, PopoverContent, PopoverTrigger } from "@bubba/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@bubba/ui/select"; +import { Switch } from "@bubba/ui/switch"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { format } from "date-fns"; +import { CalendarIcon, Loader2 } from "lucide-react"; +import { useAction } from "next-safe-action/hooks"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import type { z } from "zod"; + +const policyStatuses: PolicyStatus[] = [ + "draft", + "published", + "needs_review", +] as const; + +export function UpdatePolicyOverview({ + policy, + assignees, +}: { + policy: Policy; + assignees: (Member & { user: User })[]; +}) { + const t = useI18n(); + + const updatePolicyForm = useAction(updatePolicyFormAction, { + onSuccess: () => { + toast.success(t("policies.overview.form.update_policy_success")); + }, + onError: () => { + toast.error(t("policies.overview.form.update_policy_error")); + }, + }); + + const calculateReviewDate = (): Date => { + if (!policy.reviewDate) { + return new Date(); + } + return new Date(policy.reviewDate); + }; + + const reviewDate = calculateReviewDate(); + + const form = useForm>({ + resolver: zodResolver(updatePolicyFormSchema), + defaultValues: { + id: policy.id, + status: policy.status, + assigneeId: policy.assigneeId ?? "", + department: policy.department ?? Departments.admin, + review_frequency: policy.frequency ?? Frequency.monthly, + review_date: reviewDate, + isRequiredToSign: policy.isRequiredToSign ? "required" : "not_required", + }, + }); + + const onSubmit = (data: z.infer) => { + updatePolicyForm.execute({ + id: data.id, + status: data.status as PolicyStatus, + assigneeId: data.assigneeId, + department: data.department, + review_frequency: data.review_frequency, + review_date: data.review_date, + isRequiredToSign: data.isRequiredToSign, + }); + }; + + return ( +
+ +
+ ( + + {t("policies.overview.form.status")} + + + + + + )} + /> + ( + + + {t("policies.overview.form.review_frequency")} + + + + + + + )} + /> + ( + + + {t("policies.overview.form.policy_department")} + + + + + + + )} + /> + ( + + + {t("policies.overview.form.policy_assignee")} + + + + + + + )} + /> +
+
+ ( + + {t("policies.overview.form.review_date")} + + + +
+ +
+
+
+ + date <= new Date()} + initialFocus + /> + +
+ +
+ )} + /> + ( + + + {t("policies.overview.form.signature_requirement")} + + + { + field.onChange(checked ? "required" : "not_required"); + }} + /> + + + + )} + /> +
+
+ +
+
+ + ); +} diff --git a/apps/app/src/components/forms/policies/policy-overview.tsx b/apps/app/src/components/forms/policies/policy-overview.tsx deleted file mode 100644 index 7753aa5294..0000000000 --- a/apps/app/src/components/forms/policies/policy-overview.tsx +++ /dev/null @@ -1,287 +0,0 @@ -"use client"; - -import { updatePolicyFormAction } from "@/actions/policies/update-policy-form-action"; -import { updatePolicyFormSchema } from "@/actions/schema"; -import { StatusPolicies, type StatusType } from "@/components/status-policies"; -import { useI18n } from "@/locales/client"; -import { - Departments, - Frequency, - type Policy, - type PolicyStatus, -} from "@bubba/db/types"; -import { Button } from "@bubba/ui/button"; -import { Calendar } from "@bubba/ui/calendar"; -import { cn } from "@bubba/ui/cn"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@bubba/ui/form"; -import { Popover, PopoverContent, PopoverTrigger } from "@bubba/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@bubba/ui/select"; -import { Switch } from "@bubba/ui/switch"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { format } from "date-fns"; -import { CalendarIcon, Loader2 } from "lucide-react"; -import { useSession } from "@bubba/auth"; -import { useAction } from "next-safe-action/hooks"; -import { useForm } from "react-hook-form"; -import { toast } from "sonner"; -import type { z } from "zod"; - -const policyStatuses: PolicyStatus[] = [ - "draft", - "published", - "needs_review", -] as const; - -export function UpdatePolicyOverview({ - policy, -}: { - policy: Policy; -}) { - const t = useI18n(); - const session = useSession(); - - const updatePolicyForm = useAction(updatePolicyFormAction, { - onSuccess: () => { - toast.success(t("policies.overview.form.update_policy_success")); - }, - onError: () => { - toast.error(t("policies.overview.form.update_policy_error")); - }, - }); - - const calculateReviewDate = (): Date => { - if (!policy.reviewDate) { - return new Date(); - } - return new Date(policy.reviewDate); - }; - - const reviewDate = calculateReviewDate(); - - const form = useForm>({ - resolver: zodResolver(updatePolicyFormSchema), - defaultValues: { - id: policy.id, - status: policy.status, - ownerId: policy.ownerId ?? session.data?.user?.id, - department: policy.department ?? Departments.admin, - review_frequency: policy.frequency ?? Frequency.monthly, - review_date: reviewDate, - isRequiredToSign: policy.isRequiredToSign ? "required" : "not_required", - }, - }); - - const onSubmit = (data: z.infer) => { - updatePolicyForm.execute({ - id: data.id, - status: data.status as PolicyStatus, - ownerId: data.ownerId, - department: data.department, - review_frequency: data.review_frequency, - review_date: data.review_date, - isRequiredToSign: data.isRequiredToSign, - }); - }; - - return ( -
- -
- ( - - {t("policies.overview.form.status")} - - - - - - )} - /> - ( - - - {t("policies.overview.form.review_frequency")} - - - - - - - )} - /> - ( - - - {t("policies.overview.form.policy_department")} - - - - - - - )} - /> -
-
- ( - - {t("policies.overview.form.review_date")} - - - -
- -
-
-
- - date <= new Date()} - initialFocus - /> - -
- -
- )} - /> - ( - - - {t("policies.overview.form.signature_requirement")} - - - { - field.onChange(checked ? "required" : "not_required"); - }} - /> - - - - )} - /> -
-
- -
-
- - ); -} diff --git a/apps/app/src/components/policies/policy-overview.tsx b/apps/app/src/components/policies/policy-overview.tsx index c429666d68..ac6b8acca7 100644 --- a/apps/app/src/components/policies/policy-overview.tsx +++ b/apps/app/src/components/policies/policy-overview.tsx @@ -1,119 +1,118 @@ "use client"; import { useI18n } from "@/locales/client"; -import type { Policy, User } from "@bubba/db/types"; +import type { Member, Policy, User } from "@bubba/db/types"; import { Alert, AlertDescription, AlertTitle } from "@bubba/ui/alert"; import { Button } from "@bubba/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@bubba/ui/card"; import { Icons } from "@bubba/ui/icons"; -import { ArchiveIcon, PencilIcon, ArchiveRestoreIcon } from "lucide-react"; +import { format } from "date-fns"; +import { ArchiveIcon, ArchiveRestoreIcon, PencilIcon } from "lucide-react"; import { useQueryState } from "nuqs"; -import { PolicyOverviewSheet } from "./sheets/policy-overview-sheet"; -import { UpdatePolicyOverview } from "../forms/policies/policy-overview"; +import { UpdatePolicyOverview } from "../forms/policies/UpdatePolicyOverview"; import { PolicyArchiveSheet } from "./sheets/policy-archive-sheet"; -import { format } from "date-fns"; -import { useRouter } from "next/navigation"; +import { PolicyOverviewSheet } from "./sheets/policy-overview-sheet"; export function PolicyOverview({ - policy, + policy, + assignees, }: { - policy: Policy | null; + policy: Policy | null; + assignees: (Member & { user: User })[]; }) { - const t = useI18n(); - const router = useRouter(); - const [open, setOpen] = useQueryState("policy-overview-sheet"); - const [archiveOpen, setArchiveOpen] = useQueryState("archive-policy-sheet"); + const t = useI18n(); + const [, setOpen] = useQueryState("policy-overview-sheet"); + const [, setArchiveOpen] = useQueryState("archive-policy-sheet"); - return ( -
- {policy?.status === "archived" && ( - -
- -
{t("policies.archive.status")}
-
- - {policy?.status === "archived" && ( - <> - {t("policies.archive.archived_on")}{" "} - {format(new Date(policy?.updatedAt ?? new Date()), "PPP")} - - )} - - -
- )} + return ( +
+ {policy?.status === "archived" && ( + +
+ +
{t("policies.archive.status")}
+
+ + {policy?.status === "archived" && ( + <> + {t("policies.archive.archived_on")}{" "} + {format(new Date(policy?.updatedAt ?? new Date()), "PPP")} + + )} + + +
+ )} - - - -
- {policy?.name} -
- - -
-
-
- - {policy?.description} - -
+ + + +
+ {policy?.name} +
+ + +
+
+
+ + {policy?.description} + +
- - - -
- {t("policies.overview.title")} -
-
-
- - {policy && } - -
+ + + +
+ {t("policies.overview.title")} +
+
+
+ + {policy && ( + + )} + +
- {policy && ( - <> - - - - )} -
- ); + {policy && ( + <> + + + + )} +
+ ); } diff --git a/apps/app/src/locales/features/policies.ts b/apps/app/src/locales/features/policies.ts index 9cfbf41f13..03ac23c4c8 100644 --- a/apps/app/src/locales/features/policies.ts +++ b/apps/app/src/locales/features/policies.ts @@ -34,6 +34,8 @@ export const policies = { policy_department_placeholder: "Select a department", policy_status: "Policy Status", policy_status_placeholder: "Select a policy status", + policy_assignee: "Assignee", + policy_assignee_placeholder: "Select an assignee", }, }, new: { diff --git a/packages/db/prisma/migrations/20250403161933_policy_assignee_fix/migration.sql b/packages/db/prisma/migrations/20250403161933_policy_assignee_fix/migration.sql new file mode 100644 index 0000000000..7029c4fd90 --- /dev/null +++ b/packages/db/prisma/migrations/20250403161933_policy_assignee_fix/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - You are about to drop the column `ownerId` on the `Policy` table. All the data in the column will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Policy" DROP CONSTRAINT "Policy_ownerId_fkey"; + +-- AlterTable +ALTER TABLE "Policy" DROP COLUMN "ownerId", +ADD COLUMN "assigneeId" TEXT; + +-- AddForeignKey +ALTER TABLE "Policy" ADD CONSTRAINT "Policy_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "Member"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema/auth.prisma b/packages/db/prisma/schema/auth.prisma index f1990f62dc..27dfb7f7ca 100644 --- a/packages/db/prisma/schema/auth.prisma +++ b/packages/db/prisma/schema/auth.prisma @@ -13,7 +13,6 @@ model User { integrationResults IntegrationResult[] invitations Invitation[] members Member[] - policy Policy[] risk Risk[] sessions Session[] vendor Vendor[] @@ -88,6 +87,7 @@ model Member { isActive Boolean @default(true) EmployeeTrainingVideoCompletion EmployeeTrainingVideoCompletion[] Evidence Evidence[] + Policy Policy[] } model Invitation { diff --git a/packages/db/prisma/schema/policy.prisma b/packages/db/prisma/schema/policy.prisma index b2806da559..12298d96ac 100644 --- a/packages/db/prisma/schema/policy.prisma +++ b/packages/db/prisma/schema/policy.prisma @@ -1,22 +1,27 @@ model Policy { + // Metadata id String @id @default(cuid()) name String description String? - organizationId String status PolicyStatus @default(draft) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt content Json[] frequency Frequency? - lastPublishedAt DateTime? + department Departments? isRequiredToSign Boolean @default(false) signedBy String[] @default([]) - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - ownerId String? - owner User? @relation(fields: [ownerId], references: [id]) - department Departments? reviewDate DateTime? - artifacts Artifact[] + + // Dates + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastPublishedAt DateTime? + + // Relationships + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + assigneeId String? + assignee Member? @relation(fields: [assigneeId], references: [id]) + artifacts Artifact[] @@index([organizationId]) } From b76fd9787e19200919e376d2e652690605cba0e1 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 3 Apr 2025 12:50:29 -0400 Subject: [PATCH 2/5] feat: add isArchived and lastArchivedAt fields to Policy model for improved policy management --- packages/db/prisma/schema/policy.prisma | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/db/prisma/schema/policy.prisma b/packages/db/prisma/schema/policy.prisma index 12298d96ac..3af11a0b70 100644 --- a/packages/db/prisma/schema/policy.prisma +++ b/packages/db/prisma/schema/policy.prisma @@ -10,10 +10,12 @@ model Policy { isRequiredToSign Boolean @default(false) signedBy String[] @default([]) reviewDate DateTime? + isArchived Boolean @default(false) // Dates createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + lastArchivedAt DateTime? lastPublishedAt DateTime? // Relationships @@ -30,5 +32,4 @@ enum PolicyStatus { draft published needs_review - archived } From 071673afdafc1557fcdc0dca11cefbaa83a8c38b Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 3 Apr 2025 13:11:45 -0400 Subject: [PATCH 3/5] refactor: update policy management to use isArchived boolean instead of status for archiving logic, enhancing clarity and consistency across components --- .../src/actions/policies/archive-policy.ts | 7 ++--- .../[orgId]/policies/(overview)/page.tsx | 19 +++++++----- .../components/policies/policy-overview.tsx | 8 ++--- .../policies/sheets/policy-archive-sheet.tsx | 4 +-- .../migration.sql | 15 ---------- .../migration.sql | 29 +++++++++++++++++++ 6 files changed, 50 insertions(+), 32 deletions(-) delete mode 100644 packages/db/prisma/migrations/20250403161933_policy_assignee_fix/migration.sql create mode 100644 packages/db/prisma/migrations/20250403165218_archived_on_its_own/migration.sql diff --git a/apps/app/src/actions/policies/archive-policy.ts b/apps/app/src/actions/policies/archive-policy.ts index 8cc95a65fd..a10da2b63e 100644 --- a/apps/app/src/actions/policies/archive-policy.ts +++ b/apps/app/src/actions/policies/archive-policy.ts @@ -47,13 +47,12 @@ export const archivePolicyAction = authActionClient // Determine if we should archive or restore based on action or current state const shouldArchive = - action === "archive" || - (action === undefined && policy.status !== "archived"); + action === "archive" || (action === undefined && !policy.isArchived); await db.policy.update({ where: { id }, data: { - status: shouldArchive ? "archived" : "published", + isArchived: shouldArchive, }, }); @@ -64,7 +63,7 @@ export const archivePolicyAction = authActionClient return { success: true, - status: shouldArchive ? "archived" : "published", + isArchived: shouldArchive, }; } catch (error) { console.error(error); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(overview)/page.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(overview)/page.tsx index 783387afca..f1c710fdde 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(overview)/page.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/(overview)/page.tsx @@ -61,24 +61,27 @@ const getPoliciesOverview = async () => { where: { organizationId, status: "published", + isArchived: false, }, }), tx.policy.count({ where: { organizationId, status: "draft", + isArchived: false, }, }), tx.policy.count({ where: { organizationId, - status: "archived", + isArchived: true, }, }), tx.policy.count({ where: { organizationId, status: "needs_review", + isArchived: false, }, }), tx.policy.groupBy({ @@ -96,6 +99,7 @@ const getPoliciesOverview = async () => { }, select: { status: true, + isArchived: true, assignee: { select: { id: true, @@ -133,15 +137,16 @@ const getPoliciesOverview = async () => { const assigneeData = policyDataByOwner.get(assigneeId); assigneeData.total += 1; + // Handle archived policies separately + if (policy.isArchived) { + assigneeData.archived += 1; + continue; + } + // Handle each status type explicitly - const status = policy.status as - | "published" - | "draft" - | "archived" - | "needs_review"; + const status = policy.status; if (status === "published") assigneeData.published += 1; else if (status === "draft") assigneeData.draft += 1; - else if (status === "archived") assigneeData.archived += 1; else if (status === "needs_review") assigneeData.needs_review += 1; } diff --git a/apps/app/src/components/policies/policy-overview.tsx b/apps/app/src/components/policies/policy-overview.tsx index ac6b8acca7..5ebc323d12 100644 --- a/apps/app/src/components/policies/policy-overview.tsx +++ b/apps/app/src/components/policies/policy-overview.tsx @@ -26,14 +26,14 @@ export function PolicyOverview({ return (
- {policy?.status === "archived" && ( + {policy?.isArchived && (
{t("policies.archive.status")}
- {policy?.status === "archived" && ( + {policy?.isArchived && ( <> {t("policies.archive.archived_on")}{" "} {format(new Date(policy?.updatedAt ?? new Date()), "PPP")} @@ -64,12 +64,12 @@ export function PolicyOverview({ className="p-0 m-0 size-auto" onClick={() => setArchiveOpen("true")} title={ - policy?.status === "archived" + policy?.isArchived ? t("policies.archive.restore_tooltip") : t("policies.archive.tooltip") } > - {policy?.status === "archived" ? ( + {policy?.isArchived ? ( ) : ( diff --git a/apps/app/src/components/policies/sheets/policy-archive-sheet.tsx b/apps/app/src/components/policies/sheets/policy-archive-sheet.tsx index c9809724e7..6b98578846 100644 --- a/apps/app/src/components/policies/sheets/policy-archive-sheet.tsx +++ b/apps/app/src/components/policies/sheets/policy-archive-sheet.tsx @@ -29,11 +29,11 @@ export function PolicyArchiveSheet({ const isDesktop = useMediaQuery("(min-width: 768px)"); const [open, setOpen] = useQueryState("archive-policy-sheet"); const isOpen = Boolean(open); - const isArchived = policy.status === "archived"; + const isArchived = policy.isArchived; const archivePolicy = useAction(archivePolicyAction, { onSuccess: (result) => { - if (result.data?.status === "archived") { + if (result) { toast.success(t("policies.archive.success")); // Redirect to policies list after successful archive router.push(`/${policy.organizationId}/policies/all`); diff --git a/packages/db/prisma/migrations/20250403161933_policy_assignee_fix/migration.sql b/packages/db/prisma/migrations/20250403161933_policy_assignee_fix/migration.sql deleted file mode 100644 index 7029c4fd90..0000000000 --- a/packages/db/prisma/migrations/20250403161933_policy_assignee_fix/migration.sql +++ /dev/null @@ -1,15 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `ownerId` on the `Policy` table. All the data in the column will be lost. - -*/ --- DropForeignKey -ALTER TABLE "Policy" DROP CONSTRAINT "Policy_ownerId_fkey"; - --- AlterTable -ALTER TABLE "Policy" DROP COLUMN "ownerId", -ADD COLUMN "assigneeId" TEXT; - --- AddForeignKey -ALTER TABLE "Policy" ADD CONSTRAINT "Policy_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "Member"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20250403165218_archived_on_its_own/migration.sql b/packages/db/prisma/migrations/20250403165218_archived_on_its_own/migration.sql new file mode 100644 index 0000000000..346678aa0b --- /dev/null +++ b/packages/db/prisma/migrations/20250403165218_archived_on_its_own/migration.sql @@ -0,0 +1,29 @@ +/* + Warnings: + + - The values [archived] on the enum `PolicyStatus` will be removed. If these variants are still used in the database, this will fail. + - You are about to drop the column `ownerId` on the `Policy` table. All the data in the column will be lost. + +*/ +-- AlterEnum +BEGIN; +CREATE TYPE "PolicyStatus_new" AS ENUM ('draft', 'published', 'needs_review'); +ALTER TABLE "Policy" ALTER COLUMN "status" DROP DEFAULT; +ALTER TABLE "Policy" ALTER COLUMN "status" TYPE "PolicyStatus_new" USING ("status"::text::"PolicyStatus_new"); +ALTER TYPE "PolicyStatus" RENAME TO "PolicyStatus_old"; +ALTER TYPE "PolicyStatus_new" RENAME TO "PolicyStatus"; +DROP TYPE "PolicyStatus_old"; +ALTER TABLE "Policy" ALTER COLUMN "status" SET DEFAULT 'draft'; +COMMIT; + +-- DropForeignKey +ALTER TABLE "Policy" DROP CONSTRAINT "Policy_ownerId_fkey"; + +-- AlterTable +ALTER TABLE "Policy" DROP COLUMN "ownerId", +ADD COLUMN "assigneeId" TEXT, +ADD COLUMN "isArchived" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "lastArchivedAt" TIMESTAMP(3); + +-- AddForeignKey +ALTER TABLE "Policy" ADD CONSTRAINT "Policy_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "Member"("id") ON DELETE SET NULL ON UPDATE CASCADE; From e8762ccc8cbb282ccf67e477a653de7cd367ff09 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 3 Apr 2025 13:16:11 -0400 Subject: [PATCH 4/5] refactor: rename ownerId to assigneeId in policy overview for improved clarity and consistency --- .../forms/policies/policy-overview.tsx | 4 +-- yarn.lock | 29 ++++++++++--------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/apps/app/src/components/forms/policies/policy-overview.tsx b/apps/app/src/components/forms/policies/policy-overview.tsx index 368ffdb5a6..ae61145ae1 100644 --- a/apps/app/src/components/forms/policies/policy-overview.tsx +++ b/apps/app/src/components/forms/policies/policy-overview.tsx @@ -76,7 +76,7 @@ export function UpdatePolicyOverview({ defaultValues: { id: policy.id, status: policy.status, - ownerId: policy.ownerId ?? session.data?.user?.id, + assigneeId: policy.assigneeId ?? session.data?.user?.id, department: policy.department ?? Departments.admin, review_frequency: policy.frequency ?? Frequency.monthly, review_date: reviewDate, @@ -88,7 +88,7 @@ export function UpdatePolicyOverview({ updatePolicyForm.execute({ id: data.id, status: data.status as PolicyStatus, - ownerId: data.ownerId, + assigneeId: data.assigneeId, department: data.department, review_frequency: data.review_frequency, review_date: data.review_date, diff --git a/yarn.lock b/yarn.lock index 4779ee735e..7f987c6cc6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1448,14 +1448,14 @@ picocolors "^1.0.0" sisteransi "^1.0.5" -"@comp/analytics@packages/analytics": +"@comp/analytics@^workspace:packages/analytics": version "workspace:packages/analytics" resolved "workspace:packages/analytics" dependencies: posthog-js "^1.215.3" posthog-node "^4.4.1" -"@comp/app@apps/app", "@comp/app@workspace:*": +"@comp/app@^workspace:apps/app", "@comp/app@workspace:*": version "workspace:apps/app" resolved "workspace:apps/app" devDependencies: @@ -1554,7 +1554,7 @@ yarn "^1.22.22" zustand "^5.0.3" -"@comp/auth@packages/auth", "@comp/auth@workspace:*": +"@comp/auth@^workspace:packages/auth", "@comp/auth@workspace:*": version "workspace:packages/auth" resolved "workspace:packages/auth" devDependencies: @@ -1567,7 +1567,7 @@ better-auth "^1.2.5" server-only "0.0.1" -"@comp/data@packages/data", "@comp/data@workspace:*": +"@comp/data@^workspace:packages/data", "@comp/data@workspace:*": version "workspace:packages/data" resolved "workspace:packages/data" devDependencies: @@ -1576,11 +1576,12 @@ tsup "^8.4.0" typescript "^5.8.2" -"@comp/db@packages/db", "@comp/db@workspace:*": +"@comp/db@^workspace:packages/db", "@comp/db@workspace:*": version "workspace:packages/db" resolved "workspace:packages/db" devDependencies: "@comp/tsconfig" "workspace:*" + devDependencies: typescript "^5.8.2" dependencies: "@auth/prisma-adapter" "^2.8.0" @@ -1590,7 +1591,7 @@ "@prisma/extension-accelerate" "^1.2.2" prisma "^6.5.0" -"@comp/email@packages/email", "@comp/email@workspace:*": +"@comp/email@^workspace:packages/email", "@comp/email@workspace:*": version "workspace:packages/email" resolved "workspace:packages/email" devDependencies: @@ -1601,6 +1602,7 @@ dependencies: "@comp/ui" "workspace:*" "@comp/utils" "workspace:*" + dependencies: "@react-email/components" "0.0.31" "@react-email/render" "0.0.10" "@react-email/tailwind" "1.0.4" @@ -1610,7 +1612,7 @@ react-email "3.0.4" responsive-react-email "^0.0.5" -"@comp/integrations@packages/integrations": +"@comp/integrations@^workspace:packages/integrations": version "workspace:packages/integrations" resolved "workspace:packages/integrations" devDependencies: @@ -1633,14 +1635,14 @@ sharp "^0.33.5" zod "^3.24.2" -"@comp/kv@packages/kv": +"@comp/kv@^workspace:packages/kv": version "workspace:packages/kv" resolved "workspace:packages/kv" dependencies: "@upstash/redis" "^1.34.2" server-only "0.0.1" -"@comp/notifications@packages/notifications", "@comp/notifications@workspace:*": +"@comp/notifications@^workspace:packages/notifications", "@comp/notifications@workspace:*": version "workspace:packages/notifications" resolved "workspace:packages/notifications" devDependencies: @@ -1649,12 +1651,13 @@ "@novu/node" "^2.0.1" nanoid "5.1.0" -"@comp/portal@apps/portal": +"@comp/portal@^workspace:apps/portal": version "workspace:apps/portal" resolved "workspace:apps/portal" devDependencies: "@comp/db" "workspace:*" "@comp/ui" "workspace:*" + devDependencies: "@types/node" "^22.13.2" "@types/react" "^19.0.8" "@types/react-dom" "^19.0.3" @@ -1669,11 +1672,11 @@ react-dom "^19.0.0" react-otp-input "^3.1.1" -"@comp/tsconfig@packages/tsconfig", "@comp/tsconfig@workspace:*": +"@comp/tsconfig@^workspace:packages/tsconfig", "@comp/tsconfig@workspace:*": version "workspace:packages/tsconfig" resolved "workspace:packages/tsconfig" -"@comp/ui@packages/ui", "@comp/ui@workspace:*": +"@comp/ui@^workspace:packages/ui", "@comp/ui@workspace:*": version "workspace:packages/ui" resolved "workspace:packages/ui" devDependencies: @@ -1737,7 +1740,7 @@ use-callback-ref "^1.3.3" vaul "^1.0.0" -"@comp/utils@packages/utils", "@comp/utils@workspace:*": +"@comp/utils@^workspace:packages/utils", "@comp/utils@workspace:*": version "workspace:packages/utils" resolved "workspace:packages/utils" devDependencies: From 833b26f7056a7a44e8df64a4e3201e591bc05573 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 3 Apr 2025 13:20:11 -0400 Subject: [PATCH 5/5] refactor: enhance create policy action to validate member association and improve authorization checks --- .../src/actions/policies/create-new-policy.ts | 175 ++++++++++-------- 1 file changed, 95 insertions(+), 80 deletions(-) diff --git a/apps/app/src/actions/policies/create-new-policy.ts b/apps/app/src/actions/policies/create-new-policy.ts index 07b2de7f35..d7a8efc190 100644 --- a/apps/app/src/actions/policies/create-new-policy.ts +++ b/apps/app/src/actions/policies/create-new-policy.ts @@ -7,92 +7,107 @@ import { authActionClient } from "../safe-action"; import { createPolicySchema } from "../schema"; export const createPolicyAction = authActionClient - .schema(createPolicySchema) - .metadata({ - name: "create-policy", - track: { - event: "create-policy", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { title, description, frameworkIds, controlIds } = parsedInput; - const { activeOrganizationId } = ctx.session; - const { user } = ctx; + .schema(createPolicySchema) + .metadata({ + name: "create-policy", + track: { + event: "create-policy", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { title, description, frameworkIds, controlIds } = parsedInput; + const { activeOrganizationId } = ctx.session; + const { user } = ctx; - if (!activeOrganizationId) { - return { - success: false, - error: "Not authorized", - }; - } + if (!activeOrganizationId) { + return { + success: false, + error: "Not authorized", + }; + } - if (!user) { - return { - success: false, - error: "Not authorized", - }; - } + if (!user) { + return { + success: false, + error: "Not authorized", + }; + } - try { - // Create the policy - const policy = await db.policy.create({ - data: { - name: title, - description, - organizationId: activeOrganizationId, - ownerId: user.id, - department: Departments.none, - frequency: Frequency.monthly, - content: [ - { type: "paragraph", content: [{ type: "text", text: "" }] }, - ], - }, - }); + // Find member id in the organization + const member = await db.member.findFirst({ + where: { + userId: user.id, + organizationId: activeOrganizationId, + }, + }); - // Create artifacts for each control - if (controlIds && controlIds.length > 0) { - // Create artifacts that link the policy to controls - await Promise.all( - controlIds.map(async (controlId) => { - // Create the artifact - const artifact = await db.artifact.create({ - data: { - type: "policy", - policyId: policy.id, - organizationId: activeOrganizationId, - }, - }); + if (!member) { + return { + success: false, + error: "Not authorized", + }; + } - // Connect the artifact to the control - await db.control.update({ - where: { id: controlId }, - data: { - artifacts: { - connect: { id: artifact.id }, - }, - }, - }); + try { + // Create the policy + const policy = await db.policy.create({ + data: { + name: title, + description, + organizationId: activeOrganizationId, + assigneeId: member.id, + department: Departments.none, + frequency: Frequency.monthly, + content: [ + { type: "paragraph", content: [{ type: "text", text: "" }] }, + ], + }, + }); - return artifact; - }), - ); - } + // Create artifacts for each control + if (controlIds && controlIds.length > 0) { + // Create artifacts that link the policy to controls + await Promise.all( + controlIds.map(async (controlId) => { + // Create the artifact + const artifact = await db.artifact.create({ + data: { + type: "policy", + policyId: policy.id, + organizationId: activeOrganizationId, + }, + }); - revalidatePath(`/${activeOrganizationId}/policies/all`); - revalidatePath(`/${activeOrganizationId}/policies`); - revalidateTag("policies"); + // Connect the artifact to the control + await db.control.update({ + where: { id: controlId }, + data: { + artifacts: { + connect: { id: artifact.id }, + }, + }, + }); - return { - success: true, - policyId: policy.id, - }; - } catch (error) { - console.error(error); + return artifact; + }) + ); + } - return { - success: false, - error: "Failed to create policy", - }; - } - }); + revalidatePath(`/${activeOrganizationId}/policies/all`); + revalidatePath(`/${activeOrganizationId}/policies`); + revalidateTag("policies"); + + return { + success: true, + policyId: policy.id, + }; + } catch (error) { + console.error(error); + + return { + success: false, + error: "Failed to create policy", + }; + } + });