diff --git a/apps/app/src/actions/policies/archive-policy.ts b/apps/app/src/actions/policies/archive-policy.ts new file mode 100644 index 0000000000..202f71d30d --- /dev/null +++ b/apps/app/src/actions/policies/archive-policy.ts @@ -0,0 +1,76 @@ +"use server"; + +import { db } from "@bubba/db"; +import { revalidatePath, revalidateTag } from "next/cache"; +import { authActionClient } from "../safe-action"; +import { z } from "zod"; + +const archivePolicySchema = z.object({ + id: z.string(), + action: z.enum(["archive", "restore"]).optional(), +}); + +export const archivePolicyAction = authActionClient + .schema(archivePolicySchema) + .metadata({ + name: "archive-policy", + track: { + event: "archive-policy", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { id, action } = parsedInput; + const { user } = ctx; + + if (!user.id || !user.organizationId) { + return { + success: false, + error: "Not authorized", + }; + } + + try { + const policy = await db.organizationPolicy.findUnique({ + where: { + id, + organizationId: user.organizationId, + }, + }); + + if (!policy) { + return { + success: false, + error: "Policy not found", + }; + } + + // Determine if we should archive or restore based on action or current state + const shouldArchive = + action === "archive" || (action === undefined && !policy.isArchived); + + await db.organizationPolicy.update({ + where: { id }, + data: { + isArchived: shouldArchive, + archivedAt: shouldArchive ? new Date() : null, + }, + }); + + revalidatePath(`/${user.organizationId}/policies/all/${id}`); + revalidatePath(`/${user.organizationId}/policies/all`); + revalidatePath(`/${user.organizationId}/policies`); + revalidateTag("policies"); + + return { + success: true, + isArchived: shouldArchive, + }; + } catch (error) { + console.error(error); + return { + success: false, + error: "Failed to update policy archive status", + }; + } + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/employees/all/components/table/EmployeesTable.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/employees/all/components/table/EmployeesTable.tsx index 2b01773382..edcdc71666 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/employees/all/components/table/EmployeesTable.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/employees/all/components/table/EmployeesTable.tsx @@ -1,6 +1,6 @@ "use client"; -import { EmployeeInviteSheet } from "@/components/sheets/invite-user-sheet"; +import { EmployeeInviteSheet } from "@/components/sheets/add-employee-sheet"; import { DataTable } from "@/components/ui/data-table"; import { Plus } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/hooks/useEvidenceTasks.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/hooks/useEvidenceTasks.ts index c3d347e76c..c5522cd965 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/hooks/useEvidenceTasks.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/hooks/useEvidenceTasks.ts @@ -3,7 +3,11 @@ import useSWR from "swr"; import { getOrganizationEvidenceTasks } from "../actions/getOrganizationEvidenceTasks"; import type { PaginationMetadata } from "../actions/getOrganizationEvidenceTasks"; -import type { Frequency, OrganizationEvidence, Departments } from "@bubba/db/types"; +import type { + Frequency, + OrganizationEvidence, + Departments, +} from "@bubba/db/types"; // Define the props interface with clear types interface UseOrganizationEvidenceTasksProps { @@ -96,8 +100,6 @@ export function useOrganizationEvidenceTasks( }) ); - console.log("useOrganizationEvidenceTasks data:", data); - return { data: data?.data ?? [], pagination: data?.pagination, diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/overview/components/RequirementStatusChart.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/overview/components/RequirementStatusChart.tsx index f3eb93f710..51cad196e2 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/overview/components/RequirementStatusChart.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/overview/components/RequirementStatusChart.tsx @@ -34,10 +34,10 @@ function FrameworkCard({ return ( -
-
+
+
{framework.framework.name.substring(0, 2).toUpperCase()}
@@ -50,7 +50,7 @@ function FrameworkCard({
@@ -67,7 +67,7 @@ export function RequirementStatus({ if (!frameworks.length || !frameworksWithCompliance.length) return null; return ( - + {t("frameworks.title")} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/overview/frameworks/[frameworkId]/components/FrameworkOverview.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/overview/frameworks/[frameworkId]/components/FrameworkOverview.tsx index 7c45b0520b..9dc7fbf49c 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/overview/frameworks/[frameworkId]/components/FrameworkOverview.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/overview/frameworks/[frameworkId]/components/FrameworkOverview.tsx @@ -36,12 +36,6 @@ export function FrameworkOverview({ } }).length; - // Calculate compliance metrics - const compliancePercentage = - totalRequirements > 0 - ? Math.round((completedRequirements / totalRequirements) * 100) - : 0; - // Count controls const allControls = organizationCategories.flatMap( (category) => category.organizationControl, @@ -69,6 +63,12 @@ export function FrameworkOverview({ return completedControlRequirements === controlRequirements.length; }).length; + // Calculate compliance percentage based on compliant controls + const compliancePercentage = + totalControls > 0 + ? Math.round((compliantControls / totalControls) * 100) + : 0; + return (
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 6b84571073..22be801aaf 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 @@ -68,7 +68,7 @@ const getPoliciesOverview = async () => { tx.organizationPolicy.count({ where: { organizationId, - status: "archived", + isArchived: true, }, }), tx.organizationPolicy.count({ diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/actions/get-policies.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/actions/get-policies.ts index 4a50b3df26..23d4cc7b92 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/actions/get-policies.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/actions/get-policies.ts @@ -5,120 +5,136 @@ import { db } from "@bubba/db"; import { appErrors, policiesInputSchema } from "../types"; export const getPolicies = authActionClient - .schema(policiesInputSchema) - .metadata({ - name: "get-policies", - track: { - event: "get-policies", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { search, status, page = 1, pageSize = 10, sort } = parsedInput; - const { user } = ctx; + .schema(policiesInputSchema) + .metadata({ + name: "get-policies", + track: { + event: "get-policies", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { + search, + status, + isArchived, + page = 1, + pageSize = 10, + sort, + } = parsedInput; + const { user } = ctx; - if (!user.organizationId) { - return { - success: false, - error: appErrors.UNAUTHORIZED.message, - }; - } + if (!user.organizationId) { + return { + success: false, + error: appErrors.UNAUTHORIZED.message, + }; + } - try { - const skip = (page - 1) * pageSize; - const [column, order] = sort?.split(":") ?? []; + try { + const skip = (page - 1) * pageSize; + const [column, order] = sort?.split(":") ?? []; - let orderByClause: any = { updatedAt: "desc" }; + let orderByClause: any = { updatedAt: "desc" }; - if (column) { - if (column === "name") { - orderByClause = { - policy: { - name: order === "asc" ? "asc" : "desc", - }, - }; - } else { - orderByClause = { - [column]: order === "asc" ? "asc" : "desc", - }; - } - } + if (column) { + if (column === "name") { + orderByClause = { + policy: { + name: order === "asc" ? "asc" : "desc", + }, + }; + } else { + orderByClause = { + [column]: order === "asc" ? "asc" : "desc", + }; + } + } - const [policies, total] = await Promise.all([ - db.organizationPolicy.findMany({ - where: { - organizationId: user.organizationId, - AND: [ - search - ? { - policy: { - OR: [ - { name: { contains: search, mode: "insensitive" } }, - { - description: { - contains: search, - mode: "insensitive", - }, - }, - ], - }, - } - : {}, - status ? { status: status as any } : {}, - ], - }, - select: { - id: true, - status: true, - createdAt: true, - updatedAt: true, - policy: { - select: { - id: true, - name: true, - description: true, - slug: true, - }, - }, - }, - skip, - take: pageSize, - orderBy: orderByClause, - }), - db.organizationPolicy.count({ - where: { - organizationId: user.organizationId, - AND: [ - search - ? { - policy: { - OR: [ - { name: { contains: search, mode: "insensitive" } }, - { - description: { - contains: search, - mode: "insensitive", - }, - }, - ], - }, - } - : {}, - status ? { status: status as any } : {}, - ], - }, - }), - ]); + // When isArchived filter is active, show only archived rows + // Otherwise show only non-archived rows + const isArchivedFilter = + isArchived === "true" ? { isArchived: true } : { isArchived: false }; - return { - success: true, - data: { policies, total }, - }; - } catch (error) { - console.error("Error fetching policies:", error); - return { - success: false, - error: appErrors.UNEXPECTED_ERROR.message, - }; - } - }); + const [policies, total] = await Promise.all([ + db.organizationPolicy.findMany({ + where: { + organizationId: user.organizationId, + ...isArchivedFilter, + AND: [ + search + ? { + policy: { + OR: [ + { name: { contains: search, mode: "insensitive" } }, + { + description: { + contains: search, + mode: "insensitive", + }, + }, + ], + }, + } + : {}, + status ? { status: status as any } : {}, + ], + }, + select: { + id: true, + status: true, + createdAt: true, + updatedAt: true, + isArchived: true, + archivedAt: true, + policy: { + select: { + id: true, + name: true, + description: true, + slug: true, + }, + }, + }, + skip, + take: pageSize, + orderBy: orderByClause, + }), + db.organizationPolicy.count({ + where: { + organizationId: user.organizationId, + ...isArchivedFilter, + AND: [ + search + ? { + policy: { + OR: [ + { name: { contains: search, mode: "insensitive" } }, + { + description: { + contains: search, + mode: "insensitive", + }, + }, + ], + }, + } + : {}, + status ? { status: status as any } : {}, + ], + }, + }), + ]); + + return { + success: true, + data: { policies, total }, + }; + } catch (error) { + console.error("Error fetching policies:", error); + return { + success: false, + error: appErrors.UNEXPECTED_ERROR.message, + }; + } + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/components/PoliciesList.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/components/PoliciesList.tsx index 5bc0d540be..3d14865af6 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/components/PoliciesList.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/components/PoliciesList.tsx @@ -16,6 +16,7 @@ import type { User } from "next-auth"; import { PoliciesListSkeleton } from "./PoliciesListSkeleton"; import { PoliciesTable } from "./table/PoliciesTable"; import { PoliciesTableProvider } from "./table/hooks/usePoliciesTableContext"; +import { CreatePolicySheet } from "@/components/sheets/create-policy-sheet"; interface PoliciesListProps { columnHeaders: { @@ -33,6 +34,7 @@ export function PoliciesList({ columnHeaders, users }: PoliciesListProps) {
+
); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/components/table/PoliciesTable.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/components/table/PoliciesTable.tsx index 42a1e911eb..64aa3914d2 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/components/table/PoliciesTable.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/components/table/PoliciesTable.tsx @@ -7,10 +7,12 @@ import { getFilterCategories } from "./components/filterCategories"; import { getColumns } from "./columns"; import type { PoliciesTableProps } from "./types"; import { usePoliciesTable } from "./hooks/usePoliciesTableContext"; +import { useQueryState } from "nuqs"; export function PoliciesTable({ users }: PoliciesTableProps) { const router = useRouter(); const { orgId } = useParams<{ orgId: string }>(); + const [_, setCreatePolicySheet] = useQueryState("create-policy-sheet"); const { page, setPage, @@ -24,6 +26,8 @@ export function PoliciesTable({ users }: PoliciesTableProps) { setStatus, ownerId, setOwnerId, + isArchived, + setIsArchived, hasActiveFilters, clearFilters, isLoading, @@ -34,13 +38,17 @@ export function PoliciesTable({ users }: PoliciesTableProps) { router.replace(`/${orgId}/policies/all/${policyId}`); }; - const activeFilterCount = [status, ownerId].filter(Boolean).length; + const activeFilterCount = [status, ownerId, isArchived].filter( + Boolean, + ).length; const filterCategories = getFilterCategories({ status, setStatus, ownerId, setOwnerId, + isArchived, + setIsArchived, users, setPage, }); @@ -79,11 +87,11 @@ export function PoliciesTable({ users }: PoliciesTableProps) { onClearFilters: clearFilters, activeFilterCount, }} - // ctaButton={{ - // label: "Create Policy", - // onClick: () => router.push(`/${orgId}/policies/all/new`), - // icon: , - // }} + ctaButton={{ + label: "Create Policy", + onClick: () => setCreatePolicySheet("true", { history: "push" }), + icon: , + }} /> ); } diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/components/table/columns.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/components/table/columns.tsx index 0cb128ca71..b648a3c84c 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/components/table/columns.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/components/table/columns.tsx @@ -3,12 +3,17 @@ import type { ColumnDef } from "@tanstack/react-table"; import type { PolicyStatus } from "./types"; import { formatStatus, getStatusStyle } from "./utils"; +import { Archive } from "lucide-react"; +import { Badge } from "@bubba/ui/badge"; +import { cn } from "@bubba/ui/cn"; interface PolicyRow { id: string; status: PolicyStatus; createdAt: Date; updatedAt: Date; + isArchived: boolean; + archivedAt?: Date; policy: { id: string; name: string; @@ -27,15 +32,28 @@ export function getColumns( accessorKey: "policy.name", cell: ({ row }) => { const name = row.original.policy.name; + const isArchived = row.original.isArchived; + return (
- +
+ + {isArchived && ( + + + Archived + + )} +
); }, @@ -60,6 +78,24 @@ export function getColumns( accessorKey: "updatedAt", cell: ({ row }) => { const date = new Date(row.original.updatedAt); + const isArchived = row.original.isArchived; + const archivedDate = row.original.archivedAt + ? new Date(row.original.archivedAt) + : null; + + if (isArchived && archivedDate) { + return ( +
+ Archived on{" "} + {archivedDate.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + })} +
+ ); + } + return (
{date.toLocaleDateString("en-US", { diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/components/table/components/filterCategories.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/components/table/components/filterCategories.tsx index ae8855fb53..a539f18d11 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/components/table/components/filterCategories.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/components/table/components/filterCategories.tsx @@ -1,6 +1,6 @@ "use client"; -import { STATUS_FILTERS } from "./filterConfigs"; +import { ARCHIVED_FILTER, STATUS_FILTERS } from "./filterConfigs"; import type { User } from "next-auth"; interface FilterCategoriesProps { @@ -8,6 +8,8 @@ interface FilterCategoriesProps { setStatus: (status: string | null) => void; ownerId: string | null; setOwnerId: (ownerId: string | null) => void; + isArchived: string | null; + setIsArchived: (isArchived: string | null) => void; users: User[]; setPage: (page: string) => void; } @@ -17,6 +19,8 @@ export function getFilterCategories({ setStatus, ownerId, setOwnerId, + isArchived, + setIsArchived, users, setPage, }: FilterCategoriesProps) { @@ -32,6 +36,19 @@ export function getFilterCategories({ }, })), }, + { + label: "Filter by Archived", + items: [ + { + ...ARCHIVED_FILTER, + checked: isArchived === ARCHIVED_FILTER.value, + onChange: (checked: boolean) => { + setIsArchived(checked ? ARCHIVED_FILTER.value : null); + setPage("1"); + }, + }, + ], + }, { label: "Filter by Owner", items: users.map((user) => ({ diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/components/table/components/filterConfigs.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/components/table/components/filterConfigs.tsx index 7d1a7d631d..244d74ea58 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/components/table/components/filterConfigs.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/components/table/components/filterConfigs.tsx @@ -16,9 +16,10 @@ export const STATUS_FILTERS = [ value: "needs_review", icon:
, }, - { - label: "Archived", - value: "archived", - icon:
, - }, ] as const; + +export const ARCHIVED_FILTER = { + label: "Archived", + value: "true", + icon: , +} as const; diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/components/table/hooks/usePoliciesTableContext.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/components/table/hooks/usePoliciesTableContext.tsx index bcdfc1e17e..f93cc63379 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/components/table/hooks/usePoliciesTableContext.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/components/table/hooks/usePoliciesTableContext.tsx @@ -17,6 +17,7 @@ interface PoliciesTableContextType { search: string; status: string | null; ownerId: string | null; + isArchived: string | null; page: string; pageSize: string; @@ -24,6 +25,7 @@ interface PoliciesTableContextType { setSearch: (value: string) => void; setStatus: (value: string | null) => void; setOwnerId: (value: string | null) => void; + setIsArchived: (value: string | null) => void; setPage: (value: string) => void; setPageSize: (value: string) => void; @@ -51,6 +53,7 @@ export function PoliciesTableProvider({ children }: { children: ReactNode }) { // Query state for other filters const [status, setStatus] = useQueryState("status"); const [ownerId, setOwnerId] = useQueryState("ownerId"); + const [isArchived, setIsArchived] = useQueryState("isArchived"); const [page, setPage] = useQueryState("page", { defaultValue: "1" }); const [pageSize, setPageSize] = useQueryState("pageSize", { defaultValue: "10", @@ -68,6 +71,7 @@ export function PoliciesTableProvider({ children }: { children: ReactNode }) { search, status: status || undefined, ownerId: ownerId || undefined, + isArchived: isArchived || undefined, page: currentPage, pageSize: currentPageSize, }); @@ -77,7 +81,7 @@ export function PoliciesTableProvider({ children }: { children: ReactNode }) { if (initialLoadCompleted.current) { setIsSearching(true); } - }, [search, status, ownerId, page, pageSize]); + }, [search, status, ownerId, isArchived, page, pageSize]); // Track when loading changes useEffect(() => { @@ -103,13 +107,14 @@ export function PoliciesTableProvider({ children }: { children: ReactNode }) { // Check if any filters are active const hasActiveFilters = useMemo(() => { - return status !== null || ownerId !== null; - }, [status, ownerId]); + return status !== null || ownerId !== null || isArchived !== null; + }, [status, ownerId, isArchived]); // Clear all filters const clearFilters = () => { setStatus(null); setOwnerId(null); + setIsArchived(null); setPage("1"); // Reset to first page when clearing filters setSearch(""); // Clear search }; @@ -119,6 +124,7 @@ export function PoliciesTableProvider({ children }: { children: ReactNode }) { search, status, ownerId, + isArchived, page, pageSize, @@ -126,6 +132,7 @@ export function PoliciesTableProvider({ children }: { children: ReactNode }) { setSearch, setStatus, setOwnerId, + setIsArchived, setPage, setPageSize, diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/hooks/usePolicies.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/hooks/usePolicies.ts index 4708df8ab7..44b46ca9f4 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/hooks/usePolicies.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/hooks/usePolicies.ts @@ -30,6 +30,7 @@ export function usePolicies({ search = "", status = "", ownerId = "", + isArchived = "", sort = "", page = 1, pageSize = 10, @@ -40,8 +41,17 @@ export function usePolicies({ isLoading, mutate: revalidatePolicies, } = useSWR( - ["policies", { search, status, page, pageSize, ownerId, sort }], - () => fetchPolicies({ search, status, page, pageSize, ownerId, sort }), + ["policies", { search, status, page, pageSize, ownerId, isArchived, sort }], + () => + fetchPolicies({ + search, + status, + page, + pageSize, + ownerId, + isArchived, + sort, + }), { revalidateOnFocus: false, revalidateOnReconnect: false, diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/hooks/usePoliciesTableContext.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/hooks/usePoliciesTableContext.tsx new file mode 100644 index 0000000000..c05e7edeab --- /dev/null +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/hooks/usePoliciesTableContext.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { + createContext, + useContext, + useMemo, + useState, + useRef, + useEffect, + type ReactNode, +} from "react"; +import { useQueryState } from "nuqs"; +import { usePolicies } from "./usePolicies"; + +interface PoliciesTableContextType { + // State + search: string; + status: string | null; + ownerId: string | null; + isArchived: string | null; + page: string; + pageSize: string; + + // Setters + setSearch: (value: string) => void; + setStatus: (value: string | null) => void; + setOwnerId: (value: string | null) => void; + setIsArchived: (value: string | null) => void; + setPage: (value: string) => void; + setPageSize: (value: string) => void; + + // Data + policies: any[] | undefined; + total: number | undefined; + isLoading: boolean; + isSearching: boolean; + + // Derived data + hasActiveFilters: boolean; + + // Actions + clearFilters: () => void; +} + +const PoliciesTableContext = createContext< + PoliciesTableContextType | undefined +>(undefined); + +export function PoliciesTableProvider({ children }: { children: ReactNode }) { + // Local state for search + const [search, setSearch] = useState(""); + + // Query state for other filters + const [status, setStatus] = useQueryState("status"); + const [ownerId, setOwnerId] = useQueryState("ownerId"); + const [isArchived, setIsArchived] = useQueryState("isArchived"); + const [page, setPage] = useQueryState("page", { defaultValue: "1" }); + const [pageSize, setPageSize] = useQueryState("pageSize", { + defaultValue: "10", + }); + + // Track if this is initial load or a search/filter update + const initialLoadCompleted = useRef(false); + const [isSearching, setIsSearching] = useState(false); + + const currentPage = Number.parseInt(page, 10); + const currentPageSize = Number.parseInt(pageSize, 10); + + // Fetch data + const { policies, total, isLoading } = usePolicies({ + search, + status: status || undefined, + ownerId: ownerId || undefined, + isArchived: isArchived || undefined, + page: currentPage, + pageSize: currentPageSize, + }); + + // Track when search params change + useEffect(() => { + if (initialLoadCompleted.current) { + setIsSearching(true); + } + }, [search, status, ownerId, isArchived, page, pageSize]); + + // Track when loading changes + useEffect(() => { + if (isLoading === false) { + // Small delay to ensure UI transitions properly + setTimeout(() => { + initialLoadCompleted.current = true; + setIsSearching(false); + }, 50); + } + }, [isLoading]); + + // Additional safety reset for isSearching when data changes + useEffect(() => { + if (policies && isSearching) { + // If we have data, ensure isSearching is eventually set to false + const timer = setTimeout(() => { + setIsSearching(false); + }, 100); + return () => clearTimeout(timer); + } + }, [policies, isSearching]); + + // Check if any filters are active + const hasActiveFilters = useMemo(() => { + return status !== null || ownerId !== null || isArchived !== null; + }, [status, ownerId, isArchived]); + + // Clear all filters + const clearFilters = () => { + setStatus(null); + setOwnerId(null); + setIsArchived(null); + setPage("1"); // Reset to first page when clearing filters + setSearch(""); // Clear search + }; + + const contextValue: PoliciesTableContextType = { + // State + search, + status, + ownerId, + isArchived, + page, + pageSize, + + // Setters + setSearch, + setStatus, + setOwnerId, + setIsArchived, + setPage, + setPageSize, + + // Data + policies, + total, // Use the total directly from the API + isLoading, + isSearching, + + // Derived data + hasActiveFilters, + + // Actions + clearFilters, + }; + + return ( + + {children} + + ); +} + +export function usePoliciesTable() { + const context = useContext(PoliciesTableContext); + + if (context === undefined) { + throw new Error( + "usePoliciesTable must be used within a PoliciesTableProvider", + ); + } + + return context; +} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/types/index.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/types/index.ts index 39b43ae651..6c95a60c66 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/types/index.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/(overview)/types/index.ts @@ -11,12 +11,14 @@ export const policySchema = z.object({ description: z.string().nullable(), slug: z.string(), }), + isArchived: z.boolean().optional(), }); export const policiesInputSchema = z.object({ search: z.string().optional(), status: z.string().optional(), ownerId: z.string().optional(), + isArchived: z.string().optional(), sort: z.string().optional(), page: z.number().default(1), pageSize: z.number().default(10), diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/[policyId]/editor/actions/update-policy.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/[policyId]/editor/actions/update-policy.ts index 9d1d23d780..75f7a7e62b 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/[policyId]/editor/actions/update-policy.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/policies/all/[policyId]/editor/actions/update-policy.ts @@ -7,98 +7,101 @@ import { db } from "@bubba/db"; import { appErrors, updatePolicySchema } from "../types"; export const updatePolicy = authActionClient - .schema(updatePolicySchema) - .metadata({ - name: "update-policy", - track: { - event: "update-policy", - channel: "server", - }, - }) - .action(async ({ parsedInput }): Promise => { - const { policyId, content, status } = parsedInput; + .schema(updatePolicySchema) + .metadata({ + name: "update-policy", + track: { + event: "update-policy", + channel: "server", + }, + }) + .action(async ({ parsedInput }): Promise => { + const { policyId, content, status } = parsedInput; - const session = await auth(); - const organizationId = session?.user.organizationId; + const session = await auth(); + const organizationId = session?.user.organizationId; - if (!organizationId) { - return { - success: false, - error: appErrors.UNAUTHORIZED.message, - }; - } + if (!organizationId) { + return { + success: false, + error: appErrors.UNAUTHORIZED.message, + }; + } - try { - const existingPolicy = await db.organizationPolicy.findUnique({ - where: { - id: policyId, - organizationId, - }, - }); + try { + const existingPolicy = await db.organizationPolicy.findUnique({ + where: { + id: policyId, + organizationId, + }, + }); - if (!existingPolicy) { - return { - success: false, - error: appErrors.NOT_FOUND.message, - }; - } + if (!existingPolicy) { + return { + success: false, + error: appErrors.NOT_FOUND.message, + }; + } - const updateData: Record = {}; + const updateData: Record = {}; - if (content !== undefined) { - console.log("CONTENT TYPE:", typeof content); + if (content !== undefined) { + console.log("CONTENT TYPE:", typeof content); - if (typeof content === "object" && content !== null) { - if ( - "type" in content && - content.type === "doc" && - "content" in content && - Array.isArray(content.content) - ) { - updateData.content = content.content; - console.log("Extracted content array from TipTap doc"); - } else if (Array.isArray(content)) { - updateData.content = content; - } else { - console.log("Unknown content format - using as is"); - updateData.content = content; - } - } else { - updateData.content = content; - } - } + if (typeof content === "object" && content !== null) { + if ( + "type" in content && + content.type === "doc" && + "content" in content && + Array.isArray(content.content) + ) { + updateData.content = content.content; + console.log("Extracted content array from TipTap doc"); + } else if (Array.isArray(content)) { + updateData.content = content; + } else { + console.log("Unknown content format - using as is"); + updateData.content = content; + } + } else { + updateData.content = content; + } + } - if (status) { - updateData.status = status; - } + if (status) { + updateData.status = status; + } - if (Object.keys(updateData).length === 0) { - return { - success: true, - data: { id: policyId, status: existingPolicy.status }, - }; - } + if (Object.keys(updateData).length === 0) { + return { + success: true, + data: { id: policyId, status: existingPolicy.status }, + }; + } - const updatedPolicy = await db.organizationPolicy.update({ - where: { - id: policyId, - }, - data: updateData, - select: { - id: true, - status: true, - }, - }); + const updatedPolicy = await db.organizationPolicy.update({ + where: { + id: policyId, + }, + data: { + ...updateData, + signedBy: [], + }, + select: { + id: true, + status: true, + }, + }); - return { - success: true, - data: updatedPolicy, - }; - } catch (error) { - console.error("Error updating policy:", error); - return { - success: false, - error: appErrors.UNEXPECTED_ERROR.message, - }; - } - }); + return { + success: true, + data: updatedPolicy, + }; + } catch (error) { + console.error("Error updating policy:", error); + return { + success: false, + error: appErrors.UNEXPECTED_ERROR.message, + }; + } + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/actions/update-vendor-inherent-risk.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/actions/update-vendor-inherent-risk.ts index 5af380f413..99aa857fbd 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/actions/update-vendor-inherent-risk.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/actions/update-vendor-inherent-risk.ts @@ -1,6 +1,6 @@ "use server"; -import { db } from "@/db"; +import { db } from "@bubba/db"; import { appErrors } from "@/lib/errors"; import type { ActionResponse } from "@/types/actions"; import { createSafeActionClient } from "next-safe-action"; @@ -21,13 +21,13 @@ export const updateVendorInherentRisk = createSafeActionClient() }); revalidatePath(`/vendors/${parsedInput.vendorId}`); - + return { success: true }; } catch (error) { return { success: false, - error: error instanceof Error ? error.message : appErrors.UNEXPECTED_ERROR, + error: + error instanceof Error ? error.message : appErrors.UNEXPECTED_ERROR, }; } - - }); \ No newline at end of file + }); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/actions/update-vendor-residual-risk.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/actions/update-vendor-residual-risk.ts index 64a56d730e..913e8a5882 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/actions/update-vendor-residual-risk.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/[vendorId]/actions/update-vendor-residual-risk.ts @@ -4,7 +4,7 @@ import { createSafeActionClient } from "next-safe-action"; import { z } from "zod"; import type { ActionResponse } from "@/types/actions"; import { appErrors } from "@/lib/errors"; -import { db } from "@/db"; +import { db } from "@bubba/db"; import type { VendorResidualRisk } from "@prisma/client"; const schema = z.object({ @@ -25,7 +25,8 @@ export const updateVendorResidualRisk = createSafeActionClient() } catch (error) { return { success: false, - error: error instanceof Error ? error.message : appErrors.UNEXPECTED_ERROR, + error: + error instanceof Error ? error.message : appErrors.UNEXPECTED_ERROR, }; } - }); \ No newline at end of file + }); diff --git a/apps/app/src/components/forms/policies/policy-overview.tsx b/apps/app/src/components/forms/policies/policy-overview.tsx index 081fdb12b9..309c687778 100644 --- a/apps/app/src/components/forms/policies/policy-overview.tsx +++ b/apps/app/src/components/forms/policies/policy-overview.tsx @@ -23,6 +23,7 @@ import { FormItem, FormLabel, FormMessage, + FormDescription, } from "@bubba/ui/form"; import { Popover, PopoverContent, PopoverTrigger } from "@bubba/ui/popover"; import { @@ -40,11 +41,11 @@ import { useAction } from "next-safe-action/hooks"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import type { z } from "zod"; +import { Switch } from "@bubba/ui/switch"; const policyStatuses: PolicyStatus[] = [ "draft", "published", - "archived", "needs_review", ] as const; @@ -85,7 +86,7 @@ export function UpdatePolicyOverview({ department: organizationPolicy.department ?? Departments.admin, review_frequency: organizationPolicy.frequency ?? Frequency.monthly, review_date: reviewDate, - isRequiredToSign: (organizationPolicy.policy as any).isRequiredToSign + isRequiredToSign: organizationPolicy.isRequiredToSign ? "required" : "not_required", }, @@ -289,29 +290,19 @@ export function UpdatePolicyOverview({ control={form.control} name="isRequiredToSign" render={({ field }) => ( - + {t("policies.overview.form.signature_requirement")} - + { + field.onChange(checked ? "required" : "not_required"); + }} + /> + )} /> diff --git a/apps/app/src/components/header.tsx b/apps/app/src/components/header.tsx index 25abf862f8..58a3c844f6 100644 --- a/apps/app/src/components/header.tsx +++ b/apps/app/src/components/header.tsx @@ -48,7 +48,7 @@ export async function Header() {
- + {/* */} }> diff --git a/apps/app/src/components/mobile-menu.tsx b/apps/app/src/components/mobile-menu.tsx index 37ee071eea..946ea43fa6 100644 --- a/apps/app/src/components/mobile-menu.tsx +++ b/apps/app/src/components/mobile-menu.tsx @@ -32,11 +32,7 @@ export function MobileMenu({
- setOpen(false)} - organizationId={organizationId} - userIsAdmin={isAdmin} - /> + ); diff --git a/apps/app/src/components/policies/charts/policies-by-assignee.tsx b/apps/app/src/components/policies/charts/policies-by-assignee.tsx index 66ef9fa841..a197ee92b7 100644 --- a/apps/app/src/components/policies/charts/policies-by-assignee.tsx +++ b/apps/app/src/components/policies/charts/policies-by-assignee.tsx @@ -45,7 +45,7 @@ export async function PoliciesByAssignee({ organizationId }: Props) { (policy) => policy.status === "draft", ).length, archivedPolicies: user.organization?.OrganizationPolicy.filter( - (policy) => policy.status === "archived", + (policy) => policy.isArchived, ).length, needsReviewPolicies: user.organization?.OrganizationPolicy.filter( (policy) => policy.status === "needs_review", @@ -221,6 +221,7 @@ const userData = async (organizationId: string) => { OrganizationPolicy: { select: { status: true, + isArchived: true, }, }, }, diff --git a/apps/app/src/components/policies/policy-overview.tsx b/apps/app/src/components/policies/policy-overview.tsx index 66ae5831ae..c117fd32ae 100644 --- a/apps/app/src/components/policies/policy-overview.tsx +++ b/apps/app/src/components/policies/policy-overview.tsx @@ -6,10 +6,13 @@ 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 { PencilIcon } from "lucide-react"; +import { ArchiveIcon, PencilIcon, ArchiveRestoreIcon } from "lucide-react"; import { useQueryState } from "nuqs"; import { PolicyOverviewSheet } from "./sheets/policy-overview-sheet"; import { UpdatePolicyOverview } from "../forms/policies/policy-overview"; +import { PolicyArchiveSheet } from "./sheets/policy-archive-sheet"; +import { format } from "date-fns"; +import { useRouter } from "next/navigation"; export function PolicyOverview({ policy, @@ -21,23 +24,74 @@ export function PolicyOverview({ users: User[]; }) { const t = useI18n(); + const router = useRouter(); const [open, setOpen] = useQueryState("policy-overview-sheet"); + const [archiveOpen, setArchiveOpen] = useQueryState("archive-policy-sheet"); return (
+ {policy.isArchived && ( + +
+ +
{t("policies.archive.status")}
+
+ + {policy.archivedAt && ( + <> + {t("policies.archive.archived_on")}{" "} + {format(new Date(policy.archivedAt), "PPP")} + + )} + + +
+ )} +
{policy.policy.name} - +
+ + +
@@ -59,6 +113,7 @@ export function PolicyOverview({ +
); } diff --git a/apps/app/src/components/policies/sheets/policy-archive-sheet.tsx b/apps/app/src/components/policies/sheets/policy-archive-sheet.tsx new file mode 100644 index 0000000000..2cf0620698 --- /dev/null +++ b/apps/app/src/components/policies/sheets/policy-archive-sheet.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { archivePolicyAction } from "@/actions/policies/archive-policy"; +import { useI18n } from "@/locales/client"; +import type { OrganizationPolicy, Policy } from "@bubba/db/types"; +import { Button } from "@bubba/ui/button"; +import { Drawer, DrawerContent, DrawerTitle } from "@bubba/ui/drawer"; +import { useMediaQuery } from "@bubba/ui/hooks"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@bubba/ui/sheet"; +import { ArchiveIcon, ArchiveRestoreIcon, X } from "lucide-react"; +import { useAction } from "next-safe-action/hooks"; +import { useRouter } from "next/navigation"; +import { useQueryState } from "nuqs"; +import { toast } from "sonner"; + +export function PolicyArchiveSheet({ + policy, +}: { + policy: OrganizationPolicy & { policy: Policy }; +}) { + const t = useI18n(); + const router = useRouter(); + const isDesktop = useMediaQuery("(min-width: 768px)"); + const [open, setOpen] = useQueryState("archive-policy-sheet"); + const isOpen = Boolean(open); + const isArchived = policy.isArchived; + + const archivePolicy = useAction(archivePolicyAction, { + onSuccess: (result) => { + if (result.data?.isArchived) { + toast.success(t("policies.archive.success")); + // Redirect to policies list after successful archive + router.push(`/${policy.organizationId}/policies/all`); + } else { + toast.success(t("policies.archive.restore_success")); + // Stay on the policy page after restore + router.refresh(); + } + handleOpenChange(false); + }, + onError: () => { + toast.error(t("policies.archive.error")); + }, + }); + + const handleOpenChange = (open: boolean) => { + setOpen(open ? "true" : null); + }; + + const handleAction = () => { + archivePolicy.execute({ + id: policy.id, + action: isArchived ? "restore" : "archive", + }); + }; + + const content = ( +
+

+ {isArchived + ? t("policies.archive.restore_description") + : t("policies.archive.description")} +

+
+ + +
+
+ ); + + if (isDesktop) { + return ( + + + +
+ + {isArchived + ? t("policies.archive.restore_title") + : t("policies.archive.title")} + + +
+ {policy.policy.name} +
+ {content} +
+
+ ); + } + + return ( + + + +
+

+ {isArchived + ? t("policies.archive.restore_title") + : t("policies.archive.title")} +

+

+ {policy.policy.name} +

+
+ {content} +
+
+ ); +} diff --git a/apps/app/src/components/sheets/invite-user-sheet.tsx b/apps/app/src/components/sheets/add-employee-sheet.tsx similarity index 100% rename from apps/app/src/components/sheets/invite-user-sheet.tsx rename to apps/app/src/components/sheets/add-employee-sheet.tsx diff --git a/apps/app/src/components/tables/people/empty-states.tsx b/apps/app/src/components/tables/people/empty-states.tsx index 5c8f2d83a8..7c8836c2f2 100644 --- a/apps/app/src/components/tables/people/empty-states.tsx +++ b/apps/app/src/components/tables/people/empty-states.tsx @@ -6,7 +6,7 @@ import { Users } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; import { useQueryState } from "nuqs"; import { Plus } from "lucide-react"; -import { EmployeeInviteSheet } from "@/components/sheets/invite-user-sheet"; +import { EmployeeInviteSheet } from "@/components/sheets/add-employee-sheet"; interface Props { hasFilters?: boolean; diff --git a/apps/app/src/components/tables/people/filter-toolbar.tsx b/apps/app/src/components/tables/people/filter-toolbar.tsx index 8dbec46ae5..5b7deaca0c 100644 --- a/apps/app/src/components/tables/people/filter-toolbar.tsx +++ b/apps/app/src/components/tables/people/filter-toolbar.tsx @@ -1,6 +1,6 @@ "use client"; -import { EmployeeInviteSheet } from "@/components/sheets/invite-user-sheet"; +import { EmployeeInviteSheet } from "@/components/sheets/add-employee-sheet"; import { useI18n } from "@/locales/client"; import { Button } from "@bubba/ui/button"; import { Input } from "@bubba/ui/input"; diff --git a/apps/app/src/jobs/tasks/organization/create-organization.ts b/apps/app/src/jobs/tasks/organization/create-organization.ts index ce8e6f9634..c778e21478 100644 --- a/apps/app/src/jobs/tasks/organization/create-organization.ts +++ b/apps/app/src/jobs/tasks/organization/create-organization.ts @@ -67,7 +67,8 @@ export const createOrganizationTask = schemaTask({ await createOrganizationControlRequirements( organizationId, - organizationFrameworks.map((framework) => framework.id) + organizationFrameworks.map((framework) => framework.id), + userId ); await createOrganizationEvidence(organizationId, frameworkIds, userId); @@ -219,13 +220,26 @@ const createOrganizationPolicy = async ( } } + const organization = await db.organization.findUnique({ + where: { + id: organizationId, + }, + }); + + const currentDate = new Date(); + const formattedDate = currentDate.toISOString().split("T")[0]; // YYYY-MM-DD format + const organizationPolicies = await db.organizationPolicy.createMany({ data: policiesForFrameworks.map((policy) => ({ organizationId, policyId: policy.id, ownerId: userId, status: "draft", - content: policy.content as InputJsonValue[], + content: processContentPlaceholders( + policy.content as InputJsonValue[], + organization?.name || "", + formattedDate + ), frequency: policy.frequency, })), }); @@ -233,6 +247,26 @@ const createOrganizationPolicy = async ( return organizationPolicies; }; +/** + * Processes content placeholders by converting to string, replacing values, and converting back + */ +function processContentPlaceholders( + content: InputJsonValue[], + organizationName: string, + formattedDate: string +): InputJsonValue[] { + // Convert the content to a string + const contentString = JSON.stringify(content); + + // Replace all occurrences of the placeholders + const processedString = contentString + .replace(/{{organization}}/g, organizationName) + .replace(/{{date}}/g, formattedDate); + + // Convert back to an object + return JSON.parse(processedString); +} + const createOrganizationCategories = async ( organizationId: string, frameworkIds: string[] @@ -263,7 +297,8 @@ const createOrganizationCategories = async ( const createOrganizationControlRequirements = async ( organizationId: string, - organizationFrameworkIds: string[] + organizationFrameworkIds: string[], + userId: string ) => { if (!organizationId) { throw new Error("Not authorized - no organization found"); @@ -349,7 +384,7 @@ const createOrganizationControlRequirements = async ( description: requirement.description, frequency: requirement.frequency, frameworkId: requirement.control.frameworkCategory?.framework.id || "", - assigneeId: null, // No user provided in this function + assigneeId: userId, department: requirement.department, }); // Add the new ID to our set so subsequent iterations don't duplicate it @@ -567,7 +602,7 @@ const createOrganizationEvidence = async ( description: req.description, frequency: req.frequency, frameworkId: req.control.frameworkCategory?.framework.id || "", - assigneeId: userId, + assigneeId: userId, // Always use the provided userId department: req.department, })), }); diff --git a/apps/app/src/lib/db/employee.ts b/apps/app/src/lib/db/employee.ts index 1e2d5f1e7b..682b880e25 100644 --- a/apps/app/src/lib/db/employee.ts +++ b/apps/app/src/lib/db/employee.ts @@ -1,18 +1,11 @@ import { db } from "@bubba/db"; import type { Employee, Departments } from "@bubba/db/types"; +import { InvitePortalEmail } from "@bubba/email/emails/invite-portal"; +import { sendEmail } from "@bubba/email/lib/resend"; -const DEFAULT_TASKS = [ - { - code: "POLICY-ACCEPT", - name: "Policy Acceptance", - description: "Review and accept company policies", - }, - { - code: "DEVICE-SECURITY", - name: "Device Security", - description: "Complete device security checklist and configuration", - }, -] as const; +if (!process.env.NEXT_PUBLIC_PORTAL_URL) { + throw new Error("NEXT_PUBLIC_PORTAL_URL is not set"); +} /** * Find an existing employee by email and organization ID @@ -120,46 +113,33 @@ export async function createOrUpdatePortalUser(params: { }); } -/** - * Create default tasks for an employee - */ -export async function createDefaultTasksForEmployee( - employeeId: string -): Promise { - // Create or get the required task definitions first and store their IDs - const requiredTasks = await Promise.all( - DEFAULT_TASKS.map(async (task) => { - return db.employeeRequiredTask.upsert({ - where: { code: task.code }, - create: { - code: task.code, - name: task.name, - description: task.description, - }, - update: {}, - }); - }) - ); - - // Now create the employee tasks using the actual task IDs - await Promise.all( - requiredTasks.map(async (task) => { - return db.employeeTask.create({ - data: { - employeeId: employeeId, - requiredTaskId: task.id, - status: "assigned", - }, - }); - }) - ); -} +const inviteEmployeeToPortal = async (params: { + email: string; + organizationName: string; + inviteLink: string; +}) => { + const { email, organizationName, inviteLink } = params; + + await sendEmail({ + to: email, + subject: `You've been invited to join ${organizationName || "an organization"} on Comp AI`, + react: InvitePortalEmail({ + email, + organizationName, + inviteLink, + }), + }); + + return { + success: true, + message: "Employee invited to portal", + }; +}; /** * Complete employee creation by handling all steps: * 1. Create/reactivate the employee * 2. Create/update portal user - * 3. Create default tasks */ export async function completeEmployeeCreation(params: { name: string; @@ -177,7 +157,17 @@ export async function completeEmployeeCreation(params: { organizationId: params.organizationId, }); - await createDefaultTasksForEmployee(employee.id); + const organization = await db.organization.findUnique({ + where: { + id: params.organizationId, + }, + }); + + await inviteEmployeeToPortal({ + email: params.email, + organizationName: organization?.name || "an organization", + inviteLink: `${process.env.NEXT_PUBLIC_PORTAL_URL}`, + }); return employee; } diff --git a/apps/app/src/locales/features/people.ts b/apps/app/src/locales/features/people.ts index 2b24e3178f..addb6d5eb3 100644 --- a/apps/app/src/locales/features/people.ts +++ b/apps/app/src/locales/features/people.ts @@ -56,7 +56,8 @@ export const people = { placeholder: "Select a department", }, submit: "Add Employee", - success: "Employee added successfully", + success: + "Employee added successfully, they will receive an email to join the portal.", error: "Failed to add employee", }, dashboard: { diff --git a/apps/app/src/locales/features/policies.ts b/apps/app/src/locales/features/policies.ts index 74ac79ec30..9cfbf41f13 100644 --- a/apps/app/src/locales/features/policies.ts +++ b/apps/app/src/locales/features/policies.ts @@ -67,7 +67,7 @@ export const policies = { needs_review: "Needs Review", archived: "Archived", relevant: "Relevant", - "not-relevant": "Not Relevant" + "not-relevant": "Not Relevant", }, policies: "policies", title: "Policies", @@ -81,4 +81,23 @@ export const policies = { last_updated: "Last updated: {{date}}", save: "Save", policy_details: "Policy Details", + archive: { + tooltip: "Archive policy", + restore_tooltip: "Restore policy", + title: "Archive Policy", + restore_title: "Restore Policy", + description: "Are you sure you want to archive this policy?", + restore_description: "Are you sure you want to restore this policy?", + confirm: "Archive", + restore_confirm: "Restore", + cancel: "Cancel", + success: "Policy archived successfully", + restore_success: "Policy restored successfully", + error: "Failed to update policy archive status", + status: "This policy is archived", + archived_on: "Archived on", + }, + edit: { + tooltip: "Edit policy", + }, } as const; diff --git a/packages/db/prisma/migrations/20250328192535_move_archive_to_its_own_column_in_policies/migration.sql b/packages/db/prisma/migrations/20250328192535_move_archive_to_its_own_column_in_policies/migration.sql new file mode 100644 index 0000000000..bfbdb31d6e --- /dev/null +++ b/packages/db/prisma/migrations/20250328192535_move_archive_to_its_own_column_in_policies/migration.sql @@ -0,0 +1,20 @@ +/* + Warnings: + + - The values [archived] on the enum `PolicyStatus` will be removed. If these variants are still used in the database, this will fail. + +*/ +-- AlterEnum +BEGIN; +CREATE TYPE "PolicyStatus_new" AS ENUM ('draft', 'published', 'needs_review'); +ALTER TABLE "OrganizationPolicy" ALTER COLUMN "status" DROP DEFAULT; +ALTER TABLE "OrganizationPolicy" 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 "OrganizationPolicy" ALTER COLUMN "status" SET DEFAULT 'draft'; +COMMIT; + +-- AlterTable +ALTER TABLE "OrganizationPolicy" ADD COLUMN "archivedAt" TIMESTAMP(3), +ADD COLUMN "isArchived" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/db/prisma/schema/policy.prisma b/packages/db/prisma/schema/policy.prisma index bec5a65acf..d67831e0dd 100644 --- a/packages/db/prisma/schema/policy.prisma +++ b/packages/db/prisma/schema/policy.prisma @@ -10,6 +10,8 @@ model OrganizationPolicy { lastPublishedAt DateTime? isRequiredToSign Boolean @default(false) signedBy String[] @default([]) + isArchived Boolean @default(false) + archivedAt DateTime? OrganizationControlRequirement OrganizationControlRequirement[] PolicyComments PolicyComments[] organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) diff --git a/packages/db/prisma/schema/schema.prisma b/packages/db/prisma/schema/schema.prisma index b952788f90..51f5d75f3b 100644 --- a/packages/db/prisma/schema/schema.prisma +++ b/packages/db/prisma/schema/schema.prisma @@ -311,7 +311,6 @@ enum EmployeeTaskStatus { enum PolicyStatus { draft published - archived needs_review } diff --git a/packages/email/emails/invite-portal.tsx b/packages/email/emails/invite-portal.tsx new file mode 100644 index 0000000000..2ee79cf1df --- /dev/null +++ b/packages/email/emails/invite-portal.tsx @@ -0,0 +1,106 @@ +import { + Body, + Button, + Container, + Font, + Heading, + Html, + Link, + Preview, + Section, + Tailwind, + Text, +} from "@react-email/components"; +import { Footer } from "../components/footer"; +import { Logo } from "../components/logo"; + +interface Props { + email: string; + inviteLink: string; + organizationName?: string; +} + +export const InvitePortalEmail = ({ + email, + inviteLink, + organizationName, +}: Props) => { + return ( + + + + + + + + + You've been invited to the Comp AI Portal + + + + + + You've been invited to the Comp AI Portal + + + + {organizationName + ? `${organizationName} has invited you to access their Comp AI Portal.` + : "You've been invited to access the Comp AI Portal."} + +
+ +
+ + + or copy and paste this URL into your browser{" "} + + {inviteLink} + + + +
+
+ + This invitation was intended for{" "} + {email}.{" "} + +
+ +
+ +