From f012e34cb29316e8da44ebfb1675ef4916575ef2 Mon Sep 17 00:00:00 2001 From: BradyMitch Date: Tue, 19 May 2026 09:08:05 -0700 Subject: [PATCH] counter form --- src/app/protected/settings/counters/page.tsx | 10 + .../settings/counters/CounterForm.tsx | 77 +++++ .../counters/CounterTable/CounterTable.tsx | 51 ++- .../CreateCounterModal/CreateCounterModal.tsx | 78 +++++ .../counters/CreateCounterModal/index.ts | 1 + .../EditCounterModal/EditCounterModal.tsx | 97 ++++++ .../counters/EditCounterModal/index.ts | 1 + .../settings/counters/useCounterForm/index.ts | 1 + .../counters/useCounterForm/useCounterForm.ts | 93 ++++++ .../counters/useCreateCounterModal/index.ts | 1 + .../useCreateCounterModal.ts | 92 ++++++ .../counters/useEditCounterModal/index.ts | 1 + .../useEditCounterModal.ts | 104 ++++++ src/lib/prisma/counter/insertCounter.test.ts | 151 +++++++++ src/lib/prisma/counter/insertCounter.ts | 37 +++ src/lib/prisma/counter/updateCounter.test.ts | 310 ++++++++++++++++++ src/lib/prisma/counter/updateCounter.ts | 80 +++++ src/utils/policies/resources/counter.ts | 2 +- 18 files changed, 1184 insertions(+), 3 deletions(-) create mode 100644 src/components/settings/counters/CounterForm.tsx create mode 100644 src/components/settings/counters/CreateCounterModal/CreateCounterModal.tsx create mode 100644 src/components/settings/counters/CreateCounterModal/index.ts create mode 100644 src/components/settings/counters/EditCounterModal/EditCounterModal.tsx create mode 100644 src/components/settings/counters/EditCounterModal/index.ts create mode 100644 src/hooks/settings/counters/useCounterForm/index.ts create mode 100644 src/hooks/settings/counters/useCounterForm/useCounterForm.ts create mode 100644 src/hooks/settings/counters/useCreateCounterModal/index.ts create mode 100644 src/hooks/settings/counters/useCreateCounterModal/useCreateCounterModal.ts create mode 100644 src/hooks/settings/counters/useEditCounterModal/index.ts create mode 100644 src/hooks/settings/counters/useEditCounterModal/useEditCounterModal.ts create mode 100644 src/lib/prisma/counter/insertCounter.test.ts create mode 100644 src/lib/prisma/counter/insertCounter.ts create mode 100644 src/lib/prisma/counter/updateCounter.test.ts create mode 100644 src/lib/prisma/counter/updateCounter.ts diff --git a/src/app/protected/settings/counters/page.tsx b/src/app/protected/settings/counters/page.tsx index 0500bc1..49153a4 100644 --- a/src/app/protected/settings/counters/page.tsx +++ b/src/app/protected/settings/counters/page.tsx @@ -2,6 +2,10 @@ import { revalidatePath } from "next/cache" import { headers } from "next/headers" import { CounterTable } from "@/components/settings/counters/CounterTable" import { getAllCounters } from "@/lib/prisma/counter/getAllCounters" +import { insertCounter } from "@/lib/prisma/counter/insertCounter" +import { updateCounter } from "@/lib/prisma/counter/updateCounter" +import { getAllLocations } from "@/lib/prisma/location/getAllLocations" +import { getAllStaffUsers } from "@/lib/prisma/staff_user/getAllStaffUsers" import { getStaffUserBySub } from "@/lib/prisma/staff_user/getStaffUserBySub" import { getAuthContext } from "@/utils/auth/getAuthContext" @@ -15,6 +19,8 @@ export default async function Page() { const currentUser = await getStaffUserBySub(user?.sub ?? "") const counters = await getAllCounters() + const locations = await getAllLocations() + const staffUsers = await getAllStaffUsers() const revalidateTable = async () => { "use server" @@ -27,6 +33,10 @@ export default async function Page() { diff --git a/src/components/settings/counters/CounterForm.tsx b/src/components/settings/counters/CounterForm.tsx new file mode 100644 index 0000000..a399382 --- /dev/null +++ b/src/components/settings/counters/CounterForm.tsx @@ -0,0 +1,77 @@ +import type { Dispatch, SetStateAction } from "react" +import { TextField } from "@/components/common" +import { MultiSelect } from "@/components/common/select/MultiSelect" +import { useCounterForm } from "@/hooks/settings/counters/useCounterForm" +import type { CounterWithRelations } from "@/lib/prisma/counter/types" +import type { LocationWithRelations } from "@/lib/prisma/location/types" +import type { StaffUserWithRelations } from "@/lib/prisma/staff_user/types" + +export type CounterFormProps = { + counter: Partial + locations: LocationWithRelations[] + staffUsers: StaffUserWithRelations[] + setFormData: Dispatch | null>> + isReadonly: boolean +} + +/** + * CounterForm component renders the form fields for editing a counter. + * + * @param props - The properties object. + * @property props.counter - The counter being edited. + * @property props.locations - List of office locations. + * @property props.staffUsers - List of staff users for assignment. + * @property props.setFormData - Function to update the form data state. + * @property props.isReadonly - Whether the section inputs are read-only. + */ +export const CounterForm = ({ + counter, + locations, + staffUsers, + setFormData, + isReadonly, +}: CounterFormProps) => { + const { + selectedLocationCodes, + locationOptions, + selectedStaffUserGuids, + staffUserOptions, + handleNameChange, + handleLocationsChange, + handleStaffUsersChange, + } = useCounterForm({ counter, locations, staffUsers, setFormData }) + + return ( +
+
+ +
+
+ + +
+ ) +} diff --git a/src/components/settings/counters/CounterTable/CounterTable.tsx b/src/components/settings/counters/CounterTable/CounterTable.tsx index ea8ee0a..141137f 100644 --- a/src/components/settings/counters/CounterTable/CounterTable.tsx +++ b/src/components/settings/counters/CounterTable/CounterTable.tsx @@ -3,17 +3,46 @@ import { DataTable } from "@/components/common/datatable" import { useCounterTable } from "@/hooks/settings/counters/useCounterTable" import type { CounterWithRelations } from "@/lib/prisma/counter/types" +import type { LocationWithRelations } from "@/lib/prisma/location/types" import type { StaffUserWithRelations } from "@/lib/prisma/staff_user/types" +import { CreateCounterModal } from "../CreateCounterModal" +import { EditCounterModal } from "../EditCounterModal" import { columns } from "./columns" export type CounterTableProps = { currentUser: StaffUserWithRelations | null counters: CounterWithRelations[] + locations: LocationWithRelations[] + staffUsers: StaffUserWithRelations[] + updateCounter: ( + counter: Partial, + prevCounter: Partial + ) => Promise + insertCounter: (counter: Partial) => Promise revalidateTable: () => Promise } -export const CounterTable = ({ currentUser, counters, revalidateTable }: CounterTableProps) => { - const { canCreate, countersToShow, handleRowClick, openCreateCounterModal } = useCounterTable({ +export const CounterTable = ({ + currentUser, + counters, + locations, + staffUsers, + updateCounter, + insertCounter, + revalidateTable, +}: CounterTableProps) => { + const { + selectedCounter, + canCreate, + canEditSelectedCounter, + countersToShow, + handleRowClick, + editCounterModalOpen, + closeEditCounterModal, + createCounterModalOpen, + openCreateCounterModal, + closeCreateCounterModal, + } = useCounterTable({ currentUser, counters, revalidateTable, @@ -43,6 +72,24 @@ export const CounterTable = ({ currentUser, counters, revalidateTable }: Counter emptyMessage="No counters found." onRowClick={handleRowClick} /> + + ) } diff --git a/src/components/settings/counters/CreateCounterModal/CreateCounterModal.tsx b/src/components/settings/counters/CreateCounterModal/CreateCounterModal.tsx new file mode 100644 index 0000000..3f6571e --- /dev/null +++ b/src/components/settings/counters/CreateCounterModal/CreateCounterModal.tsx @@ -0,0 +1,78 @@ +"use client" + +import { + CloseButton, + DialogActions, + DialogBody, + DialogHeader, + DialogTitle, + Modal, +} from "@/components/common/dialog" +import { useCreateCounterModal } from "@/hooks/settings/counters/useCreateCounterModal" +import type { CounterWithRelations } from "@/lib/prisma/counter/types" +import type { LocationWithRelations } from "@/lib/prisma/location/types" +import type { StaffUserWithRelations } from "@/lib/prisma/staff_user/types" +import { CounterForm } from "../CounterForm" + +type CreateCounterModalProps = { + open: boolean + onClose: () => void + locations: LocationWithRelations[] + staffUsers: StaffUserWithRelations[] + insertCounter: (counter: Partial) => Promise + revalidateTable: () => Promise +} + +export const CreateCounterModal = ({ + open, + onClose, + locations, + staffUsers, + insertCounter, + revalidateTable, +}: CreateCounterModalProps) => { + const { isSaving, error, formData, setFormData, isReadonly, isSaveDisabled, handleSave } = + useCreateCounterModal({ + open, + onClose, + insertCounter, + revalidateTable, + }) + + if (!formData) return null + + return ( + + }> + Create Counter + + + +
+ {error && ( +
+

{error}

+
+ )} + + + +
+ + + + + +
+ ) +} diff --git a/src/components/settings/counters/CreateCounterModal/index.ts b/src/components/settings/counters/CreateCounterModal/index.ts new file mode 100644 index 0000000..b4901dd --- /dev/null +++ b/src/components/settings/counters/CreateCounterModal/index.ts @@ -0,0 +1 @@ +export * from "./CreateCounterModal" diff --git a/src/components/settings/counters/EditCounterModal/EditCounterModal.tsx b/src/components/settings/counters/EditCounterModal/EditCounterModal.tsx new file mode 100644 index 0000000..8ebf53a --- /dev/null +++ b/src/components/settings/counters/EditCounterModal/EditCounterModal.tsx @@ -0,0 +1,97 @@ +"use client" + +import { + CloseButton, + DialogActions, + DialogBody, + DialogHeader, + DialogTitle, + Modal, +} from "@/components/common/dialog" +import { useEditCounterModal } from "@/hooks/settings/counters/useEditCounterModal" +import type { CounterWithRelations } from "@/lib/prisma/counter/types" +import type { LocationWithRelations } from "@/lib/prisma/location/types" +import type { StaffUserWithRelations } from "@/lib/prisma/staff_user/types" +import { CounterForm } from "../CounterForm" + +type EditCounterModalProps = { + open: boolean + onClose: () => void + counter: CounterWithRelations | null + locations: LocationWithRelations[] + staffUsers: StaffUserWithRelations[] + canEdit: boolean + updateCounter: ( + counter: Partial, + prevCounter: Partial + ) => Promise + revalidateTable: () => Promise +} + +export const EditCounterModal = ({ + open, + onClose, + counter, + locations, + staffUsers, + canEdit, + updateCounter, + revalidateTable, +}: EditCounterModalProps) => { + const { isSaving, error, formData, setFormData, isReadonly, isSaveDisabled, handleSave } = + useEditCounterModal({ + open, + onClose, + counter, + canEdit, + updateCounter, + revalidateTable, + }) + + if (!counter || !formData) return null + + return ( + + }> + Edit Counter: {counter.name} + + + +
+ {!canEdit && ( +
+

+ {counter.name === "Counter" + ? "The default counter cannot be edited." + : "You do not have permission to edit this counter."} +

+
+ )} + + {error && ( +
+

{error}

+
+ )} + + + +
+ + + + + +
+ ) +} diff --git a/src/components/settings/counters/EditCounterModal/index.ts b/src/components/settings/counters/EditCounterModal/index.ts new file mode 100644 index 0000000..2d629fd --- /dev/null +++ b/src/components/settings/counters/EditCounterModal/index.ts @@ -0,0 +1 @@ +export * from "./EditCounterModal" diff --git a/src/hooks/settings/counters/useCounterForm/index.ts b/src/hooks/settings/counters/useCounterForm/index.ts new file mode 100644 index 0000000..d982f08 --- /dev/null +++ b/src/hooks/settings/counters/useCounterForm/index.ts @@ -0,0 +1 @@ +export { useCounterForm } from "./useCounterForm" diff --git a/src/hooks/settings/counters/useCounterForm/useCounterForm.ts b/src/hooks/settings/counters/useCounterForm/useCounterForm.ts new file mode 100644 index 0000000..28fd30e --- /dev/null +++ b/src/hooks/settings/counters/useCounterForm/useCounterForm.ts @@ -0,0 +1,93 @@ +import type { Dispatch, SetStateAction } from "react" +import { useMemo } from "react" +import type { CounterWithRelations } from "@/lib/prisma/counter/types" +import type { LocationWithRelations } from "@/lib/prisma/location/types" +import type { StaffUserWithRelations } from "@/lib/prisma/staff_user/types" + +type UseCounterFormProps = { + counter: Partial + locations: LocationWithRelations[] + staffUsers: StaffUserWithRelations[] + setFormData: Dispatch | null>> +} + +/** + * Custom hook encapsulating all logic for the CounterForm component. + * + * @param props - Hook configuration. + * @property props.counter - The counter being edited. + * @property props.locations - List of office locations. + * @property props.staffUsers - List of all staff users. + * @property props.setFormData - Function to update the form data state. + * @returns Derived values and change handlers for the counter form. + */ +export const useCounterForm = ({ + counter, + locations, + staffUsers, + setFormData, +}: UseCounterFormProps) => { + const selectedLocationCodes = counter.locations ? counter.locations.map((l) => l.code) : [] + + const availableLocations = locations.filter((location) => location.deletedAt === null) + const locationOptions = useMemo( + () => availableLocations.map((l) => ({ key: l.code, label: l.name })), + [availableLocations] + ) + + const selectedStaffUserGuids = counter.staffUsers ? counter.staffUsers.map((u) => u.guid) : [] + + // Only show staff users who belong to one of the selected locations and are not deleted + const availableStaffUsers = useMemo( + () => + staffUsers.filter( + (u) => + u.deletedAt === null && + u.locationCode !== null && + selectedLocationCodes.includes(u.locationCode as string) + ), + [staffUsers, selectedLocationCodes] + ) + + const staffUserOptions = useMemo( + () => availableStaffUsers.map((u) => ({ key: u.guid, label: u.displayName })), + [availableStaffUsers] + ) + + const handleNameChange = (v: string) => setFormData((s) => (s ? { ...s, name: v } : s)) + + const handleLocationsChange = (selected: string[]) => + setFormData((s) => { + if (!s) return s + const newLocations = selected.map( + (code) => locations.find((l) => l.code === code) as LocationWithRelations + ) + // Remove any assigned staff users who no longer belong to the selected locations + const updatedStaffUsers = (s.staffUsers ?? []).filter( + (u) => u.locationCode !== null && selected.includes(u.locationCode as string) + ) + return { ...s, locations: newLocations, staffUsers: updatedStaffUsers } + }) + + const handleStaffUsersChange = (selected: string[]) => + setFormData((s) => + s + ? { + ...s, + staffUsers: selected.map( + (guid) => staffUsers.find((u) => u.guid === guid) as StaffUserWithRelations + ), + } + : s + ) + + return { + selectedLocationCodes, + locationOptions, + selectedStaffUserGuids, + staffUserOptions, + handleNameChange, + handleLocationsChange, + handleStaffUsersChange, + } +} diff --git a/src/hooks/settings/counters/useCreateCounterModal/index.ts b/src/hooks/settings/counters/useCreateCounterModal/index.ts new file mode 100644 index 0000000..b35e2e7 --- /dev/null +++ b/src/hooks/settings/counters/useCreateCounterModal/index.ts @@ -0,0 +1 @@ +export { useCreateCounterModal } from "./useCreateCounterModal" diff --git a/src/hooks/settings/counters/useCreateCounterModal/useCreateCounterModal.ts b/src/hooks/settings/counters/useCreateCounterModal/useCreateCounterModal.ts new file mode 100644 index 0000000..ff76ee1 --- /dev/null +++ b/src/hooks/settings/counters/useCreateCounterModal/useCreateCounterModal.ts @@ -0,0 +1,92 @@ +import { useRouter } from "next/navigation" +import { useEffect, useState } from "react" +import { z } from "zod" +import type { CounterWithRelations } from "@/lib/prisma/counter/types" + +type UseCreateCounterModalProps = { + open: boolean + onClose: () => void + insertCounter: (counter: Partial) => Promise + revalidateTable: () => Promise +} + +const NewCounterSchema = z.object({ + name: z.string().min(1, "Name is required"), + locations: z.array(z.any()), + staffUsers: z.array(z.any()), +}) + +/** + * Custom hook encapsulating all logic for the CreateCounterModal component. + * + * @param props - Hook configuration. + * @property props.open - Whether the modal is open. + * @property props.onClose - Callback to close the modal. + * @property props.insertCounter - Async function to persist the new counter. + * @property props.revalidateTable - Async function to refresh the table. + * @returns State, derived flags, form data, and handlers for the modal. + */ +export const useCreateCounterModal = ({ + open, + onClose, + insertCounter, + revalidateTable, +}: UseCreateCounterModalProps) => { + const router = useRouter() + const [isSaving, setIsSaving] = useState(false) + const [error, setError] = useState(null) + const [formData, setFormData] = useState | null>(null) + const [isFormValid, setIsFormValid] = useState(false) + + // Initialize form data when the modal opens + useEffect(() => { + if (open) { + setFormData({ name: "", locations: [], staffUsers: [] }) + setError(null) + } else { + setFormData(null) + setIsFormValid(false) + } + }, [open]) + + useEffect(() => { + if (!formData) { + setIsFormValid(false) + return + } + const result = NewCounterSchema.safeParse(formData) + setIsFormValid(result.success) + }, [formData]) + + const handleSave = async () => { + if (formData) { + try { + setIsSaving(true) + await insertCounter(formData) + await revalidateTable() + onClose() + setIsSaving(false) + router.refresh() + } catch (e: unknown) { + if (e instanceof Error) { + setError(e.message) + } else { + setError("An unknown error occurred") + } + setIsSaving(false) + } + } + } + + const isSaveDisabled = isSaving || !isFormValid + + return { + isSaving, + error, + formData, + setFormData, + isReadonly: false, + isSaveDisabled, + handleSave, + } +} diff --git a/src/hooks/settings/counters/useEditCounterModal/index.ts b/src/hooks/settings/counters/useEditCounterModal/index.ts new file mode 100644 index 0000000..3a43550 --- /dev/null +++ b/src/hooks/settings/counters/useEditCounterModal/index.ts @@ -0,0 +1 @@ +export { useEditCounterModal } from "./useEditCounterModal" diff --git a/src/hooks/settings/counters/useEditCounterModal/useEditCounterModal.ts b/src/hooks/settings/counters/useEditCounterModal/useEditCounterModal.ts new file mode 100644 index 0000000..9db9f2b --- /dev/null +++ b/src/hooks/settings/counters/useEditCounterModal/useEditCounterModal.ts @@ -0,0 +1,104 @@ +import { useRouter } from "next/navigation" +import { useEffect, useState } from "react" +import { z } from "zod" +import type { CounterWithRelations } from "@/lib/prisma/counter/types" + +type UseEditCounterModalProps = { + open: boolean + onClose: () => void + counter: CounterWithRelations | null + canEdit: boolean + updateCounter: ( + counter: Partial, + prevCounter: Partial + ) => Promise + revalidateTable: () => Promise +} + +const EditCounterSchema = z.object({ + name: z.string().min(1, "Name is required"), + locations: z.array(z.any()), + staffUsers: z.array(z.any()), +}) + +/** + * Custom hook encapsulating all logic for the EditCounterModal component. + * + * @param props - Hook configuration. + * @property props.open - Whether the modal is open. + * @property props.onClose - Callback to close the modal. + * @property props.counter - The counter being edited. + * @property props.canEdit - Whether the current user can edit this counter. + * @property props.updateCounter - Async function to persist changes. + * @property props.revalidateTable - Async function to refresh the table. + * @returns State, derived flags, form data, and handlers for the modal. + */ +export const useEditCounterModal = ({ + open, + onClose, + counter, + canEdit, + updateCounter, + revalidateTable, +}: UseEditCounterModalProps) => { + const router = useRouter() + const [isSaving, setIsSaving] = useState(false) + const [error, setError] = useState(null) + const [formData, setFormData] = useState | null>(null) + const [previousCounter, setPreviousCounter] = useState | null>(null) + const [isFormValid, setIsFormValid] = useState(false) + + const hasMadeChanges = JSON.stringify(formData) !== JSON.stringify(previousCounter) + + useEffect(() => { + if (open && counter) { + setFormData(counter) + setPreviousCounter(counter) + setError(null) + } + }, [open, counter]) + + useEffect(() => { + if (!formData) { + setIsFormValid(false) + return + } + + const result = EditCounterSchema.safeParse(formData) + setIsFormValid(result.success) + }, [formData]) + + const isReadonly = !canEdit + + const handleSave = async () => { + if (formData && previousCounter && !isReadonly) { + try { + setIsSaving(true) + await updateCounter(formData, previousCounter) + await revalidateTable() + onClose() + setIsSaving(false) + router.refresh() + } catch (e: unknown) { + if (e instanceof Error) { + setError(e.message) + } else { + setError("An unknown error occurred") + } + setIsSaving(false) + } + } + } + + const isSaveDisabled = isReadonly || isSaving || !isFormValid || !hasMadeChanges + + return { + isSaving, + error, + formData, + setFormData, + isReadonly, + isSaveDisabled, + handleSave, + } +} diff --git a/src/lib/prisma/counter/insertCounter.test.ts b/src/lib/prisma/counter/insertCounter.test.ts new file mode 100644 index 0000000..d932f2e --- /dev/null +++ b/src/lib/prisma/counter/insertCounter.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { prisma } from "@/utils/db/prisma" +import { insertCounter } from "./insertCounter" +import type { CounterWithRelations } from "./types" + +vi.mock("@/utils/db/prisma", () => ({ + prisma: { + counter: { + create: vi.fn(), + }, + }, +})) + +describe("insertCounter", () => { + const mockLocationBase = { + code: "LOC001", + name: "Location 1", + timezone: "America/Vancouver", + streetAddress: "123 Main St", + mailAddress: null, + phoneNumber: null, + latitude: 49.2827, + longitude: -123.1207, + legacyOfficeNumber: null, + deletedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + } + + const mockStaffUserBase = { + guid: "USER-GUID-001", + sub: "user-sub-001", + legacyCsrId: null, + username: "jsmith", + displayName: "Jane Smith", + locationCode: "LOC001", + counterId: null, + role: "CSR" as const, + isActive: true, + deletedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + isReceptionist: false, + isOfficeManager: false, + isPesticideDesignate: false, + isFinanceDesignate: false, + isIta2Designate: false, + isDeveloper: false, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("inserts and returns the created counter when name is provided", async () => { + const input = { name: "Counter 1" } + const mockCounter: CounterWithRelations = { + id: "COUNTER-001", + name: "Counter 1", + createdAt: new Date(), + updatedAt: new Date(), + locations: [], + staffUsers: [], + } + vi.mocked(prisma.counter.create).mockResolvedValueOnce(mockCounter) + + const result = await insertCounter(input) + + expect(result).toEqual(mockCounter) + expect(prisma.counter.create).toHaveBeenCalledWith({ + data: { name: "Counter 1" }, + include: { locations: true, staffUsers: true }, + }) + }) + + it("throws when name is missing and does not call prisma.create", async () => { + await expect(insertCounter({})).rejects.toThrow("Name is required to insert a counter.") + expect(prisma.counter.create).not.toHaveBeenCalled() + }) + + it("connects locations when provided", async () => { + const mockCounter: CounterWithRelations = { + id: "COUNTER-001", + name: "Counter 1", + createdAt: new Date(), + updatedAt: new Date(), + locations: [mockLocationBase], + staffUsers: [], + } + vi.mocked(prisma.counter.create).mockResolvedValueOnce(mockCounter) + + await insertCounter({ name: "Counter 1", locations: [mockLocationBase] }) + + expect(prisma.counter.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + locations: { connect: [{ code: "LOC001" }] }, + }), + include: { locations: true, staffUsers: true }, + }) + }) + + it("connects staffUsers when provided", async () => { + const mockCounter: CounterWithRelations = { + id: "COUNTER-001", + name: "Counter 1", + createdAt: new Date(), + updatedAt: new Date(), + locations: [], + staffUsers: [mockStaffUserBase], + } + vi.mocked(prisma.counter.create).mockResolvedValueOnce(mockCounter) + + await insertCounter({ name: "Counter 1", staffUsers: [mockStaffUserBase] }) + + expect(prisma.counter.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + staffUsers: { connect: [{ guid: "USER-GUID-001" }] }, + }), + include: { locations: true, staffUsers: true }, + }) + }) + + it("does not include empty relation connects when arrays are empty", async () => { + const mockCounter: CounterWithRelations = { + id: "COUNTER-001", + name: "Counter 1", + createdAt: new Date(), + updatedAt: new Date(), + locations: [], + staffUsers: [], + } + vi.mocked(prisma.counter.create).mockResolvedValueOnce(mockCounter) + + await insertCounter({ name: "Counter 1", locations: [], staffUsers: [] }) + + expect(prisma.counter.create).toHaveBeenCalledWith({ + data: expect.not.objectContaining({ + locations: expect.anything(), + staffUsers: expect.anything(), + }), + include: { locations: true, staffUsers: true }, + }) + }) + + it("propagates database errors", async () => { + const dbError = new Error("Database connection failed") + vi.mocked(prisma.counter.create).mockRejectedValueOnce(dbError) + + await expect(insertCounter({ name: "Counter 1" })).rejects.toThrow("Database connection failed") + }) +}) diff --git a/src/lib/prisma/counter/insertCounter.ts b/src/lib/prisma/counter/insertCounter.ts new file mode 100644 index 0000000..3163a26 --- /dev/null +++ b/src/lib/prisma/counter/insertCounter.ts @@ -0,0 +1,37 @@ +"use server" + +import type { Prisma } from "@/generated/prisma/client" +import { prisma } from "@/utils/db/prisma" +import type { CounterWithRelations } from "./types" + +/** + * Function to insert a counter in the database. + * @param counter Data to insert the counter with + * @returns Promise resolving to the inserted Counter object + */ +export const insertCounter = async ( + counter: Partial +): Promise => { + if (!counter.name) { + throw new Error("Name is required to insert a counter.") + } + + const { locations, staffUsers, ...rest } = counter + + // map locations and staffUsers to Prisma connect shape when provided + const data: Prisma.CounterCreateInput = { + ...(rest as Prisma.CounterCreateInput), + ...(locations && locations.length > 0 + ? { locations: { connect: locations.map((l) => ({ code: l.code })) } } + : {}), + ...(staffUsers && staffUsers.length > 0 + ? { staffUsers: { connect: staffUsers.map((u) => ({ guid: u.guid })) } } + : {}), + } + + const newCounter = await prisma.counter.create({ + data, + include: { locations: true, staffUsers: true }, + }) + return newCounter +} diff --git a/src/lib/prisma/counter/updateCounter.test.ts b/src/lib/prisma/counter/updateCounter.test.ts new file mode 100644 index 0000000..67eba20 --- /dev/null +++ b/src/lib/prisma/counter/updateCounter.test.ts @@ -0,0 +1,310 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { prisma } from "@/utils/db/prisma" +import type { CounterWithRelations } from "./types" +import { updateCounter } from "./updateCounter" + +vi.mock("@/utils/db/prisma", () => ({ + prisma: { + counter: { + update: vi.fn(), + findUnique: vi.fn(), + }, + staffUser: { + updateMany: vi.fn(), + }, + }, +})) + +describe("updateCounter", () => { + const mockLocationBase = { + code: "LOC001", + name: "Location 1", + timezone: "America/Vancouver", + streetAddress: "123 Main St", + mailAddress: null, + phoneNumber: null, + latitude: 49.2827, + longitude: -123.1207, + legacyOfficeNumber: null, + deletedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + } + + const mockStaffUserBase = { + guid: "USER-GUID-001", + sub: "user-sub-001", + legacyCsrId: null, + username: "jsmith", + displayName: "Jane Smith", + locationCode: "LOC001", + counterId: "COUNTER-001", + role: "CSR" as const, + isActive: true, + deletedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + isReceptionist: false, + isOfficeManager: false, + isPesticideDesignate: false, + isFinanceDesignate: false, + isIta2Designate: false, + isDeveloper: false, + } + + const mockCounterBase = { + id: "COUNTER-001", + name: "Counter 1", + createdAt: new Date(), + updatedAt: new Date(), + } + + const mockCounterWithRelations: CounterWithRelations = { + ...mockCounterBase, + locations: [mockLocationBase], + staffUsers: [mockStaffUserBase], + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("returns null when neither id nor prevCounter.id is provided", async () => { + const result = await updateCounter({}, {}) + + expect(result).toBeNull() + expect(prisma.counter.update).not.toHaveBeenCalled() + }) + + it("returns null when only prevCounter.id is missing and id is not provided", async () => { + const result = await updateCounter({ name: "Updated Name" }, { id: undefined }) + + expect(result).toBeNull() + }) + + it("updates counter with basic fields using provided id", async () => { + const updatedCounter = { ...mockCounterWithRelations, name: "Updated Counter" } + vi.mocked(prisma.counter.update).mockResolvedValueOnce(updatedCounter) + + const result = await updateCounter( + { id: "COUNTER-001", name: "Updated Counter" }, + { id: "COUNTER-001" } + ) + + expect(result).toEqual(updatedCounter) + expect(prisma.counter.update).toHaveBeenCalledWith({ + where: { id: "COUNTER-001" }, + data: expect.objectContaining({ + name: "Updated Counter", + updatedAt: expect.any(Date), + }), + include: { locations: true, staffUsers: true }, + }) + }) + + it("updates counter using prevCounter.id when id is not provided", async () => { + const updatedCounter = { ...mockCounterWithRelations, name: "Updated Counter" } + vi.mocked(prisma.counter.update).mockResolvedValueOnce(updatedCounter) + + const result = await updateCounter({ name: "Updated Counter" }, { id: "COUNTER-001" }) + + expect(result).toEqual(updatedCounter) + expect(prisma.counter.update).toHaveBeenCalledWith({ + where: { id: "COUNTER-001" }, + data: expect.objectContaining({ + name: "Updated Counter", + updatedAt: expect.any(Date), + }), + include: { locations: true, staffUsers: true }, + }) + }) + + it("updates locations when provided", async () => { + const newLocation = { ...mockLocationBase, code: "LOC002", name: "Location 2" } + const updatedCounter = { ...mockCounterWithRelations, locations: [newLocation] } + vi.mocked(prisma.counter.update).mockResolvedValueOnce(updatedCounter) + + const result = await updateCounter( + { id: "COUNTER-001", locations: [newLocation] }, + { id: "COUNTER-001" } + ) + + expect(result).toEqual(updatedCounter) + expect(prisma.counter.update).toHaveBeenCalledWith({ + where: { id: "COUNTER-001" }, + data: expect.objectContaining({ + locations: { set: [{ code: "LOC002" }] }, + updatedAt: expect.any(Date), + }), + include: { locations: true, staffUsers: true }, + }) + }) + + it("updates staffUsers when provided", async () => { + const newUser = { ...mockStaffUserBase, guid: "USER-GUID-002", displayName: "John Doe" } + const updatedCounter = { ...mockCounterWithRelations, staffUsers: [newUser] } + vi.mocked(prisma.counter.update).mockResolvedValueOnce(updatedCounter) + + const result = await updateCounter( + { id: "COUNTER-001", staffUsers: [newUser] }, + { id: "COUNTER-001" } + ) + + expect(result).toEqual(updatedCounter) + expect(prisma.counter.update).toHaveBeenCalledWith({ + where: { id: "COUNTER-001" }, + data: expect.objectContaining({ + staffUsers: { set: [{ guid: "USER-GUID-002" }] }, + updatedAt: expect.any(Date), + }), + include: { locations: true, staffUsers: true }, + }) + }) + + it("updates multiple fields and relations together", async () => { + const newLocation = { ...mockLocationBase, code: "LOC002", name: "Location 2" } + const newUser = { ...mockStaffUserBase, guid: "USER-GUID-002", displayName: "John Doe" } + const updatedCounter = { + ...mockCounterWithRelations, + name: "Updated Counter", + locations: [newLocation], + staffUsers: [newUser], + } + vi.mocked(prisma.counter.update).mockResolvedValueOnce(updatedCounter) + + const result = await updateCounter( + { + id: "COUNTER-001", + name: "Updated Counter", + locations: [newLocation], + staffUsers: [newUser], + }, + { id: "COUNTER-001" } + ) + + expect(result).toEqual(updatedCounter) + expect(prisma.counter.update).toHaveBeenCalledWith({ + where: { id: "COUNTER-001" }, + data: expect.objectContaining({ + name: "Updated Counter", + locations: { set: [{ code: "LOC002" }] }, + staffUsers: { set: [{ guid: "USER-GUID-002" }] }, + updatedAt: expect.any(Date), + }), + include: { locations: true, staffUsers: true }, + }) + }) + + it("does not update relations if not provided", async () => { + const updatedCounter = { ...mockCounterWithRelations, name: "Updated Counter" } + vi.mocked(prisma.counter.update).mockResolvedValueOnce(updatedCounter) + + await updateCounter({ id: "COUNTER-001", name: "Updated Counter" }, { id: "COUNTER-001" }) + + expect(prisma.counter.update).toHaveBeenCalledWith({ + where: { id: "COUNTER-001" }, + data: expect.not.objectContaining({ + locations: expect.anything(), + staffUsers: expect.anything(), + }), + include: { locations: true, staffUsers: true }, + }) + }) + + it("propagates database errors", async () => { + const dbError = new Error("Database connection failed") + vi.mocked(prisma.counter.update).mockRejectedValueOnce(dbError) + + await expect( + updateCounter({ id: "COUNTER-001", name: "Updated" }, { id: "COUNTER-001" }) + ).rejects.toThrow("Database connection failed") + }) + + describe("location removal — staff user reassignment", () => { + const mockDefaultCounter = { + id: "DEFAULT-COUNTER", + name: "Counter", + createdAt: new Date(), + updatedAt: new Date(), + locations: [], + staffUsers: [], + } + + it("reassigns staff at removed locations to the default counter", async () => { + const removedLocation = { ...mockLocationBase, code: "LOC002", name: "Location 2" } + const updatedCounter = { + ...mockCounterWithRelations, + locations: [mockLocationBase], + } + vi.mocked(prisma.counter.findUnique).mockResolvedValueOnce(mockDefaultCounter) + vi.mocked(prisma.staffUser.updateMany).mockResolvedValueOnce({ count: 1 }) + vi.mocked(prisma.counter.update).mockResolvedValueOnce(updatedCounter) + + const result = await updateCounter( + { id: "COUNTER-001", locations: [mockLocationBase] }, + { id: "COUNTER-001", locations: [mockLocationBase, removedLocation] } + ) + + expect(result).toEqual(updatedCounter) + expect(prisma.counter.findUnique).toHaveBeenCalledWith({ where: { name: "Counter" } }) + expect(prisma.staffUser.updateMany).toHaveBeenCalledWith({ + where: { locationCode: { in: ["LOC002"] }, counterId: "COUNTER-001" }, + data: { counterId: "DEFAULT-COUNTER" }, + }) + }) + + it("sets counterId to null when the default counter does not exist", async () => { + const removedLocation = { ...mockLocationBase, code: "LOC002", name: "Location 2" } + const updatedCounter = { ...mockCounterWithRelations, locations: [mockLocationBase] } + vi.mocked(prisma.counter.findUnique).mockResolvedValueOnce(null) + vi.mocked(prisma.staffUser.updateMany).mockResolvedValueOnce({ count: 1 }) + vi.mocked(prisma.counter.update).mockResolvedValueOnce(updatedCounter) + + await updateCounter( + { id: "COUNTER-001", locations: [mockLocationBase] }, + { id: "COUNTER-001", locations: [mockLocationBase, removedLocation] } + ) + + expect(prisma.staffUser.updateMany).toHaveBeenCalledWith({ + where: { locationCode: { in: ["LOC002"] }, counterId: "COUNTER-001" }, + data: { counterId: null }, + }) + }) + + it("skips staff reassignment when no locations are removed", async () => { + const updatedCounter = { ...mockCounterWithRelations, name: "Renamed" } + vi.mocked(prisma.counter.update).mockResolvedValueOnce(updatedCounter) + + await updateCounter( + { id: "COUNTER-001", name: "Renamed", locations: [mockLocationBase] }, + { id: "COUNTER-001", locations: [mockLocationBase] } + ) + + expect(prisma.counter.findUnique).not.toHaveBeenCalled() + expect(prisma.staffUser.updateMany).not.toHaveBeenCalled() + }) + + it("skips staff reassignment when locations are not provided", async () => { + const updatedCounter = { ...mockCounterWithRelations, name: "Renamed" } + vi.mocked(prisma.counter.update).mockResolvedValueOnce(updatedCounter) + + await updateCounter({ id: "COUNTER-001", name: "Renamed" }, { id: "COUNTER-001" }) + + expect(prisma.counter.findUnique).not.toHaveBeenCalled() + expect(prisma.staffUser.updateMany).not.toHaveBeenCalled() + }) + + it("skips staff reassignment when prevCounter has no locations", async () => { + const updatedCounter = { ...mockCounterWithRelations, locations: [mockLocationBase] } + vi.mocked(prisma.counter.update).mockResolvedValueOnce(updatedCounter) + + await updateCounter( + { id: "COUNTER-001", locations: [mockLocationBase] }, + { id: "COUNTER-001" } + ) + + expect(prisma.counter.findUnique).not.toHaveBeenCalled() + expect(prisma.staffUser.updateMany).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/lib/prisma/counter/updateCounter.ts b/src/lib/prisma/counter/updateCounter.ts new file mode 100644 index 0000000..e7538e2 --- /dev/null +++ b/src/lib/prisma/counter/updateCounter.ts @@ -0,0 +1,80 @@ +"use server" + +import { prisma } from "@/utils/db/prisma" +import type { CounterWithRelations } from "./types" + +const DEFAULT_COUNTER_NAME = "Counter" + +/** + * Function to update a counter in the database. + * + * When locations are removed from the counter, any staff user assigned to this + * counter whose location code is one of the removed locations will be moved to + * the default "Counter" counter (or have their counterId set to null if the + * default counter cannot be found). + * + * @param counter Data to update the counter with + * @param prevCounter Previous data of the counter (should include locations) + * @returns Promise resolving to the updated CounterWithRelations object or null if not found + */ +export const updateCounter = async ( + counter: Partial, + prevCounter: Partial +): Promise => { + const { locations, staffUsers, ...data } = counter + const id = counter.id ?? prevCounter.id + if (!id) return null + + // Determine which locations are being removed so displaced staff can be reassigned + if (locations && prevCounter.locations) { + const newLocationCodes = new Set(locations.map((l) => l.code)) + const removedLocationCodes = prevCounter.locations + .filter((l) => !newLocationCodes.has(l.code)) + .map((l) => l.code) + + if (removedLocationCodes.length > 0) { + const defaultCounter = await prisma.counter.findUnique({ + where: { name: DEFAULT_COUNTER_NAME }, + }) + + await prisma.staffUser.updateMany({ + where: { + locationCode: { in: removedLocationCodes }, + counterId: id, + }, + data: { counterId: defaultCounter?.id ?? null }, + }) + } + } + + // Update locations if provided, otherwise keep existing relations + const locationData = locations + ? { + locations: { + set: locations.map((loc) => ({ code: loc.code })), + }, + } + : {} + + // Update staffUsers if provided, otherwise keep existing relations + const staffUserData = staffUsers + ? { + staffUsers: { + set: staffUsers.map((u) => ({ guid: u.guid })), + }, + } + : {} + + const updatedCounter = await prisma.counter.update({ + where: { id }, + data: { + ...data, + ...locationData, + ...staffUserData, + updatedAt: new Date(), + }, + include: { locations: true, staffUsers: true }, + }) + + return updatedCounter +} diff --git a/src/utils/policies/resources/counter.ts b/src/utils/policies/resources/counter.ts index aa58411..9be77cf 100644 --- a/src/utils/policies/resources/counter.ts +++ b/src/utils/policies/resources/counter.ts @@ -10,7 +10,7 @@ export const CounterPolicy: Policy = (user_context, data) => { // SDM and Administrator can create and edit counters if (role === "SDM" || role === "Administrator") { actions.add("create") - actions.add("edit") + if (data?.name !== "Counter") actions.add("edit") // Allow editing of any counter except the default "Counter" } // Only Administrators can delete counters, except the default "Counter" which is protected from deletion