diff --git a/CLAUDE.md b/CLAUDE.md index 7674d48..ecbc227 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,6 +108,13 @@ tests/ - Never use `as unknown as T` double casts — fix the query or type definition instead. - Prefer single joined queries over N+1 loops (e.g., fetch all related rows with `.in()` instead of per-item queries in a `for` loop). +### Hooks / Services Architecture + +- **Services** (`src/features/[feature]/services/[domain].ts`) — plain `async` functions that call Supabase. They have no React dependencies. One file per data domain (e.g., `customers.ts`, `companies.ts`, `invites.ts`). +- **Hooks** (`src/features/[feature]/hooks/use[Action].ts`) — one file per action/query. Import **only** from the feature's service files, never from `@/lib/supabase` directly. +- Components import hooks; hooks import services; services import `@/lib/supabase`. No layer skips. +- **Testing**: hook tests mock the service module (`vi.mock("../services/[domain]", ...)`). Service tests mock `@/lib/supabase`. Never mock supabase in hook tests. Service test files live colocated with the service at `src/features/[feature]/services/[domain].test.ts`. + ## Linting - **Always run `npm run lint` before considering work complete.** Fix all errors before committing. @@ -140,7 +147,7 @@ tests/ - Test files in `tests/e2e/`. - Write tests for all business logic and critical user paths. - **Unit test files with JSX must use `.test.tsx`** (not `.test.ts`) — Vite/OXC won't parse JSX in `.ts` files. -- **Mock `@/lib/supabase`** in unit tests — all hooks import supabase directly, so mock the module with `vi.mock("@/lib/supabase", ...)`. +- **Mocking strategy**: hook tests mock the service module (`vi.mock("../services/[domain]", ...)`) — hooks no longer import supabase directly. Service tests mock `@/lib/supabase` (`vi.mock("@/lib/supabase", ...)`). Never mock supabase directly in hook tests. - **Use `@testing-library/react` `renderHook`** for testing React Query hooks — wrap with `QueryClientProvider` (retry: false for deterministic tests). - **Test wrapper**: `src/test-utils.tsx` provides `renderWithProviders` (QueryClient + MemoryRouter) and `createTestQueryClient`. - **Mock `AuthProvider`** in component tests by mocking `"../AuthProvider"` with a `useAuthContext` that returns controlled values — avoids needing real Supabase auth. diff --git a/docs/superpowers/plans/2026-04-07-hooks-services-separation.md b/docs/superpowers/plans/2026-04-07-hooks-services-separation.md new file mode 100644 index 0000000..bfa9639 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-hooks-services-separation.md @@ -0,0 +1,2029 @@ +# Hooks/Services Separation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Separate all Supabase calls into plain async service functions; hooks import only from services, never from `@/lib/supabase` directly. + +**Architecture:** Each feature gets a `services/` folder with one file per data domain (e.g. `customers.ts`, `companies.ts`). Hook files are split one-per-action and import their service function. Tests for hooks mock the service module; tests for services mock `@/lib/supabase`. + +**Tech Stack:** React 19, TypeScript, TanStack Query v5, Vitest, @testing-library/react v16 + +--- + +## File Map + +### Created +- `src/features/customers/services/customers.ts` — fetchCustomers, createCustomer, updateCustomer, fetchUserCompany +- `src/features/customers/services/customers.test.ts` — service unit tests (mock supabase) +- `src/features/customers/hooks/useCreateCustomer.ts` — mutation hook +- `src/features/customers/hooks/useUpdateCustomer.ts` — mutation hook +- `src/features/customers/hooks/useUserCompany.ts` — query hook for CM's company +- `src/features/companies/services/companies.ts` — fetchCompanies, fetchCompany, createCompany, updateCompany, deleteCompany +- `src/features/companies/services/companies.test.ts` +- `src/features/companies/services/invites.ts` — createInvite, validateInvite +- `src/features/companies/services/invites.test.ts` +- `src/features/companies/hooks/useCreateCompany.ts` +- `src/features/companies/hooks/useUpdateCompany.ts` +- `src/features/companies/hooks/useDeleteCompany.ts` +- `src/features/agents/services/agents.ts` — fetchAgents, updateAgentCompanies +- `src/features/agents/services/agents.test.ts` +- `src/features/auth/services/auth.ts` — signIn, signUp, signOut, getSession, onAuthStateChange +- `src/features/auth/services/profile.ts` — fetchProfile +- `src/features/auth/services/invite.ts` — validateInvite + +### Modified +- `src/features/customers/hooks/useCustomers.ts` — query only, imports service +- `src/features/customers/hooks/useCustomers.test.tsx` — mock service instead of supabase +- `src/features/customers/components/CustomerList.tsx` — use useUserCompany hook, remove supabase import +- `src/features/customers/components/CustomerList.test.tsx` — mock useUserCompany hook +- `src/features/customers/components/CustomerForm.tsx` — import from split hook files +- `src/features/customers/components/CustomerForm.test.tsx` — mock split hook files +- `src/features/companies/hooks/useCompanies.ts` — query only, imports service +- `src/features/companies/hooks/useCompanies.test.tsx` — mock service +- `src/features/companies/hooks/useCompany.ts` — imports service +- `src/features/companies/hooks/useCreateInvite.ts` — imports service +- `src/features/companies/hooks/useCreateInvite.test.tsx` — mock service +- `src/features/companies/components/CompanyForm.tsx` — import useCreateCompany from new file +- `src/features/companies/components/CompanyDetail.tsx` — import from new split files +- `src/features/agents/hooks/useAgents.ts` — imports service +- `src/features/agents/hooks/useAgentCompanies.ts` — imports service +- `src/features/agents/hooks/useAgentCompanies.test.tsx` — mock service +- `src/features/auth/hooks/useAuth.ts` — imports auth service +- `src/features/auth/hooks/useProfile.ts` — imports profile service +- `src/features/auth/AuthProvider.tsx` — imports getSession/onAuthStateChange from auth service +- `src/features/auth/AuthProvider.test.tsx` — mock auth service instead of supabase +- `src/features/auth/components/SignupForm.tsx` — imports validateInvite from invite service +- `CLAUDE.md` — add hooks/services conventions + +--- + +### Task 1: Create branch and document conventions + +**Files:** +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Create the feature branch** + +```bash +git checkout -b feat/refactor-hooks-services +``` + +- [ ] **Step 2: Add hooks/services conventions to CLAUDE.md** + +Add this section under `## Coding Conventions`: + +```markdown +### Hooks / Services Architecture + +- **Services** (`src/features/[feature]/services/[domain].ts`) — plain `async` functions that call Supabase. They have no React dependencies. One file per data domain (e.g., `customers.ts`, `companies.ts`, `invites.ts`). +- **Hooks** (`src/features/[feature]/hooks/use[Action].ts`) — one file per action/query. Import **only** from the feature's service files, never from `@/lib/supabase` directly. +- Components import hooks; hooks import services; services import `@/lib/supabase`. No layer skips. +- **Testing**: hook tests mock the service module (`vi.mock("../services/[domain]", ...)`). Service tests mock `@/lib/supabase`. Never mock supabase in hook tests. +``` + +- [ ] **Step 3: Commit** + +```bash +git add CLAUDE.md +git commit -m "docs: add hooks/services separation conventions to CLAUDE.md" +``` + +--- + +### Task 2: Customers service layer + +**Files:** +- Create: `src/features/customers/services/customers.ts` +- Create: `src/features/customers/services/customers.test.ts` + +- [ ] **Step 1: Write the failing service tests** + +```typescript +// src/features/customers/services/customers.test.ts +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + fetchCustomers, + createCustomer, + updateCustomer, + fetchUserCompany, +} from "./customers"; + +const mockFrom = vi.fn(); +vi.mock("@/lib/supabase", () => ({ + supabase: { from: (...args: unknown[]) => mockFrom(...args) }, +})); + +const makeChain = (resolve: unknown) => { + const chain: Record = {}; + chain.select = vi.fn().mockReturnValue(chain); + chain.insert = vi.fn().mockReturnValue(chain); + chain.update = vi.fn().mockReturnValue(chain); + chain.eq = vi.fn().mockReturnValue(chain); + chain.order = vi.fn().mockResolvedValue(resolve); + chain.single = vi.fn().mockResolvedValue(resolve); + return chain; +}; + +describe("fetchCustomers", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns ordered customers", async () => { + const rows = [{ id: "c1", name: "Alice", email: "a@x.com", company_id: "co1", created_at: "" }]; + mockFrom.mockReturnValue(makeChain({ data: rows, error: null })); + const result = await fetchCustomers(); + expect(result).toEqual(rows); + }); + + it("throws on error", async () => { + mockFrom.mockReturnValue(makeChain({ data: null, error: { message: "Permission denied" } })); + await expect(fetchCustomers()).rejects.toEqual({ message: "Permission denied" }); + }); +}); + +describe("createCustomer", () => { + beforeEach(() => vi.clearAllMocks()); + + it("inserts with company_id snake_case and returns row", async () => { + const row = { id: "c1", name: "Bob", email: "b@x.com", company_id: "co1", created_at: "" }; + const chain = makeChain({ data: row, error: null }); + mockFrom.mockReturnValue(chain); + + const result = await createCustomer({ name: "Bob", email: "b@x.com", companyId: "co1" }); + expect(result).toEqual(row); + expect(chain.insert).toHaveBeenCalledWith({ name: "Bob", email: "b@x.com", company_id: "co1" }); + }); + + it("throws on error", async () => { + mockFrom.mockReturnValue(makeChain({ data: null, error: { message: "Unique violation" } })); + await expect(createCustomer({ name: "Bob", email: "b@x.com", companyId: "co1" })).rejects.toEqual({ + message: "Unique violation", + }); + }); +}); + +describe("updateCustomer", () => { + beforeEach(() => vi.clearAllMocks()); + + it("updates name and email only — no company_id", async () => { + const row = { id: "c1", name: "Alice Updated", email: "a@x.com", company_id: "co1", created_at: "" }; + const chain = makeChain({ data: row, error: null }); + mockFrom.mockReturnValue(chain); + + const result = await updateCustomer({ id: "c1", name: "Alice Updated", email: "a@x.com" }); + expect(result).toEqual(row); + expect(chain.update).toHaveBeenCalledWith({ name: "Alice Updated", email: "a@x.com" }); + expect(chain.eq).toHaveBeenCalledWith("id", "c1"); + }); +}); + +describe("fetchUserCompany", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns company_id for user", async () => { + const chain = makeChain({ data: { company_id: "co1" }, error: null }); + mockFrom.mockReturnValue(chain); + + const result = await fetchUserCompany("user-1"); + expect(result).toEqual({ company_id: "co1" }); + expect(chain.eq).toHaveBeenCalledWith("user_id", "user-1"); + }); + + it("throws on error", async () => { + mockFrom.mockReturnValue(makeChain({ data: null, error: { message: "Not found" } })); + await expect(fetchUserCompany("user-1")).rejects.toEqual({ message: "Not found" }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +npm test -- customers/services/customers.test.ts +``` + +Expected: FAIL with `Cannot find module './customers'` + +- [ ] **Step 3: Implement the service** + +```typescript +// src/features/customers/services/customers.ts +import { supabase } from "@/lib/supabase"; +import type { Customer } from "@/lib/types"; +import type { TablesInsert, TablesUpdate } from "@/lib/database.types"; + +export const fetchCustomers = async (): Promise => { + const { data, error } = await supabase.from("customers").select("*").order("name"); + if (error) throw error; + return data; +}; + +export const createCustomer = async ({ + name, + email, + companyId, +}: { + name: string; + email: string; + companyId: string; +}): Promise => { + const payload: TablesInsert<"customers"> = { name, email, company_id: companyId }; + const { data, error } = await supabase + .from("customers") + .insert(payload) + .select() + .single(); + if (error) throw error; + return data; +}; + +export const updateCustomer = async ({ + id, + name, + email, +}: { + id: string; + name: string; + email: string; +}): Promise => { + const payload: TablesUpdate<"customers"> = { name, email }; + const { data, error } = await supabase + .from("customers") + .update(payload) + .eq("id", id) + .select() + .single(); + if (error) throw error; + return data; +}; + +export const fetchUserCompany = async (userId: string): Promise<{ company_id: string }> => { + const { data, error } = await supabase + .from("user_companies") + .select("company_id") + .eq("user_id", userId) + .single(); + if (error) throw error; + return data; +}; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +npm test -- customers/services/customers.test.ts +``` + +Expected: PASS (6 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/features/customers/services/ +git commit -m "feat: add customers service layer" +``` + +--- + +### Task 3: Split customers hooks + update components + update tests + +**Files:** +- Modify: `src/features/customers/hooks/useCustomers.ts` +- Create: `src/features/customers/hooks/useCreateCustomer.ts` +- Create: `src/features/customers/hooks/useUpdateCustomer.ts` +- Create: `src/features/customers/hooks/useUserCompany.ts` +- Modify: `src/features/customers/hooks/useCustomers.test.tsx` +- Modify: `src/features/customers/components/CustomerList.tsx` +- Modify: `src/features/customers/components/CustomerList.test.tsx` +- Modify: `src/features/customers/components/CustomerForm.tsx` +- Modify: `src/features/customers/components/CustomerForm.test.tsx` + +- [ ] **Step 1: Replace useCustomers.ts with query-only hook** + +```typescript +// src/features/customers/hooks/useCustomers.ts +import { useQuery } from "@tanstack/react-query"; +import { fetchCustomers } from "../services/customers"; +import type { Customer } from "@/lib/types"; + +export const useCustomers = () => + useQuery({ + queryKey: ["customers"], + queryFn: fetchCustomers, + }); +``` + +- [ ] **Step 2: Create useCreateCustomer.ts** + +```typescript +// src/features/customers/hooks/useCreateCustomer.ts +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { createCustomer } from "../services/customers"; + +export const useCreateCustomer = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createCustomer, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["customers"] }), + }); +}; +``` + +- [ ] **Step 3: Create useUpdateCustomer.ts** + +```typescript +// src/features/customers/hooks/useUpdateCustomer.ts +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { updateCustomer } from "../services/customers"; + +export const useUpdateCustomer = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: updateCustomer, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["customers"] }), + }); +}; +``` + +- [ ] **Step 4: Create useUserCompany.ts** + +```typescript +// src/features/customers/hooks/useUserCompany.ts +import { useQuery } from "@tanstack/react-query"; +import { fetchUserCompany } from "../services/customers"; + +export const useUserCompany = (userId: string | undefined) => + useQuery({ + queryKey: ["user-company", userId], + queryFn: () => { + if (!userId) throw new Error("Not authenticated"); + return fetchUserCompany(userId); + }, + enabled: !!userId, + }); +``` + +- [ ] **Step 5: Update useCustomers.test.tsx to mock service** + +```typescript +// src/features/customers/hooks/useCustomers.test.tsx +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { type ReactNode } from "react"; +import { useCustomers } from "./useCustomers"; +import { useCreateCustomer } from "./useCreateCustomer"; +import { useUpdateCustomer } from "./useUpdateCustomer"; + +const mockFetchCustomers = vi.fn(); +const mockCreateCustomer = vi.fn(); +const mockUpdateCustomer = vi.fn(); + +vi.mock("../services/customers", () => ({ + fetchCustomers: () => mockFetchCustomers(), + createCustomer: (args: unknown) => mockCreateCustomer(args), + updateCustomer: (args: unknown) => mockUpdateCustomer(args), + fetchUserCompany: vi.fn(), +})); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +}; + +describe("useCustomers", () => { + beforeEach(() => vi.clearAllMocks()); + + it("fetches customers ordered by name", async () => { + const customers = [ + { id: "c1", name: "Alice", email: "alice@acme.com", company_id: "co1", created_at: "" }, + ]; + mockFetchCustomers.mockResolvedValue(customers); + + const { result } = renderHook(() => useCustomers(), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(customers); + }); + + it("propagates query error", async () => { + mockFetchCustomers.mockRejectedValue(new Error("Permission denied")); + + const { result } = renderHook(() => useCustomers(), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); +}); + +describe("useCreateCustomer", () => { + beforeEach(() => vi.clearAllMocks()); + + it("calls createCustomer service with args and invalidates cache", async () => { + const created = { id: "c1", name: "Bob", email: "bob@acme.com", company_id: "co1", created_at: "" }; + mockCreateCustomer.mockResolvedValue(created); + + const { result } = renderHook(() => useCreateCustomer(), { wrapper: createWrapper() }); + + result.current.mutate({ name: "Bob", email: "bob@acme.com", companyId: "co1" }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockCreateCustomer).toHaveBeenCalledWith({ name: "Bob", email: "bob@acme.com", companyId: "co1" }); + }); + + it("propagates mutation error", async () => { + mockCreateCustomer.mockRejectedValue(new Error("Unique violation")); + + const { result } = renderHook(() => useCreateCustomer(), { wrapper: createWrapper() }); + + result.current.mutate({ name: "Bob", email: "bob@acme.com", companyId: "co1" }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); +}); + +describe("useUpdateCustomer", () => { + beforeEach(() => vi.clearAllMocks()); + + it("calls updateCustomer service with args", async () => { + const updated = { id: "c1", name: "Alice Updated", email: "a@acme.com", company_id: "co1", created_at: "" }; + mockUpdateCustomer.mockResolvedValue(updated); + + const { result } = renderHook(() => useUpdateCustomer(), { wrapper: createWrapper() }); + + result.current.mutate({ id: "c1", name: "Alice Updated", email: "a@acme.com" }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockUpdateCustomer).toHaveBeenCalledWith({ id: "c1", name: "Alice Updated", email: "a@acme.com" }); + }); + + it("propagates mutation error", async () => { + mockUpdateCustomer.mockRejectedValue(new Error("Not found")); + + const { result } = renderHook(() => useUpdateCustomer(), { wrapper: createWrapper() }); + + result.current.mutate({ id: "c1", name: "Alice", email: "a@acme.com" }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); +}); +``` + +- [ ] **Step 6: Update CustomerList.tsx to use useUserCompany hook** + +Replace the entire file with: + +```typescript +// src/features/customers/components/CustomerList.tsx +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useCustomers } from "../hooks/useCustomers"; +import { useUserCompany } from "../hooks/useUserCompany"; +import { useAuthContext } from "@/features/auth/AuthProvider"; +import { CustomerForm } from "./CustomerForm"; +import type { Customer } from "@/lib/types"; + +export const CustomerList = () => { + const { data: customers, isLoading, isError, error } = useCustomers(); + const { user } = useAuthContext(); + const [showCreate, setShowCreate] = useState(false); + const [editingCustomer, setEditingCustomer] = useState(null); + + const { data: userCompany } = useUserCompany(user?.id); + const companyId = userCompany?.company_id; + + if (isLoading) { + return

Loading customers...

; + } + + if (isError) { + return

{error.message}

; + } + + return ( +
+
+

Customers

+ +
+ + {showCreate && companyId && ( + setShowCreate(false)} + /> + )} + + {editingCustomer && companyId && ( + setEditingCustomer(null)} + /> + )} + + + + + Name + Email + Created + + + + + {customers?.map((customer) => ( + + {customer.name} + {customer.email} + + {new Date(customer.created_at).toLocaleDateString()} + + + + + + ))} + {customers?.length === 0 && ( + + + No customers yet + + + )} + +
+
+ ); +}; +``` + +- [ ] **Step 7: Update CustomerList.test.tsx to mock useUserCompany instead of supabase** + +Replace the entire file with: + +```typescript +// src/features/customers/components/CustomerList.test.tsx +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "@/test-utils"; +import { CustomerList } from "./CustomerList"; +import type { Customer } from "@/lib/types"; + +// Mock AuthProvider +vi.mock("@/features/auth/AuthProvider", () => ({ + useAuthContext: () => ({ user: { id: "user-1" } }), +})); + +// Mock useCustomers hook +const mockUseCustomers = vi.fn(); +vi.mock("../hooks/useCustomers", () => ({ + useCustomers: () => mockUseCustomers(), +})); + +// Mock useUserCompany hook +const mockUseUserCompany = vi.fn(); +vi.mock("../hooks/useUserCompany", () => ({ + useUserCompany: () => mockUseUserCompany(), +})); + +// Mock useCreateCustomer and useUpdateCustomer (used by CustomerForm) +vi.mock("../hooks/useCreateCustomer", () => ({ + useCreateCustomer: () => ({ mutateAsync: vi.fn(), isPending: false, error: null }), +})); +vi.mock("../hooks/useUpdateCustomer", () => ({ + useUpdateCustomer: () => ({ mutateAsync: vi.fn(), isPending: false, error: null }), +})); + +const CUSTOMERS: Customer[] = [ + { id: "c1", name: "Alice Smith", email: "alice@acme.com", company_id: "co1", created_at: "2026-01-01T00:00:00Z" }, + { id: "c2", name: "Bob Jones", email: "bob@acme.com", company_id: "co1", created_at: "2026-02-01T00:00:00Z" }, +]; + +describe("CustomerList", () => { + beforeEach(() => vi.clearAllMocks()); + + it("shows loading state", () => { + mockUseCustomers.mockReturnValue({ isLoading: true, isError: false, data: undefined, error: null }); + mockUseUserCompany.mockReturnValue({ data: { company_id: "co1" } }); + + renderWithProviders(); + expect(screen.getByText("Loading customers...")).toBeTruthy(); + }); + + it("shows error state", () => { + mockUseCustomers.mockReturnValue({ + isLoading: false, + isError: true, + data: undefined, + error: { message: "Permission denied" }, + }); + mockUseUserCompany.mockReturnValue({ data: { company_id: "co1" } }); + + renderWithProviders(); + expect(screen.getByText("Permission denied")).toBeTruthy(); + }); + + it("shows empty state", async () => { + mockUseCustomers.mockReturnValue({ isLoading: false, isError: false, data: [], error: null }); + mockUseUserCompany.mockReturnValue({ data: { company_id: "co1" } }); + + renderWithProviders(); + await waitFor(() => expect(screen.getByText("No customers yet")).toBeTruthy()); + }); + + it("renders a row per customer with name and email", async () => { + mockUseCustomers.mockReturnValue({ isLoading: false, isError: false, data: CUSTOMERS, error: null }); + mockUseUserCompany.mockReturnValue({ data: { company_id: "co1" } }); + + renderWithProviders(); + await waitFor(() => expect(screen.getByText("Alice Smith")).toBeTruthy()); + expect(screen.getByText("alice@acme.com")).toBeTruthy(); + expect(screen.getByText("Bob Jones")).toBeTruthy(); + expect(screen.getByText("bob@acme.com")).toBeTruthy(); + }); + + it("New Customer button is disabled while companyId is loading", async () => { + mockUseCustomers.mockReturnValue({ isLoading: false, isError: false, data: [], error: null }); + mockUseUserCompany.mockReturnValue({ data: undefined }); + + renderWithProviders(); + await waitFor(() => { + const btn = screen.getByRole("button", { name: "New Customer" }); + expect((btn as HTMLButtonElement).disabled).toBe(true); + }); + }); + + it("New Customer button enabled when companyId resolves, and clicking shows form", async () => { + mockUseCustomers.mockReturnValue({ isLoading: false, isError: false, data: CUSTOMERS, error: null }); + mockUseUserCompany.mockReturnValue({ data: { company_id: "co1" } }); + + renderWithProviders(); + const btn = await screen.findByRole("button", { name: "New Customer" }); + await waitFor(() => expect((btn as HTMLButtonElement).disabled).toBe(false)); + await userEvent.click(btn); + expect(screen.getByText("New Customer", { selector: "div" })).toBeTruthy(); + }); + + it("clicking Edit shows pre-filled form for that customer", async () => { + mockUseCustomers.mockReturnValue({ isLoading: false, isError: false, data: CUSTOMERS, error: null }); + mockUseUserCompany.mockReturnValue({ data: { company_id: "co1" } }); + + renderWithProviders(); + await waitFor(() => expect(screen.getByText("Alice Smith")).toBeTruthy()); + const user = userEvent.setup(); + const editButtons = screen.getAllByRole("button", { name: "Edit" }); + const firstEdit = editButtons[0]; + if (!firstEdit) throw new Error("No Edit button found"); + await user.click(firstEdit); + expect(screen.getByText("Edit Customer")).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 8: Update CustomerForm.tsx to import from split hook files** + +Change the import line from: +```typescript +import { useCreateCustomer, useUpdateCustomer } from "../hooks/useCustomers"; +``` +to: +```typescript +import { useCreateCustomer } from "../hooks/useCreateCustomer"; +import { useUpdateCustomer } from "../hooks/useUpdateCustomer"; +``` + +- [ ] **Step 9: Update CustomerForm.test.tsx to mock split hook files** + +Change the mock from: +```typescript +vi.mock("../hooks/useCustomers", () => ({ + useCreateCustomer: () => ({ + mutateAsync: mockCreateMutateAsync, + isPending: false, + error: null, + }), + useUpdateCustomer: () => ({ + mutateAsync: mockUpdateMutateAsync, + isPending: false, + error: null, + }), +})); +``` +to: +```typescript +vi.mock("../hooks/useCreateCustomer", () => ({ + useCreateCustomer: () => ({ + mutateAsync: mockCreateMutateAsync, + isPending: false, + error: null, + }), +})); + +vi.mock("../hooks/useUpdateCustomer", () => ({ + useUpdateCustomer: () => ({ + mutateAsync: mockUpdateMutateAsync, + isPending: false, + error: null, + }), +})); +``` + +- [ ] **Step 10: Run all customer tests** + +```bash +npm test -- customers +``` + +Expected: All PASS + +- [ ] **Step 11: Commit** + +```bash +git add src/features/customers/ +git commit -m "refactor: split customers hooks one-per-action, add service layer" +``` + +--- + +### Task 4: Companies service layer + +**Files:** +- Create: `src/features/companies/services/companies.ts` +- Create: `src/features/companies/services/companies.test.ts` +- Create: `src/features/companies/services/invites.ts` +- Create: `src/features/companies/services/invites.test.ts` + +- [ ] **Step 1: Write failing companies service tests** + +```typescript +// src/features/companies/services/companies.test.ts +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fetchCompanies, fetchCompany, createCompany, updateCompany, deleteCompany } from "./companies"; + +const mockFrom = vi.fn(); +vi.mock("@/lib/supabase", () => ({ + supabase: { from: (...args: unknown[]) => mockFrom(...args) }, +})); + +const makeChain = (resolve: unknown) => { + const chain: Record = {}; + chain.select = vi.fn().mockReturnValue(chain); + chain.insert = vi.fn().mockReturnValue(chain); + chain.update = vi.fn().mockReturnValue(chain); + chain.delete = vi.fn().mockReturnValue(chain); + chain.eq = vi.fn().mockReturnValue(chain); + chain.order = vi.fn().mockResolvedValue(resolve); + chain.single = vi.fn().mockResolvedValue(resolve); + return chain; +}; + +describe("fetchCompanies", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns companies list", async () => { + const rows = [{ id: "c1", name: "Acme", created_at: "", updated_at: "" }]; + mockFrom.mockReturnValue(makeChain({ data: rows, error: null })); + expect(await fetchCompanies()).toEqual(rows); + }); + + it("throws on error", async () => { + mockFrom.mockReturnValue(makeChain({ data: null, error: { message: "fail" } })); + await expect(fetchCompanies()).rejects.toEqual({ message: "fail" }); + }); +}); + +describe("fetchCompany", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns single company by id", async () => { + const row = { id: "c1", name: "Acme", created_at: "", updated_at: "" }; + const chain = makeChain({ data: row, error: null }); + mockFrom.mockReturnValue(chain); + expect(await fetchCompany("c1")).toEqual(row); + expect(chain.eq).toHaveBeenCalledWith("id", "c1"); + }); +}); + +describe("createCompany", () => { + beforeEach(() => vi.clearAllMocks()); + + it("inserts with name and returns row", async () => { + const row = { id: "c1", name: "New Corp", created_at: "", updated_at: "" }; + const chain = makeChain({ data: row, error: null }); + mockFrom.mockReturnValue(chain); + expect(await createCompany("New Corp")).toEqual(row); + expect(chain.insert).toHaveBeenCalledWith({ name: "New Corp" }); + }); +}); + +describe("updateCompany", () => { + beforeEach(() => vi.clearAllMocks()); + + it("updates name and returns row", async () => { + const row = { id: "c1", name: "Updated Corp", created_at: "", updated_at: "" }; + const chain = makeChain({ data: row, error: null }); + mockFrom.mockReturnValue(chain); + expect(await updateCompany({ id: "c1", name: "Updated Corp" })).toEqual(row); + expect(chain.update).toHaveBeenCalledWith({ name: "Updated Corp" }); + expect(chain.eq).toHaveBeenCalledWith("id", "c1"); + }); +}); + +describe("deleteCompany", () => { + beforeEach(() => vi.clearAllMocks()); + + it("deletes by id without error", async () => { + const chain = makeChain({ error: null }); + mockFrom.mockReturnValue(chain); + await expect(deleteCompany("c1")).resolves.toBeUndefined(); + expect(chain.eq).toHaveBeenCalledWith("id", "c1"); + }); + + it("throws on error", async () => { + mockFrom.mockReturnValue(makeChain({ error: { message: "FK violation" } })); + await expect(deleteCompany("c1")).rejects.toEqual({ message: "FK violation" }); + }); +}); +``` + +- [ ] **Step 2: Write failing invite service tests** + +```typescript +// src/features/companies/services/invites.test.ts +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createInvite, validateInvite } from "./invites"; + +const mockRpc = vi.fn(); +vi.mock("@/lib/supabase", () => ({ + supabase: { rpc: (...args: unknown[]) => mockRpc(...args) }, +})); + +describe("createInvite", () => { + beforeEach(() => vi.clearAllMocks()); + + it("calls create_invite RPC with company ids", async () => { + mockRpc.mockResolvedValue({ + data: [{ invite_id: "inv-1", token: "tok-abc" }], + error: null, + }); + const result = await createInvite({ email: "a@x.com", role: "agent", companyIds: ["co1"] }); + expect(result).toEqual({ invite: { id: "inv-1" }, token: "tok-abc" }); + expect(mockRpc).toHaveBeenCalledWith("create_invite", { + p_email: "a@x.com", + p_role: "agent", + p_company_ids: ["co1"], + }); + }); + + it("omits p_company_ids when empty", async () => { + mockRpc.mockResolvedValue({ data: [{ invite_id: "inv-2", token: "tok-xyz" }], error: null }); + await createInvite({ email: "a@x.com", role: "agent", companyIds: [] }); + expect(mockRpc).toHaveBeenCalledWith("create_invite", { p_email: "a@x.com", p_role: "agent" }); + }); + + it("throws when data is empty", async () => { + mockRpc.mockResolvedValue({ data: [], error: null }); + await expect(createInvite({ email: "a@x.com", role: "agent", companyIds: [] })).rejects.toThrow( + "Failed to create invite", + ); + }); + + it("throws on RPC error", async () => { + mockRpc.mockResolvedValue({ data: null, error: { message: "Forbidden" } }); + await expect(createInvite({ email: "a@x.com", role: "agent", companyIds: [] })).rejects.toEqual({ + message: "Forbidden", + }); + }); +}); + +describe("validateInvite", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns email from RPC", async () => { + mockRpc.mockResolvedValue({ data: [{ email: "a@x.com" }], error: null }); + const result = await validateInvite("tok-abc"); + expect(result).toEqual({ email: "a@x.com" }); + expect(mockRpc).toHaveBeenCalledWith("validate_invite", { p_token: "tok-abc" }); + }); + + it("throws when no data returned", async () => { + mockRpc.mockResolvedValue({ data: [], error: null }); + await expect(validateInvite("bad-token")).rejects.toThrow("Invalid or expired invite link"); + }); + + it("throws on RPC error", async () => { + mockRpc.mockResolvedValue({ data: null, error: { message: "DB error" } }); + await expect(validateInvite("tok")).rejects.toEqual({ message: "DB error" }); + }); +}); +``` + +- [ ] **Step 3: Run tests to verify they fail** + +```bash +npm test -- companies/services/ +``` + +Expected: FAIL with `Cannot find module './companies'` and `Cannot find module './invites'` + +- [ ] **Step 4: Implement companies service** + +```typescript +// src/features/companies/services/companies.ts +import { supabase } from "@/lib/supabase"; +import type { Company } from "@/lib/types"; + +export const fetchCompanies = async (): Promise => { + const { data, error } = await supabase.from("companies").select("*").order("name"); + if (error) throw error; + return data; +}; + +export const fetchCompany = async (id: string): Promise => { + const { data, error } = await supabase + .from("companies") + .select("*") + .eq("id", id) + .single(); + if (error) throw error; + return data; +}; + +export const createCompany = async (name: string): Promise => { + const { data, error } = await supabase + .from("companies") + .insert({ name }) + .select() + .single(); + if (error) throw error; + return data; +}; + +export const updateCompany = async ({ id, name }: { id: string; name: string }): Promise => { + const { data, error } = await supabase + .from("companies") + .update({ name }) + .eq("id", id) + .select() + .single(); + if (error) throw error; + return data; +}; + +export const deleteCompany = async (id: string): Promise => { + const { error } = await supabase.from("companies").delete().eq("id", id); + if (error) throw error; +}; +``` + +- [ ] **Step 5: Implement invites service** + +```typescript +// src/features/companies/services/invites.ts +import { supabase } from "@/lib/supabase"; +import type { AppRole } from "@/lib/types"; + +type CreateInviteParams = { + email: string; + role: AppRole; + companyIds: string[]; +}; + +export const createInvite = async ({ + email, + role, + companyIds, +}: CreateInviteParams): Promise<{ invite: { id: string }; token: string }> => { + const params: { p_email: string; p_role: AppRole; p_company_ids?: string[] } = { + p_email: email, + p_role: role, + }; + if (companyIds.length > 0) { + params.p_company_ids = companyIds; + } + const { data, error } = await supabase.rpc("create_invite", params); + if (error) throw error; + + const row = data?.[0]; + if (!row) throw new Error("Failed to create invite"); + + return { invite: { id: row.invite_id }, token: row.token }; +}; + +export const validateInvite = async (token: string): Promise<{ email: string }> => { + const { data, error } = await supabase.rpc("validate_invite", { p_token: token }); + if (error) throw error; + + const row = (data as { email: string }[] | null)?.[0]; + if (!row) throw new Error("Invalid or expired invite link"); + + return { email: row.email }; +}; +``` + +- [ ] **Step 6: Run tests to verify they pass** + +```bash +npm test -- companies/services/ +``` + +Expected: All PASS + +- [ ] **Step 7: Commit** + +```bash +git add src/features/companies/services/ +git commit -m "feat: add companies and invites service layers" +``` + +--- + +### Task 5: Split companies hooks + update components + update tests + +**Files:** +- Modify: `src/features/companies/hooks/useCompanies.ts` +- Create: `src/features/companies/hooks/useCreateCompany.ts` +- Create: `src/features/companies/hooks/useUpdateCompany.ts` +- Create: `src/features/companies/hooks/useDeleteCompany.ts` +- Modify: `src/features/companies/hooks/useCompany.ts` +- Modify: `src/features/companies/hooks/useCreateInvite.ts` +- Modify: `src/features/companies/hooks/useCompanies.test.tsx` +- Modify: `src/features/companies/hooks/useCreateInvite.test.tsx` +- Modify: `src/features/companies/components/CompanyForm.tsx` +- Modify: `src/features/companies/components/CompanyDetail.tsx` + +- [ ] **Step 1: Replace useCompanies.ts with query-only hook** + +```typescript +// src/features/companies/hooks/useCompanies.ts +import { useQuery } from "@tanstack/react-query"; +import { fetchCompanies } from "../services/companies"; +import type { Company } from "@/lib/types"; + +export const useCompanies = () => + useQuery({ + queryKey: ["companies"], + queryFn: fetchCompanies, + }); +``` + +- [ ] **Step 2: Create useCreateCompany.ts** + +```typescript +// src/features/companies/hooks/useCreateCompany.ts +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { createCompany } from "../services/companies"; + +export const useCreateCompany = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createCompany, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["companies"] }), + }); +}; +``` + +- [ ] **Step 3: Create useUpdateCompany.ts** + +```typescript +// src/features/companies/hooks/useUpdateCompany.ts +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { updateCompany } from "../services/companies"; + +export const useUpdateCompany = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: updateCompany, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["companies"] }), + }); +}; +``` + +- [ ] **Step 4: Create useDeleteCompany.ts** + +```typescript +// src/features/companies/hooks/useDeleteCompany.ts +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { deleteCompany } from "../services/companies"; + +export const useDeleteCompany = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: deleteCompany, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["companies"] }), + }); +}; +``` + +- [ ] **Step 5: Update useCompany.ts to use service** + +```typescript +// src/features/companies/hooks/useCompany.ts +import { useQuery } from "@tanstack/react-query"; +import { fetchCompany } from "../services/companies"; +import type { Company } from "@/lib/types"; + +export const useCompany = (id: string | undefined) => + useQuery({ + queryKey: ["company", id], + queryFn: () => { + if (!id) throw new Error("Company id is required"); + return fetchCompany(id); + }, + enabled: !!id, + }); +``` + +- [ ] **Step 6: Update useCreateInvite.ts to use service** + +```typescript +// src/features/companies/hooks/useCreateInvite.ts +import { useMutation } from "@tanstack/react-query"; +import { createInvite } from "../services/invites"; +import type { AppRole } from "@/lib/types"; + +type CreateInviteParams = { + email: string; + role: AppRole; + companyIds: string[]; +}; + +export const useCreateInvite = () => + useMutation({ + mutationFn: (params: CreateInviteParams) => createInvite(params), + }); +``` + +- [ ] **Step 7: Update useCompanies.test.tsx to mock service** + +Replace the entire file with: + +```typescript +// src/features/companies/hooks/useCompanies.test.tsx +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { type ReactNode } from "react"; +import { useCompanies } from "./useCompanies"; +import { useCreateCompany } from "./useCreateCompany"; + +const mockFetchCompanies = vi.fn(); +const mockCreateCompany = vi.fn(); + +vi.mock("../services/companies", () => ({ + fetchCompanies: () => mockFetchCompanies(), + fetchCompany: vi.fn(), + createCompany: (name: unknown) => mockCreateCompany(name), + updateCompany: vi.fn(), + deleteCompany: vi.fn(), +})); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +}; + +describe("useCompanies", () => { + beforeEach(() => vi.clearAllMocks()); + + it("fetches companies list", async () => { + const companies = [ + { id: "c1", name: "Acme", created_at: "", updated_at: "" }, + { id: "c2", name: "Beta", created_at: "", updated_at: "" }, + ]; + mockFetchCompanies.mockResolvedValue(companies); + + const { result } = renderHook(() => useCompanies(), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(companies); + }); + + it("handles fetch error", async () => { + mockFetchCompanies.mockRejectedValue(new Error("Permission denied")); + + const { result } = renderHook(() => useCompanies(), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); +}); + +describe("useCreateCompany", () => { + beforeEach(() => vi.clearAllMocks()); + + it("calls createCompany service with name", async () => { + const newCompany = { id: "c1", name: "New Corp", created_at: "", updated_at: "" }; + mockCreateCompany.mockResolvedValue(newCompany); + + const { result } = renderHook(() => useCreateCompany(), { wrapper: createWrapper() }); + + result.current.mutate("New Corp"); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockCreateCompany).toHaveBeenCalledWith("New Corp"); + }); +}); +``` + +- [ ] **Step 8: Update useCreateInvite.test.tsx to mock service** + +Replace the entire file with: + +```typescript +// src/features/companies/hooks/useCreateInvite.test.tsx +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { type ReactNode } from "react"; +import { useCreateInvite } from "./useCreateInvite"; + +const mockCreateInvite = vi.fn(); + +vi.mock("../services/invites", () => ({ + createInvite: (args: unknown) => mockCreateInvite(args), + validateInvite: vi.fn(), +})); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +}; + +describe("useCreateInvite", () => { + beforeEach(() => vi.clearAllMocks()); + + it("calls createInvite service with correct params", async () => { + mockCreateInvite.mockResolvedValue({ invite: { id: "inv-1" }, token: "tok-abc" }); + + const { result } = renderHook(() => useCreateInvite(), { wrapper: createWrapper() }); + + result.current.mutate({ email: "agent@example.com", role: "agent", companyIds: ["co1"] }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockCreateInvite).toHaveBeenCalledWith({ + email: "agent@example.com", + role: "agent", + companyIds: ["co1"], + }); + expect(result.current.data).toEqual({ invite: { id: "inv-1" }, token: "tok-abc" }); + }); + + it("handles service error", async () => { + mockCreateInvite.mockRejectedValue(new Error("Only admins can create invites")); + + const { result } = renderHook(() => useCreateInvite(), { wrapper: createWrapper() }); + + result.current.mutate({ email: "test@x.com", role: "customer_manager", companyIds: [] }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); +}); +``` + +- [ ] **Step 9: Update CompanyForm.tsx import** + +In `src/features/companies/components/CompanyForm.tsx`, find the import: +```typescript +import { useCreateCompany } from "../hooks/useCompanies"; +``` +Replace with: +```typescript +import { useCreateCompany } from "../hooks/useCreateCompany"; +``` + +- [ ] **Step 10: Update CompanyDetail.tsx imports** + +In `src/features/companies/components/CompanyDetail.tsx`, find the import: +```typescript +import { useUpdateCompany, useDeleteCompany } from "../hooks/useCompanies"; +``` +Replace with: +```typescript +import { useUpdateCompany } from "../hooks/useUpdateCompany"; +import { useDeleteCompany } from "../hooks/useDeleteCompany"; +``` + +- [ ] **Step 11: Run all companies tests** + +```bash +npm test -- companies +``` + +Expected: All PASS + +- [ ] **Step 12: Commit** + +```bash +git add src/features/companies/ +git commit -m "refactor: split companies hooks one-per-action, add service layer" +``` + +--- + +### Task 6: Agents service layer + update hooks + tests + +**Files:** +- Create: `src/features/agents/services/agents.ts` +- Create: `src/features/agents/services/agents.test.ts` +- Modify: `src/features/agents/hooks/useAgents.ts` +- Modify: `src/features/agents/hooks/useAgentCompanies.ts` +- Modify: `src/features/agents/hooks/useAgentCompanies.test.tsx` + +- [ ] **Step 1: Write failing agents service tests** + +```typescript +// src/features/agents/services/agents.test.ts +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fetchAgents, updateAgentCompanies } from "./agents"; +import type { AgentWithCompanies } from "../types"; + +const mockFrom = vi.fn(); +const mockRpc = vi.fn(); +vi.mock("@/lib/supabase", () => ({ + supabase: { + from: (...args: unknown[]) => mockFrom(...args), + rpc: (...args: unknown[]) => mockRpc(...args), + }, +})); + +const makeChain = (resolve: unknown) => { + const chain: Record = {}; + chain.select = vi.fn().mockReturnValue(chain); + chain.eq = vi.fn().mockReturnValue(chain); + chain.in = vi.fn().mockReturnValue(chain); + chain.order = vi.fn().mockResolvedValue(resolve); + return chain; +}; + +describe("fetchAgents", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns agents with their companies", async () => { + const profiles = [{ user_id: "u1", name: "Agent A", role: "agent", created_at: "" }]; + const userCompanies = [{ user_id: "u1", companies: { id: "co1", name: "Acme", created_at: "", updated_at: "" } }]; + + mockFrom.mockImplementation((table: string) => { + if (table === "profiles") return makeChain({ data: profiles, error: null }); + if (table === "user_companies") return makeChain({ data: userCompanies, error: null }); + return makeChain({ data: null, error: null }); + }); + + const result = await fetchAgents(); + expect(result).toHaveLength(1); + expect((result as AgentWithCompanies[])[0].companies).toHaveLength(1); + expect((result as AgentWithCompanies[])[0].companies[0].name).toBe("Acme"); + }); + + it("throws on profiles error", async () => { + mockFrom.mockReturnValue(makeChain({ data: null, error: { message: "Forbidden" } })); + await expect(fetchAgents()).rejects.toEqual({ message: "Forbidden" }); + }); +}); + +describe("updateAgentCompanies", () => { + beforeEach(() => vi.clearAllMocks()); + + it("calls update_agent_companies RPC", async () => { + mockRpc.mockResolvedValue({ data: null, error: null }); + await updateAgentCompanies({ agentId: "u1", companyIds: ["co1"] }); + expect(mockRpc).toHaveBeenCalledWith("update_agent_companies", { + p_agent_id: "u1", + p_company_ids: ["co1"], + }); + }); + + it("throws on error", async () => { + mockRpc.mockResolvedValue({ data: null, error: { message: "Permission denied" } }); + await expect(updateAgentCompanies({ agentId: "u1", companyIds: [] })).rejects.toEqual({ + message: "Permission denied", + }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +npm test -- agents/services/agents.test.ts +``` + +Expected: FAIL with `Cannot find module './agents'` + +- [ ] **Step 3: Implement agents service** + +```typescript +// src/features/agents/services/agents.ts +import { supabase } from "@/lib/supabase"; +import type { AgentWithCompanies } from "../types"; + +export const fetchAgents = async (): Promise => { + const { data: profiles, error: profilesError } = await supabase + .from("profiles") + .select("*") + .eq("role", "agent") + .order("name"); + + if (profilesError) throw profilesError; + + const agentUserIds = profiles.map((p) => p.user_id); + + const { data: userCompanies, error: ucError } = await supabase + .from("user_companies") + .select("user_id, companies(*)") + .in("user_id", agentUserIds); + + if (ucError) throw ucError; + + const companiesByAgent = new Map(); + for (const uc of userCompanies ?? []) { + if (!uc.companies) continue; + const existing = companiesByAgent.get(uc.user_id) ?? []; + existing.push(uc.companies as AgentWithCompanies["companies"][number]); + companiesByAgent.set(uc.user_id, existing); + } + + return profiles.map((profile) => ({ + ...profile, + companies: companiesByAgent.get(profile.user_id) ?? [], + })); +}; + +export const updateAgentCompanies = async ({ + agentId, + companyIds, +}: { + agentId: string; + companyIds: string[]; +}): Promise => { + const { error } = await supabase.rpc("update_agent_companies", { + p_agent_id: agentId, + p_company_ids: companyIds, + }); + if (error) throw error; +}; +``` + +- [ ] **Step 4: Update useAgents.ts to use service** + +```typescript +// src/features/agents/hooks/useAgents.ts +import { useQuery } from "@tanstack/react-query"; +import { fetchAgents } from "../services/agents"; +import type { AgentWithCompanies } from "../types"; + +export const useAgents = () => + useQuery({ + queryKey: ["agents"], + queryFn: fetchAgents, + }); +``` + +- [ ] **Step 5: Update useAgentCompanies.ts to use service** + +```typescript +// src/features/agents/hooks/useAgentCompanies.ts +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { updateAgentCompanies } from "../services/agents"; + +export const useUpdateAgentCompanies = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: updateAgentCompanies, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["agents"] }), + }); +}; +``` + +- [ ] **Step 6: Update useAgentCompanies.test.tsx to mock service** + +Replace the entire file with: + +```typescript +// src/features/agents/hooks/useAgentCompanies.test.tsx +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { type ReactNode } from "react"; +import { useUpdateAgentCompanies } from "./useAgentCompanies"; + +const mockUpdateAgentCompanies = vi.fn(); + +vi.mock("../services/agents", () => ({ + fetchAgents: vi.fn(), + updateAgentCompanies: (args: unknown) => mockUpdateAgentCompanies(args), +})); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +}; + +describe("useUpdateAgentCompanies", () => { + beforeEach(() => vi.clearAllMocks()); + + it("calls updateAgentCompanies service with correct params", async () => { + mockUpdateAgentCompanies.mockResolvedValue(undefined); + + const { result } = renderHook(() => useUpdateAgentCompanies(), { wrapper: createWrapper() }); + + result.current.mutate({ agentId: "agent-123", companyIds: ["co1", "co2"] }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockUpdateAgentCompanies).toHaveBeenCalledWith({ + agentId: "agent-123", + companyIds: ["co1", "co2"], + }); + }); + + it("handles service error", async () => { + mockUpdateAgentCompanies.mockRejectedValue(new Error("Permission denied")); + + const { result } = renderHook(() => useUpdateAgentCompanies(), { wrapper: createWrapper() }); + + result.current.mutate({ agentId: "agent-123", companyIds: ["co1"] }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); + + it("handles empty companyIds", async () => { + mockUpdateAgentCompanies.mockResolvedValue(undefined); + + const { result } = renderHook(() => useUpdateAgentCompanies(), { wrapper: createWrapper() }); + + result.current.mutate({ agentId: "agent-123", companyIds: [] }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockUpdateAgentCompanies).toHaveBeenCalledWith({ agentId: "agent-123", companyIds: [] }); + }); +}); +``` + +- [ ] **Step 7: Run all agents tests** + +```bash +npm test -- agents +``` + +Expected: All PASS + +- [ ] **Step 8: Commit** + +```bash +git add src/features/agents/ +git commit -m "refactor: add agents service layer, update hooks to import service" +``` + +--- + +### Task 7: Auth service layer + update hooks + components + tests + +**Files:** +- Create: `src/features/auth/services/auth.ts` +- Create: `src/features/auth/services/profile.ts` +- Create: `src/features/auth/services/invite.ts` +- Modify: `src/features/auth/hooks/useAuth.ts` +- Modify: `src/features/auth/hooks/useProfile.ts` +- Modify: `src/features/auth/AuthProvider.tsx` +- Modify: `src/features/auth/AuthProvider.test.tsx` +- Modify: `src/features/auth/components/SignupForm.tsx` + +- [ ] **Step 1: Create auth service** + +```typescript +// src/features/auth/services/auth.ts +import { supabase } from "@/lib/supabase"; +import type { Session, AuthChangeEvent, Subscription } from "@supabase/supabase-js"; + +export const signIn = async (email: string, password: string): Promise => { + const { error } = await supabase.auth.signInWithPassword({ email, password }); + if (error) throw error; +}; + +export const signUp = async ( + email: string, + password: string, + name: string, + token?: string, +): Promise => { + const { error } = await supabase.auth.signUp({ + email, + password, + options: { data: { name, ...(token ? { invite_token: token } : {}) } }, + }); + if (error) throw error; +}; + +export const signOut = async (): Promise => { + const { error } = await supabase.auth.signOut(); + if (error) throw error; +}; + +export const getSession = async (): Promise => { + const { data, error } = await supabase.auth.getSession(); + if (error) throw error; + return data.session; +}; + +export const onAuthStateChange = ( + callback: (event: AuthChangeEvent, session: Session | null) => void, +): { unsubscribe: () => void } => { + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange(callback); + return { unsubscribe: () => subscription.unsubscribe() }; +}; +``` + +- [ ] **Step 2: Create profile service** + +```typescript +// src/features/auth/services/profile.ts +import { supabase } from "@/lib/supabase"; +import type { Profile } from "@/lib/types"; + +export const fetchProfile = async (userId: string): Promise => { + const { data, error } = await supabase + .from("profiles") + .select("*") + .eq("user_id", userId) + .single(); + if (error) throw error; + return data; +}; +``` + +- [ ] **Step 3: Create invite service** + +```typescript +// src/features/auth/services/invite.ts +import { supabase } from "@/lib/supabase"; + +export const validateInvite = async (token: string): Promise<{ email: string }> => { + const { data, error } = await supabase.rpc("validate_invite", { p_token: token }); + if (error) throw error; + + const row = (data as { email: string }[] | null)?.[0]; + if (!row) throw new Error("Invalid or expired invite link"); + + return { email: row.email }; +}; +``` + +- [ ] **Step 4: Update useAuth.ts to use auth service** + +```typescript +// src/features/auth/hooks/useAuth.ts +import { signIn, signUp, signOut } from "../services/auth"; + +export const useAuth = () => ({ signIn, signUp, signOut }); +``` + +- [ ] **Step 5: Update useProfile.ts to use profile service** + +```typescript +// src/features/auth/hooks/useProfile.ts +import { useQuery } from "@tanstack/react-query"; +import { fetchProfile } from "../services/profile"; +import type { Profile } from "@/lib/types"; + +export const useProfile = (userId: string | undefined) => + useQuery({ + queryKey: ["profile", userId], + queryFn: () => { + if (!userId) throw new Error("userId is required"); + return fetchProfile(userId); + }, + enabled: !!userId, + }); +``` + +- [ ] **Step 6: Update AuthProvider.tsx to use auth service** + +```typescript +// src/features/auth/AuthProvider.tsx +import { + createContext, + useContext, + useEffect, + useState, + type ReactNode, +} from "react"; +import type { User, Session } from "@supabase/supabase-js"; +import { getSession, onAuthStateChange } from "./services/auth"; +import { useProfile } from "./hooks/useProfile"; +import type { Profile } from "@/lib/types"; + +type AuthContextType = { + user: User | null; + profile: Profile | null; + session: Session | null; + loading: boolean; +}; + +const AuthContext = createContext({ + user: null, + profile: null, + session: null, + loading: true, +}); + +// eslint-disable-next-line react-refresh/only-export-components +export const useAuthContext = () => useContext(AuthContext); + +export const AuthProvider = ({ children }: { children: ReactNode }) => { + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + getSession() + .then((session) => { + setSession(session); + setLoading(false); + }) + .catch(() => { + setSession(null); + setLoading(false); + }); + + const { unsubscribe } = onAuthStateChange((_event, session) => { + setSession(session); + }); + + return unsubscribe; + }, []); + + const user = session?.user ?? null; + const { data: profile } = useProfile(user?.id); + + return ( + + {children} + + ); +}; +``` + +- [ ] **Step 7: Update AuthProvider.test.tsx to mock auth service** + +Replace the entire file with: + +```typescript +// src/features/auth/AuthProvider.test.tsx +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import { renderWithProviders } from "@/test-utils"; +import { AuthProvider, useAuthContext } from "./AuthProvider"; + +const mockGetSession = vi.fn(); +const mockOnAuthStateChange = vi.fn(); + +vi.mock("./services/auth", () => ({ + getSession: () => mockGetSession(), + onAuthStateChange: (...args: unknown[]) => { + mockOnAuthStateChange(...args); + return { unsubscribe: vi.fn() }; + }, + signIn: vi.fn(), + signUp: vi.fn(), + signOut: vi.fn(), +})); + +vi.mock("./hooks/useProfile", () => ({ + useProfile: (userId: string | undefined) => ({ + data: userId + ? { user_id: userId, name: "Test User", role: "admin", created_at: "" } + : undefined, + }), +})); + +const AuthConsumer = () => { + const { user, profile, loading } = useAuthContext(); + if (loading) return
Loading
; + if (!user) return
No user
; + return ( +
+ {user.email} + {profile?.role} +
+ ); +}; + +describe("AuthProvider", () => { + beforeEach(() => vi.clearAllMocks()); + + it("shows loading state initially then resolves with session", async () => { + mockGetSession.mockResolvedValue({ + user: { id: "user-1", email: "admin@test.com" }, + access_token: "token", + }); + + renderWithProviders( + + + , + ); + + expect(screen.getByText("Loading")).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByTestId("email")).toHaveTextContent("admin@test.com"); + }); + expect(screen.getByTestId("role")).toHaveTextContent("admin"); + }); + + it("handles session load failure gracefully", async () => { + mockGetSession.mockRejectedValue(new Error("Network error")); + + renderWithProviders( + + + , + ); + + await waitFor(() => { + expect(screen.getByText("No user")).toBeInTheDocument(); + }); + }); + + it("subscribes to auth state changes and cleans up on unmount", () => { + const unsubscribeFn = vi.fn(); + mockGetSession.mockResolvedValue(null); + mockOnAuthStateChange.mockImplementation(() => {}); + + vi.mock("./services/auth", () => ({ + getSession: () => mockGetSession(), + onAuthStateChange: (...args: unknown[]) => { + mockOnAuthStateChange(...args); + return { unsubscribe: unsubscribeFn }; + }, + signIn: vi.fn(), + signUp: vi.fn(), + signOut: vi.fn(), + })); + + const { unmount } = renderWithProviders( + + + , + ); + + expect(mockOnAuthStateChange).toHaveBeenCalledTimes(1); + unmount(); + }); +}); +``` + +- [ ] **Step 8: Update SignupForm.tsx to use invite service** + +In `src/features/auth/components/SignupForm.tsx`: + +Remove: +```typescript +import { supabase } from "@/lib/supabase"; +``` + +Add: +```typescript +import { validateInvite } from "../services/invite"; +``` + +Replace the `validateToken` function body from: +```typescript + const { data, error: rpcError } = await supabase.rpc("validate_invite", { + p_token: token, + }); + + if (rpcError || !data || data.length === 0) { + setError("Invalid or expired invite link"); + setValidating(false); + return; + } + + const invite = data[0] as { email: string } | undefined; + if (!invite) { + setError("Invalid or expired invite link"); + setValidating(false); + return; + } + setInviteEmail(invite.email); + setValue("email", invite.email); + setValidating(false); +``` +to: +```typescript + const invite = await validateInvite(token); + setInviteEmail(invite.email); + setValue("email", invite.email); + setValidating(false); +``` + +- [ ] **Step 9: Run all auth tests** + +```bash +npm test -- auth +``` + +Expected: All PASS + +- [ ] **Step 10: Commit** + +```bash +git add src/features/auth/ +git commit -m "refactor: add auth/profile/invite service layers, update AuthProvider and SignupForm" +``` + +--- + +### Task 8: Final lint + build + open PR + +**Files:** None modified. + +- [ ] **Step 1: Run full lint** + +```bash +npm run lint +``` + +Expected: 0 errors, 0 warnings. Fix any issues before continuing. + +- [ ] **Step 2: Run full test suite** + +```bash +npm test +``` + +Expected: All tests PASS. + +- [ ] **Step 3: Run TypeScript build** + +```bash +npm run build +``` + +Expected: Build succeeds with 0 type errors. + +- [ ] **Step 4: Push branch and open PR** + +```bash +git push -u origin feat/refactor-hooks-services +gh pr create \ + --title "refactor: separate hooks and services across all features" \ + --body "$(cat <<'EOF' +## Summary + +- Adds `services/` folder per feature with plain async functions wrapping Supabase +- Splits multi-action hook files into one file per action +- Hooks no longer import from `@/lib/supabase` directly — only from their feature's service +- Hook tests mock the service layer; service tests mock supabase +- Documents the convention in CLAUDE.md + +## Features refactored +- customers: service + 4 hooks (useCustomers, useCreateCustomer, useUpdateCustomer, useUserCompany) +- companies: service + invites service + 5 hooks (useCompanies, useCreateCompany, useUpdateCompany, useDeleteCompany, useCreateInvite) +- agents: service + 2 hooks (useAgents, useUpdateAgentCompanies) +- auth: auth/profile/invite services + updated useAuth, useProfile, AuthProvider, SignupForm + +## Test plan +- [ ] All existing unit tests pass +- [ ] New service tests cover happy path and error for each function +- [ ] Hook tests now mock service module, not supabase directly +- [ ] E2E tests: login, customers CRUD, invite flow still work end-to-end + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +Expected: PR URL printed. diff --git a/src/features/agents/hooks/useAgentCompanies.test.tsx b/src/features/agents/hooks/useAgentCompanies.test.tsx index fb0efee..b7e56d6 100644 --- a/src/features/agents/hooks/useAgentCompanies.test.tsx +++ b/src/features/agents/hooks/useAgentCompanies.test.tsx @@ -4,12 +4,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { type ReactNode } from "react"; import { useUpdateAgentCompanies } from "./useAgentCompanies"; -const mockRpc = vi.fn(); +const mockUpdateAgentCompanies = vi.fn(); -vi.mock("@/lib/supabase", () => ({ - supabase: { - rpc: (...args: unknown[]) => mockRpc(...args), - }, +vi.mock("../services/agents", () => ({ + fetchAgents: vi.fn(), + updateAgentCompanies: (args: unknown) => mockUpdateAgentCompanies(args), })); const createWrapper = () => { @@ -22,66 +21,40 @@ const createWrapper = () => { }; describe("useUpdateAgentCompanies", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + beforeEach(() => vi.clearAllMocks()); - it("calls the update_agent_companies RPC with correct params", async () => { - mockRpc.mockResolvedValue({ data: null, error: null }); + it("calls updateAgentCompanies service with correct params", async () => { + mockUpdateAgentCompanies.mockResolvedValue(undefined); - const { result } = renderHook(() => useUpdateAgentCompanies(), { - wrapper: createWrapper(), - }); + const { result } = renderHook(() => useUpdateAgentCompanies(), { wrapper: createWrapper() }); - result.current.mutate({ - agentId: "agent-123", - companyIds: ["company-1", "company-2"], - }); + result.current.mutate({ agentId: "agent-123", companyIds: ["co1", "co2"] }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(mockRpc).toHaveBeenCalledWith("update_agent_companies", { - p_agent_id: "agent-123", - p_company_ids: ["company-1", "company-2"], + expect(mockUpdateAgentCompanies).toHaveBeenCalledWith({ + agentId: "agent-123", + companyIds: ["co1", "co2"], }); }); - it("handles RPC error", async () => { - mockRpc.mockResolvedValue({ - data: null, - error: { message: "Permission denied" }, - }); + it("handles service error", async () => { + mockUpdateAgentCompanies.mockRejectedValue(new Error("Permission denied")); - const { result } = renderHook(() => useUpdateAgentCompanies(), { - wrapper: createWrapper(), - }); + const { result } = renderHook(() => useUpdateAgentCompanies(), { wrapper: createWrapper() }); - result.current.mutate({ - agentId: "agent-123", - companyIds: ["company-1"], - }); + result.current.mutate({ agentId: "agent-123", companyIds: ["co1"] }); await waitFor(() => expect(result.current.isError).toBe(true)); - expect(result.current.error).toEqual({ message: "Permission denied" }); }); it("handles empty companyIds", async () => { - mockRpc.mockResolvedValue({ data: null, error: null }); + mockUpdateAgentCompanies.mockResolvedValue(undefined); - const { result } = renderHook(() => useUpdateAgentCompanies(), { - wrapper: createWrapper(), - }); + const { result } = renderHook(() => useUpdateAgentCompanies(), { wrapper: createWrapper() }); - result.current.mutate({ - agentId: "agent-123", - companyIds: [], - }); + result.current.mutate({ agentId: "agent-123", companyIds: [] }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(mockRpc).toHaveBeenCalledWith("update_agent_companies", { - p_agent_id: "agent-123", - p_company_ids: [], - }); + expect(mockUpdateAgentCompanies).toHaveBeenCalledWith({ agentId: "agent-123", companyIds: [] }); }); }); diff --git a/src/features/agents/hooks/useAgentCompanies.ts b/src/features/agents/hooks/useAgentCompanies.ts index a1b610b..0b3c40f 100644 --- a/src/features/agents/hooks/useAgentCompanies.ts +++ b/src/features/agents/hooks/useAgentCompanies.ts @@ -1,26 +1,10 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { supabase } from "@/lib/supabase"; +import { updateAgentCompanies } from "../services/agents"; export const useUpdateAgentCompanies = () => { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async ({ - agentId, - companyIds, - }: { - agentId: string; - companyIds: string[]; - }) => { - const { error } = await supabase.rpc("update_agent_companies", { - p_agent_id: agentId, - p_company_ids: companyIds, - }); - - if (error) throw error; - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["agents"] }); - }, + mutationFn: updateAgentCompanies, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["agents"] }), }); }; diff --git a/src/features/agents/hooks/useAgents.ts b/src/features/agents/hooks/useAgents.ts index 3a0331d..3f69074 100644 --- a/src/features/agents/hooks/useAgents.ts +++ b/src/features/agents/hooks/useAgents.ts @@ -1,41 +1,9 @@ import { useQuery } from "@tanstack/react-query"; -import { supabase } from "@/lib/supabase"; +import { fetchAgents } from "../services/agents"; import type { AgentWithCompanies } from "../types"; -export const useAgents = () => { - return useQuery({ +export const useAgents = () => + useQuery({ queryKey: ["agents"], - queryFn: async () => { - // Get all agent profiles - const { data: profiles, error: profilesError } = await supabase - .from("profiles") - .select("*") - .eq("role", "agent") - .order("name"); - - if (profilesError) throw profilesError; - - const agentUserIds = profiles.map((p) => p.user_id); - - const { data: userCompanies, error: ucError } = await supabase - .from("user_companies") - .select("user_id, companies(*)") - .in("user_id", agentUserIds); - - if (ucError) throw ucError; - - const companiesByAgent = new Map(); - for (const uc of userCompanies ?? []) { - if (!uc.companies) continue; - const existing = companiesByAgent.get(uc.user_id) ?? []; - existing.push(uc.companies as AgentWithCompanies["companies"][number]); - companiesByAgent.set(uc.user_id, existing); - } - - return profiles.map((profile) => ({ - ...profile, - companies: companiesByAgent.get(profile.user_id) ?? [], - })); - }, + queryFn: fetchAgents, }); -}; diff --git a/src/features/agents/services/agents.test.ts b/src/features/agents/services/agents.test.ts new file mode 100644 index 0000000..79d345e --- /dev/null +++ b/src/features/agents/services/agents.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fetchAgents, updateAgentCompanies } from "./agents"; +import type { AgentWithCompanies } from "../types"; + +const mockFrom = vi.fn(); +const mockRpc = vi.fn(); +vi.mock("@/lib/supabase", () => ({ + supabase: { + from: (...args: unknown[]) => mockFrom(...args), + rpc: (...args: unknown[]) => mockRpc(...args), + }, +})); + +const makeChain = (resolve: unknown) => { + const chain: Record = {}; + chain.select = vi.fn().mockReturnValue(chain); + chain.eq = vi.fn().mockReturnValue(chain); + chain.in = vi.fn().mockResolvedValue(resolve); + chain.order = vi.fn().mockResolvedValue(resolve); + return chain; +}; + +describe("fetchAgents", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns agents with their companies", async () => { + const profiles = [{ user_id: "u1", name: "Agent A", role: "agent", created_at: "" }]; + const userCompanies = [{ user_id: "u1", companies: { id: "co1", name: "Acme", created_at: "", updated_at: "" } }]; + + mockFrom.mockImplementation((table: string) => { + if (table === "profiles") return makeChain({ data: profiles, error: null }); + if (table === "user_companies") return makeChain({ data: userCompanies, error: null }); + return makeChain({ data: null, error: null }); + }); + + const result = await fetchAgents() as AgentWithCompanies[]; + expect(result).toHaveLength(1); + expect(result[0]?.companies).toHaveLength(1); + expect(result[0]?.companies[0]?.name).toBe("Acme"); + }); + + it("throws on profiles error", async () => { + mockFrom.mockReturnValue(makeChain({ data: null, error: { message: "Forbidden" } })); + await expect(fetchAgents()).rejects.toEqual({ message: "Forbidden" }); + }); + + it("returns agent with empty companies when no user_companies", async () => { + const profiles = [{ user_id: "u1", name: "Agent A", role: "agent", created_at: "" }]; + let callCount = 0; + mockFrom.mockImplementation(() => { + callCount++; + if (callCount === 1) return makeChain({ data: profiles, error: null }); + return makeChain({ data: [], error: null }); + }); + + const result = await fetchAgents() as AgentWithCompanies[]; + expect(result).toHaveLength(1); + expect(result[0]?.companies).toHaveLength(0); + }); + + it("throws on user_companies error", async () => { + const profiles = [{ user_id: "u1", name: "Agent A", role: "agent", created_at: "" }]; + let callCount = 0; + mockFrom.mockImplementation(() => { + callCount++; + if (callCount === 1) return makeChain({ data: profiles, error: null }); + return makeChain({ data: null, error: { message: "user_companies failed" } }); + }); + + await expect(fetchAgents()).rejects.toEqual({ message: "user_companies failed" }); + }); +}); + +describe("updateAgentCompanies", () => { + beforeEach(() => vi.clearAllMocks()); + + it("calls update_agent_companies RPC", async () => { + mockRpc.mockResolvedValue({ data: null, error: null }); + await updateAgentCompanies({ agentId: "u1", companyIds: ["co1"] }); + expect(mockRpc).toHaveBeenCalledWith("update_agent_companies", { + p_agent_id: "u1", + p_company_ids: ["co1"], + }); + }); + + it("throws on error", async () => { + mockRpc.mockResolvedValue({ data: null, error: { message: "Permission denied" } }); + await expect(updateAgentCompanies({ agentId: "u1", companyIds: [] })).rejects.toEqual({ + message: "Permission denied", + }); + }); +}); diff --git a/src/features/agents/services/agents.ts b/src/features/agents/services/agents.ts new file mode 100644 index 0000000..7edf3da --- /dev/null +++ b/src/features/agents/services/agents.ts @@ -0,0 +1,48 @@ +import { supabase } from "@/lib/supabase"; +import type { AgentWithCompanies } from "../types"; + +export const fetchAgents = async (): Promise => { + const { data: profiles, error: profilesError } = await supabase + .from("profiles") + .select("*") + .eq("role", "agent") + .order("name"); + + if (profilesError) throw profilesError; + + const agentUserIds = profiles.map((p) => p.user_id); + + const { data: userCompanies, error: ucError } = await supabase + .from("user_companies") + .select("user_id, companies(*)") + .in("user_id", agentUserIds); + + if (ucError) throw ucError; + + const companiesByAgent = new Map(); + for (const uc of userCompanies ?? []) { + if (!uc.companies) continue; + const existing = companiesByAgent.get(uc.user_id) ?? []; + existing.push(uc.companies as AgentWithCompanies["companies"][number]); + companiesByAgent.set(uc.user_id, existing); + } + + return profiles.map((profile) => ({ + ...profile, + companies: companiesByAgent.get(profile.user_id) ?? [], + })); +}; + +export const updateAgentCompanies = async ({ + agentId, + companyIds, +}: { + agentId: string; + companyIds: string[]; +}): Promise => { + const { error } = await supabase.rpc("update_agent_companies", { + p_agent_id: agentId, + p_company_ids: companyIds, + }); + if (error) throw error; +}; diff --git a/src/features/auth/AuthProvider.test.tsx b/src/features/auth/AuthProvider.test.tsx index ad01aaf..9188d5d 100644 --- a/src/features/auth/AuthProvider.test.tsx +++ b/src/features/auth/AuthProvider.test.tsx @@ -6,13 +6,15 @@ import { AuthProvider, useAuthContext } from "./AuthProvider"; const mockGetSession = vi.fn(); const mockOnAuthStateChange = vi.fn(); -vi.mock("@/lib/supabase", () => ({ - supabase: { - auth: { - getSession: () => mockGetSession(), - onAuthStateChange: (...args: unknown[]) => mockOnAuthStateChange(...args), - }, +vi.mock("./services/auth", () => ({ + getSession: () => mockGetSession(), + onAuthStateChange: (...args: unknown[]) => { + mockOnAuthStateChange(...args); + return { unsubscribe: vi.fn() }; }, + signIn: vi.fn(), + signUp: vi.fn(), + signOut: vi.fn(), })); vi.mock("./hooks/useProfile", () => ({ @@ -36,21 +38,12 @@ const AuthConsumer = () => { }; describe("AuthProvider", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockOnAuthStateChange.mockReturnValue({ - data: { subscription: { unsubscribe: vi.fn() } }, - }); - }); + beforeEach(() => vi.clearAllMocks()); it("shows loading state initially then resolves with session", async () => { mockGetSession.mockResolvedValue({ - data: { - session: { - user: { id: "user-1", email: "admin@test.com" }, - access_token: "token", - }, - }, + user: { id: "user-1", email: "admin@test.com" }, + access_token: "token", }); renderWithProviders( @@ -81,22 +74,15 @@ describe("AuthProvider", () => { }); }); - it("subscribes to auth state changes and cleans up", () => { - const unsubscribe = vi.fn(); - mockGetSession.mockResolvedValue({ data: { session: null } }); - mockOnAuthStateChange.mockReturnValue({ - data: { subscription: { unsubscribe } }, - }); + it("subscribes to auth state changes on mount", () => { + mockGetSession.mockResolvedValue(null); - const { unmount } = renderWithProviders( + renderWithProviders( , ); expect(mockOnAuthStateChange).toHaveBeenCalledTimes(1); - - unmount(); - expect(unsubscribe).toHaveBeenCalled(); }); }); diff --git a/src/features/auth/AuthProvider.tsx b/src/features/auth/AuthProvider.tsx index 36b7c25..59248c8 100644 --- a/src/features/auth/AuthProvider.tsx +++ b/src/features/auth/AuthProvider.tsx @@ -6,7 +6,7 @@ import { type ReactNode, } from "react"; import type { User, Session } from "@supabase/supabase-js"; -import { supabase } from "@/lib/supabase"; +import { getSession, onAuthStateChange } from "./services/auth"; import { useProfile } from "./hooks/useProfile"; import type { Profile } from "@/lib/types"; @@ -32,35 +32,28 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { const [loading, setLoading] = useState(true); useEffect(() => { - supabase.auth.getSession().then(({ data: { session } }) => { + getSession() + .then((session) => { + setSession(session); + setLoading(false); + }) + .catch(() => { + setSession(null); + setLoading(false); + }); + + const { unsubscribe } = onAuthStateChange((_event, session) => { setSession(session); - setLoading(false); - }).catch(() => { - setSession(null); - setLoading(false); }); - const { - data: { subscription }, - } = supabase.auth.onAuthStateChange((_event, session) => { - setSession(session); - }); - - return () => subscription.unsubscribe(); + return unsubscribe; }, []); const user = session?.user ?? null; const { data: profile } = useProfile(user?.id); return ( - + {children} ); diff --git a/src/features/auth/components/SignupForm.tsx b/src/features/auth/components/SignupForm.tsx index f2a0a07..2f543ba 100644 --- a/src/features/auth/components/SignupForm.tsx +++ b/src/features/auth/components/SignupForm.tsx @@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { supabase } from "@/lib/supabase"; +import { validateInvite } from "../services/invite"; import { useAuth } from "../hooks/useAuth"; const signupSchema = z.object({ @@ -45,22 +45,7 @@ export const SignupForm = () => { hasValidated.current = true; const validateToken = async () => { - const { data, error: rpcError } = await supabase.rpc("validate_invite", { - p_token: token, - }); - - if (rpcError || !data || data.length === 0) { - setError("Invalid or expired invite link"); - setValidating(false); - return; - } - - const invite = data[0] as { email: string } | undefined; - if (!invite) { - setError("Invalid or expired invite link"); - setValidating(false); - return; - } + const invite = await validateInvite(token); setInviteEmail(invite.email); setValue("email", invite.email); setValidating(false); @@ -113,7 +98,7 @@ export const SignupForm = () => { -
+ {error && (
{error} diff --git a/src/features/auth/hooks/useAuth.test.ts b/src/features/auth/hooks/useAuth.test.ts index 24d1b24..214aa1e 100644 --- a/src/features/auth/hooks/useAuth.test.ts +++ b/src/features/auth/hooks/useAuth.test.ts @@ -2,18 +2,16 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook } from "@testing-library/react"; import { useAuth } from "./useAuth"; -const mockSignInWithPassword = vi.fn(); +const mockSignIn = vi.fn(); const mockSignUp = vi.fn(); const mockSignOut = vi.fn(); -vi.mock("@/lib/supabase", () => ({ - supabase: { - auth: { - signInWithPassword: (...args: unknown[]) => mockSignInWithPassword(...args), - signUp: (...args: unknown[]) => mockSignUp(...args), - signOut: () => mockSignOut(), - }, - }, +vi.mock("../services/auth", () => ({ + signIn: (...args: unknown[]) => mockSignIn(...args), + signUp: (...args: unknown[]) => mockSignUp(...args), + signOut: () => mockSignOut(), + getSession: vi.fn(), + onAuthStateChange: vi.fn(), })); describe("useAuth", () => { @@ -22,22 +20,20 @@ describe("useAuth", () => { }); describe("signIn", () => { - it("calls supabase signInWithPassword with email and password", async () => { - mockSignInWithPassword.mockResolvedValue({ error: null }); + it("calls signIn with email and password", async () => { + mockSignIn.mockResolvedValue(undefined); const { result } = renderHook(() => useAuth()); await result.current.signIn("test@example.com", "password123"); - expect(mockSignInWithPassword).toHaveBeenCalledWith({ - email: "test@example.com", - password: "password123", - }); + expect(mockSignIn).toHaveBeenCalledWith( + "test@example.com", + "password123", + ); }); - it("throws on supabase error", async () => { - mockSignInWithPassword.mockResolvedValue({ - error: new Error("Invalid credentials"), - }); + it("throws on service error", async () => { + mockSignIn.mockRejectedValue(new Error("Invalid credentials")); const { result } = renderHook(() => useAuth()); await expect( @@ -47,21 +43,21 @@ describe("useAuth", () => { }); describe("signUp", () => { - it("calls supabase signUp with email, password, and name", async () => { - mockSignUp.mockResolvedValue({ error: null }); + it("calls signUp with email, password, and name", async () => { + mockSignUp.mockResolvedValue(undefined); const { result } = renderHook(() => useAuth()); await result.current.signUp("test@example.com", "password123", "Test User"); - expect(mockSignUp).toHaveBeenCalledWith({ - email: "test@example.com", - password: "password123", - options: { data: { name: "Test User" } }, - }); + expect(mockSignUp).toHaveBeenCalledWith( + "test@example.com", + "password123", + "Test User", + ); }); - it("includes invite_token in metadata when provided", async () => { - mockSignUp.mockResolvedValue({ error: null }); + it("includes invite_token when provided", async () => { + mockSignUp.mockResolvedValue(undefined); const { result } = renderHook(() => useAuth()); await result.current.signUp( @@ -71,19 +67,16 @@ describe("useAuth", () => { "abc-invite-token", ); - expect(mockSignUp).toHaveBeenCalledWith({ - email: "test@example.com", - password: "password123", - options: { - data: { name: "Test User", invite_token: "abc-invite-token" }, - }, - }); + expect(mockSignUp).toHaveBeenCalledWith( + "test@example.com", + "password123", + "Test User", + "abc-invite-token", + ); }); - it("throws on supabase error", async () => { - mockSignUp.mockResolvedValue({ - error: new Error("Email already registered"), - }); + it("throws on service error", async () => { + mockSignUp.mockRejectedValue(new Error("Email already registered")); const { result } = renderHook(() => useAuth()); await expect( @@ -93,8 +86,8 @@ describe("useAuth", () => { }); describe("signOut", () => { - it("calls supabase signOut", async () => { - mockSignOut.mockResolvedValue({ error: null }); + it("calls signOut", async () => { + mockSignOut.mockResolvedValue(undefined); const { result } = renderHook(() => useAuth()); await result.current.signOut(); @@ -102,10 +95,8 @@ describe("useAuth", () => { expect(mockSignOut).toHaveBeenCalled(); }); - it("throws on supabase error", async () => { - mockSignOut.mockResolvedValue({ - error: new Error("Network error"), - }); + it("throws on service error", async () => { + mockSignOut.mockRejectedValue(new Error("Network error")); const { result } = renderHook(() => useAuth()); await expect(result.current.signOut()).rejects.toThrow("Network error"); diff --git a/src/features/auth/hooks/useAuth.ts b/src/features/auth/hooks/useAuth.ts index 06c3fc4..9427ff2 100644 --- a/src/features/auth/hooks/useAuth.ts +++ b/src/features/auth/hooks/useAuth.ts @@ -1,34 +1,3 @@ -import { supabase } from "@/lib/supabase"; +import { signIn, signUp, signOut } from "../services/auth"; -export const useAuth = () => { - const signIn = async (email: string, password: string) => { - const { error } = await supabase.auth.signInWithPassword({ - email, - password, - }); - if (error) throw error; - }; - - const signUp = async ( - email: string, - password: string, - name: string, - token?: string, - ) => { - const { error } = await supabase.auth.signUp({ - email, - password, - options: { - data: { name, ...(token ? { invite_token: token } : {}) }, - }, - }); - if (error) throw error; - }; - - const signOut = async () => { - const { error } = await supabase.auth.signOut(); - if (error) throw error; - }; - - return { signIn, signUp, signOut }; -}; +export const useAuth = () => ({ signIn, signUp, signOut }); diff --git a/src/features/auth/hooks/useProfile.ts b/src/features/auth/hooks/useProfile.ts index f8467d4..c61be82 100644 --- a/src/features/auth/hooks/useProfile.ts +++ b/src/features/auth/hooks/useProfile.ts @@ -1,20 +1,13 @@ import { useQuery } from "@tanstack/react-query"; -import { supabase } from "@/lib/supabase"; +import { fetchProfile } from "../services/profile"; import type { Profile } from "@/lib/types"; -export const useProfile = (userId: string | undefined) => { - return useQuery({ +export const useProfile = (userId: string | undefined) => + useQuery({ queryKey: ["profile", userId], - queryFn: async () => { + queryFn: () => { if (!userId) throw new Error("userId is required"); - const { data, error } = await supabase - .from("profiles") - .select("*") - .eq("user_id", userId) - .single(); - if (error) throw error; - return data; + return fetchProfile(userId); }, enabled: !!userId, }); -}; diff --git a/src/features/auth/services/auth.ts b/src/features/auth/services/auth.ts new file mode 100644 index 0000000..c0cea8a --- /dev/null +++ b/src/features/auth/services/auth.ts @@ -0,0 +1,41 @@ +import { supabase } from "@/lib/supabase"; +import type { Session, AuthChangeEvent } from "@supabase/supabase-js"; + +export const signIn = async (email: string, password: string): Promise => { + const { error } = await supabase.auth.signInWithPassword({ email, password }); + if (error) throw error; +}; + +export const signUp = async ( + email: string, + password: string, + name: string, + token?: string, +): Promise => { + const { error } = await supabase.auth.signUp({ + email, + password, + options: { data: { name, ...(token ? { invite_token: token } : {}) } }, + }); + if (error) throw error; +}; + +export const signOut = async (): Promise => { + const { error } = await supabase.auth.signOut(); + if (error) throw error; +}; + +export const getSession = async (): Promise => { + const { data, error } = await supabase.auth.getSession(); + if (error) throw error; + return data.session; +}; + +export const onAuthStateChange = ( + callback: (event: AuthChangeEvent, session: Session | null) => void, +): { unsubscribe: () => void } => { + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange(callback); + return { unsubscribe: () => subscription.unsubscribe() }; +}; diff --git a/src/features/auth/services/invite.ts b/src/features/auth/services/invite.ts new file mode 100644 index 0000000..5754ea2 --- /dev/null +++ b/src/features/auth/services/invite.ts @@ -0,0 +1,11 @@ +import { supabase } from "@/lib/supabase"; + +export const validateInvite = async (token: string): Promise<{ email: string }> => { + const { data, error } = await supabase.rpc("validate_invite", { p_token: token }); + if (error) throw error; + + const row = data?.[0]; + if (!row) throw new Error("Invalid or expired invite link"); + + return { email: row.email as string }; +}; diff --git a/src/features/auth/services/profile.ts b/src/features/auth/services/profile.ts new file mode 100644 index 0000000..29ce3fe --- /dev/null +++ b/src/features/auth/services/profile.ts @@ -0,0 +1,12 @@ +import { supabase } from "@/lib/supabase"; +import type { Profile } from "@/lib/types"; + +export const fetchProfile = async (userId: string): Promise => { + const { data, error } = await supabase + .from("profiles") + .select("*") + .eq("user_id", userId) + .single(); + if (error) throw error; + return data; +}; diff --git a/src/features/companies/components/CompanyDetail.tsx b/src/features/companies/components/CompanyDetail.tsx index b7e6cc3..eecf661 100644 --- a/src/features/companies/components/CompanyDetail.tsx +++ b/src/features/companies/components/CompanyDetail.tsx @@ -8,7 +8,8 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { useCompany } from "../hooks/useCompany"; -import { useUpdateCompany, useDeleteCompany } from "../hooks/useCompanies"; +import { useUpdateCompany } from "../hooks/useUpdateCompany"; +import { useDeleteCompany } from "../hooks/useDeleteCompany"; import { InviteCMDialog } from "./InviteCMDialog"; const editSchema = z.object({ diff --git a/src/features/companies/components/CompanyForm.tsx b/src/features/companies/components/CompanyForm.tsx index 193b988..8bce906 100644 --- a/src/features/companies/components/CompanyForm.tsx +++ b/src/features/companies/components/CompanyForm.tsx @@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Card, CardContent } from "@/components/ui/card"; -import { useCreateCompany } from "../hooks/useCompanies"; +import { useCreateCompany } from "../hooks/useCreateCompany"; const companySchema = z.object({ name: z.string().min(1, "Company name is required"), diff --git a/src/features/companies/hooks/useCompanies.test.tsx b/src/features/companies/hooks/useCompanies.test.tsx index b5b73a4..e8d774b 100644 --- a/src/features/companies/hooks/useCompanies.test.tsx +++ b/src/features/companies/hooks/useCompanies.test.tsx @@ -2,51 +2,18 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { type ReactNode } from "react"; -import { - useCompanies, - useCreateCompany, -} from "./useCompanies"; - -const mockSelect = vi.fn(); -const mockInsert = vi.fn(); - -const chainable = (terminal: (...args: unknown[]) => unknown) => { - const chain: Record = {}; - chain.select = vi.fn().mockReturnValue(chain); - chain.insert = vi.fn().mockReturnValue(chain); - chain.update = vi.fn().mockReturnValue(chain); - chain.delete = vi.fn().mockReturnValue(chain); - chain.eq = vi.fn().mockReturnValue(chain); - chain.order = vi.fn().mockImplementation((...args: unknown[]) => terminal(...args)); - chain.single = vi.fn().mockImplementation((...args: unknown[]) => terminal(...args)); - return chain; -}; - -vi.mock("@/lib/supabase", () => ({ - supabase: { - from: (table: string) => { - if (table === "companies") { - return { - select: (...args: unknown[]) => { - const chain = chainable(mockSelect); - mockSelect.call(null, ...args); - return chain; - }, - insert: (...args: unknown[]) => { - mockInsert(...args); - return chainable(() => mockInsert.mock.results[mockInsert.mock.results.length - 1]?.value ?? { data: null, error: null }); - }, - update: () => { - return chainable(() => ({ data: null, error: null })); - }, - delete: () => { - return chainable(() => ({ data: null, error: null })); - }, - }; - } - return {}; - }, - }, +import { useCompanies } from "./useCompanies"; +import { useCreateCompany } from "./useCreateCompany"; + +const mockFetchCompanies = vi.fn(); +const mockCreateCompany = vi.fn(); + +vi.mock("../services/companies", () => ({ + fetchCompanies: () => mockFetchCompanies(), + fetchCompany: vi.fn(), + createCompany: (name: unknown) => mockCreateCompany(name), + updateCompany: vi.fn(), + deleteCompany: vi.fn(), })); const createWrapper = () => { @@ -59,55 +26,52 @@ const createWrapper = () => { }; describe("useCompanies", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + beforeEach(() => vi.clearAllMocks()); it("fetches companies list", async () => { const companies = [ { id: "c1", name: "Acme", created_at: "", updated_at: "" }, { id: "c2", name: "Beta", created_at: "", updated_at: "" }, ]; - mockSelect.mockResolvedValue({ data: companies, error: null }); + mockFetchCompanies.mockResolvedValue(companies); - const { result } = renderHook(() => useCompanies(), { - wrapper: createWrapper(), - }); + const { result } = renderHook(() => useCompanies(), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data).toEqual(companies); }); it("handles fetch error", async () => { - mockSelect.mockResolvedValue({ - data: null, - error: { message: "Permission denied" }, - }); + mockFetchCompanies.mockRejectedValue(new Error("Permission denied")); - const { result } = renderHook(() => useCompanies(), { - wrapper: createWrapper(), - }); + const { result } = renderHook(() => useCompanies(), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isError).toBe(true)); }); }); describe("useCreateCompany", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + beforeEach(() => vi.clearAllMocks()); - it("creates a company", async () => { + it("calls createCompany service with name", async () => { const newCompany = { id: "c1", name: "New Corp", created_at: "", updated_at: "" }; - mockInsert.mockResolvedValue({ data: newCompany, error: null }); + mockCreateCompany.mockResolvedValue(newCompany); - const { result } = renderHook(() => useCreateCompany(), { - wrapper: createWrapper(), - }); + const { result } = renderHook(() => useCreateCompany(), { wrapper: createWrapper() }); result.current.mutate("New Corp"); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(mockInsert).toHaveBeenCalledWith({ name: "New Corp" }); + expect(mockCreateCompany).toHaveBeenCalledWith("New Corp"); + }); + + it("handles create error", async () => { + mockCreateCompany.mockRejectedValue(new Error("Duplicate name")); + + const { result } = renderHook(() => useCreateCompany(), { wrapper: createWrapper() }); + + result.current.mutate("New Corp"); + + await waitFor(() => expect(result.current.isError).toBe(true)); }); }); diff --git a/src/features/companies/hooks/useCompanies.ts b/src/features/companies/hooks/useCompanies.ts index 8c71f9d..e5b927f 100644 --- a/src/features/companies/hooks/useCompanies.ts +++ b/src/features/companies/hooks/useCompanies.ts @@ -1,70 +1,9 @@ -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { supabase } from "@/lib/supabase"; +import { useQuery } from "@tanstack/react-query"; +import { fetchCompanies } from "../services/companies"; import type { Company } from "@/lib/types"; -export const useCompanies = () => { - return useQuery({ +export const useCompanies = () => + useQuery({ queryKey: ["companies"], - queryFn: async () => { - const { data, error } = await supabase - .from("companies") - .select("*") - .order("name"); - if (error) throw error; - return data; - }, + queryFn: fetchCompanies, }); -}; - -export const useCreateCompany = () => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (name: string) => { - const { data, error } = await supabase - .from("companies") - .insert({ name }) - .select() - .single(); - if (error) throw error; - return data; - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["companies"] }); - }, - }); -}; - -export const useUpdateCompany = () => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async ({ id, name }: { id: string; name: string }) => { - const { data, error } = await supabase - .from("companies") - .update({ name }) - .eq("id", id) - .select() - .single(); - if (error) throw error; - return data; - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["companies"] }); - }, - }); -}; - -export const useDeleteCompany = () => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (id: string) => { - const { error } = await supabase.from("companies").delete().eq("id", id); - if (error) throw error; - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["companies"] }); - }, - }); -}; diff --git a/src/features/companies/hooks/useCompany.ts b/src/features/companies/hooks/useCompany.ts index c3cf32a..61f8554 100644 --- a/src/features/companies/hooks/useCompany.ts +++ b/src/features/companies/hooks/useCompany.ts @@ -1,20 +1,13 @@ import { useQuery } from "@tanstack/react-query"; -import { supabase } from "@/lib/supabase"; +import { fetchCompany } from "../services/companies"; import type { Company } from "@/lib/types"; -export const useCompany = (id: string | undefined) => { - return useQuery({ +export const useCompany = (id: string | undefined) => + useQuery({ queryKey: ["company", id], - queryFn: async () => { + queryFn: () => { if (!id) throw new Error("Company id is required"); - const { data, error } = await supabase - .from("companies") - .select("*") - .eq("id", id) - .single(); - if (error) throw error; - return data; + return fetchCompany(id); }, enabled: !!id, }); -}; diff --git a/src/features/companies/hooks/useCreateCompany.ts b/src/features/companies/hooks/useCreateCompany.ts new file mode 100644 index 0000000..9569edb --- /dev/null +++ b/src/features/companies/hooks/useCreateCompany.ts @@ -0,0 +1,10 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { createCompany } from "../services/companies"; + +export const useCreateCompany = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createCompany, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["companies"] }), + }); +}; diff --git a/src/features/companies/hooks/useCreateInvite.test.tsx b/src/features/companies/hooks/useCreateInvite.test.tsx index 7f5b620..a706fbc 100644 --- a/src/features/companies/hooks/useCreateInvite.test.tsx +++ b/src/features/companies/hooks/useCreateInvite.test.tsx @@ -4,12 +4,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { type ReactNode } from "react"; import { useCreateInvite } from "./useCreateInvite"; -const mockRpc = vi.fn(); +const mockCreateInvite = vi.fn(); -vi.mock("@/lib/supabase", () => ({ - supabase: { - rpc: (...args: unknown[]) => mockRpc(...args), - }, +vi.mock("../services/invites", () => ({ + createInvite: (args: unknown) => mockCreateInvite(args), + validateInvite: vi.fn(), })); const createWrapper = () => { @@ -22,103 +21,31 @@ const createWrapper = () => { }; describe("useCreateInvite", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + beforeEach(() => vi.clearAllMocks()); - it("calls the create_invite RPC with correct params", async () => { - mockRpc.mockResolvedValue({ - data: [{ invite_id: "inv-1", token: "generated-token-abc" }], - error: null, - }); + it("calls createInvite service with correct params", async () => { + mockCreateInvite.mockResolvedValue({ invite: { id: "inv-1" }, token: "tok-abc" }); - const { result } = renderHook(() => useCreateInvite(), { - wrapper: createWrapper(), - }); + const { result } = renderHook(() => useCreateInvite(), { wrapper: createWrapper() }); - result.current.mutate({ - email: "agent@example.com", - role: "agent", - companyIds: ["company-1"], - }); + result.current.mutate({ email: "agent@example.com", role: "agent", companyIds: ["co1"] }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(mockRpc).toHaveBeenCalledWith("create_invite", { - p_email: "agent@example.com", - p_role: "agent", - p_company_ids: ["company-1"], - } as Record); - - expect(result.current.data).toEqual({ - invite: { id: "inv-1" }, - token: "generated-token-abc", - }); - }); - - it("omits p_company_ids when companyIds is empty", async () => { - mockRpc.mockResolvedValue({ - data: [{ invite_id: "inv-2", token: "token-no-companies" }], - error: null, - }); - - const { result } = renderHook(() => useCreateInvite(), { - wrapper: createWrapper(), - }); - - result.current.mutate({ + expect(mockCreateInvite).toHaveBeenCalledWith({ email: "agent@example.com", role: "agent", - companyIds: [], + companyIds: ["co1"], }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(mockRpc).toHaveBeenCalledWith("create_invite", { - p_email: "agent@example.com", - p_role: "agent", - } as Record); + expect(result.current.data).toEqual({ invite: { id: "inv-1" }, token: "tok-abc" }); }); - it("handles RPC error", async () => { - mockRpc.mockResolvedValue({ - data: null, - error: { message: "Only admins can create invites" }, - }); - - const { result } = renderHook(() => useCreateInvite(), { - wrapper: createWrapper(), - }); + it("handles service error", async () => { + mockCreateInvite.mockRejectedValue(new Error("Only admins can create invites")); - result.current.mutate({ - email: "test@example.com", - role: "customer_manager", - companyIds: [], - }); + const { result } = renderHook(() => useCreateInvite(), { wrapper: createWrapper() }); - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(result.current.error).toEqual({ - message: "Only admins can create invites", - }); - }); - - it("handles empty data response", async () => { - mockRpc.mockResolvedValue({ data: [], error: null }); - - const { result } = renderHook(() => useCreateInvite(), { - wrapper: createWrapper(), - }); - - result.current.mutate({ - email: "test@example.com", - role: "agent", - companyIds: [], - }); + result.current.mutate({ email: "test@x.com", role: "customer_manager", companyIds: [] }); await waitFor(() => expect(result.current.isError).toBe(true)); - expect(result.current.error).toBeInstanceOf(Error); - expect((result.current.error as Error).message).toBe( - "Failed to create invite", - ); }); }); diff --git a/src/features/companies/hooks/useCreateInvite.ts b/src/features/companies/hooks/useCreateInvite.ts index 6639e4d..4bc4e11 100644 --- a/src/features/companies/hooks/useCreateInvite.ts +++ b/src/features/companies/hooks/useCreateInvite.ts @@ -1,5 +1,5 @@ import { useMutation } from "@tanstack/react-query"; -import { supabase } from "@/lib/supabase"; +import { createInvite } from "../services/invites"; import type { AppRole } from "@/lib/types"; type CreateInviteParams = { @@ -8,24 +8,7 @@ type CreateInviteParams = { companyIds: string[]; }; -export const useCreateInvite = () => { - return useMutation({ - mutationFn: async ({ email, role, companyIds }: CreateInviteParams) => { - const params: { p_email: string; p_role: AppRole; p_company_ids?: string[] } = { - p_email: email, - p_role: role, - }; - if (companyIds.length > 0) { - params.p_company_ids = companyIds; - } - const { data, error } = await supabase.rpc("create_invite", params); - - if (error) throw error; - - const row = data?.[0]; - if (!row) throw new Error("Failed to create invite"); - - return { invite: { id: row.invite_id }, token: row.token }; - }, +export const useCreateInvite = () => + useMutation({ + mutationFn: (params: CreateInviteParams) => createInvite(params), }); -}; diff --git a/src/features/companies/hooks/useDeleteCompany.ts b/src/features/companies/hooks/useDeleteCompany.ts new file mode 100644 index 0000000..18337c7 --- /dev/null +++ b/src/features/companies/hooks/useDeleteCompany.ts @@ -0,0 +1,10 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { deleteCompany } from "../services/companies"; + +export const useDeleteCompany = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: deleteCompany, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["companies"] }), + }); +}; diff --git a/src/features/companies/hooks/useUpdateCompany.ts b/src/features/companies/hooks/useUpdateCompany.ts new file mode 100644 index 0000000..f303fbd --- /dev/null +++ b/src/features/companies/hooks/useUpdateCompany.ts @@ -0,0 +1,10 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { updateCompany } from "../services/companies"; + +export const useUpdateCompany = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: updateCompany, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["companies"] }), + }); +}; diff --git a/src/features/companies/services/companies.test.ts b/src/features/companies/services/companies.test.ts new file mode 100644 index 0000000..72cc2bd --- /dev/null +++ b/src/features/companies/services/companies.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fetchCompanies, fetchCompany, createCompany, updateCompany, deleteCompany } from "./companies"; + +const mockFrom = vi.fn(); +vi.mock("@/lib/supabase", () => ({ + supabase: { from: (...args: unknown[]) => mockFrom(...args) }, +})); + +const makeChain = (resolve: unknown) => { + const chain: Record = {}; + chain.select = vi.fn().mockReturnValue(chain); + chain.insert = vi.fn().mockReturnValue(chain); + chain.update = vi.fn().mockReturnValue(chain); + chain.delete = vi.fn().mockReturnValue(chain); + chain.eq = vi.fn().mockReturnValue(chain); + chain.order = vi.fn().mockResolvedValue(resolve); + chain.single = vi.fn().mockResolvedValue(resolve); + // Make the chain itself thenable so it resolves when awaited directly + // (e.g. deleteCompany which ends at .eq() without .single()) + chain.then = vi.fn((onFulfilled: (v: unknown) => unknown) => Promise.resolve(resolve).then(onFulfilled)); + return chain; +}; + +describe("fetchCompanies", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns companies list", async () => { + const rows = [{ id: "c1", name: "Acme", created_at: "", updated_at: "" }]; + mockFrom.mockReturnValue(makeChain({ data: rows, error: null })); + expect(await fetchCompanies()).toEqual(rows); + }); + + it("throws on error", async () => { + mockFrom.mockReturnValue(makeChain({ data: null, error: { message: "fail" } })); + await expect(fetchCompanies()).rejects.toEqual({ message: "fail" }); + }); +}); + +describe("fetchCompany", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns single company by id", async () => { + const row = { id: "c1", name: "Acme", created_at: "", updated_at: "" }; + const chain = makeChain({ data: row, error: null }); + mockFrom.mockReturnValue(chain); + expect(await fetchCompany("c1")).toEqual(row); + expect(chain.eq).toHaveBeenCalledWith("id", "c1"); + }); + + it("throws on error", async () => { + mockFrom.mockReturnValue(makeChain({ data: null, error: { message: "Not found" } })); + await expect(fetchCompany("c1")).rejects.toEqual({ message: "Not found" }); + }); +}); + +describe("createCompany", () => { + beforeEach(() => vi.clearAllMocks()); + + it("inserts with name and returns row", async () => { + const row = { id: "c1", name: "New Corp", created_at: "", updated_at: "" }; + const chain = makeChain({ data: row, error: null }); + mockFrom.mockReturnValue(chain); + expect(await createCompany("New Corp")).toEqual(row); + expect(chain.insert).toHaveBeenCalledWith({ name: "New Corp" }); + }); + + it("throws on error", async () => { + mockFrom.mockReturnValue(makeChain({ data: null, error: { message: "Duplicate" } })); + await expect(createCompany("New Corp")).rejects.toEqual({ message: "Duplicate" }); + }); +}); + +describe("updateCompany", () => { + beforeEach(() => vi.clearAllMocks()); + + it("updates name and returns row", async () => { + const row = { id: "c1", name: "Updated Corp", created_at: "", updated_at: "" }; + const chain = makeChain({ data: row, error: null }); + mockFrom.mockReturnValue(chain); + expect(await updateCompany({ id: "c1", name: "Updated Corp" })).toEqual(row); + expect(chain.update).toHaveBeenCalledWith({ name: "Updated Corp" }); + expect(chain.eq).toHaveBeenCalledWith("id", "c1"); + }); + + it("throws on error", async () => { + mockFrom.mockReturnValue(makeChain({ data: null, error: { message: "Not found" } })); + await expect(updateCompany({ id: "c1", name: "X" })).rejects.toEqual({ message: "Not found" }); + }); +}); + +describe("deleteCompany", () => { + beforeEach(() => vi.clearAllMocks()); + + it("deletes by id without error", async () => { + const chain = makeChain({ error: null }); + mockFrom.mockReturnValue(chain); + await expect(deleteCompany("c1")).resolves.toBeUndefined(); + expect(chain.eq).toHaveBeenCalledWith("id", "c1"); + }); + + it("throws on error", async () => { + mockFrom.mockReturnValue(makeChain({ error: { message: "FK violation" } })); + await expect(deleteCompany("c1")).rejects.toEqual({ message: "FK violation" }); + }); +}); diff --git a/src/features/companies/services/companies.ts b/src/features/companies/services/companies.ts new file mode 100644 index 0000000..1fa89e8 --- /dev/null +++ b/src/features/companies/services/companies.ts @@ -0,0 +1,44 @@ +import { supabase } from "@/lib/supabase"; +import type { Company } from "@/lib/types"; + +export const fetchCompanies = async (): Promise => { + const { data, error } = await supabase.from("companies").select("*").order("name"); + if (error) throw error; + return data; +}; + +export const fetchCompany = async (id: string): Promise => { + const { data, error } = await supabase + .from("companies") + .select("*") + .eq("id", id) + .single(); + if (error) throw error; + return data; +}; + +export const createCompany = async (name: string): Promise => { + const { data, error } = await supabase + .from("companies") + .insert({ name }) + .select() + .single(); + if (error) throw error; + return data; +}; + +export const updateCompany = async ({ id, name }: { id: string; name: string }): Promise => { + const { data, error } = await supabase + .from("companies") + .update({ name }) + .eq("id", id) + .select() + .single(); + if (error) throw error; + return data; +}; + +export const deleteCompany = async (id: string): Promise => { + const { error } = await supabase.from("companies").delete().eq("id", id); + if (error) throw error; +}; diff --git a/src/features/companies/services/invites.test.ts b/src/features/companies/services/invites.test.ts new file mode 100644 index 0000000..2771bdc --- /dev/null +++ b/src/features/companies/services/invites.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createInvite, validateInvite } from "./invites"; + +const mockRpc = vi.fn(); +vi.mock("@/lib/supabase", () => ({ + supabase: { rpc: (...args: unknown[]) => mockRpc(...args) }, +})); + +describe("createInvite", () => { + beforeEach(() => vi.clearAllMocks()); + + it("calls create_invite RPC with company ids", async () => { + mockRpc.mockResolvedValue({ + data: [{ invite_id: "inv-1", token: "tok-abc" }], + error: null, + }); + const result = await createInvite({ email: "a@x.com", role: "agent", companyIds: ["co1"] }); + expect(result).toEqual({ invite: { id: "inv-1" }, token: "tok-abc" }); + expect(mockRpc).toHaveBeenCalledWith("create_invite", { + p_email: "a@x.com", + p_role: "agent", + p_company_ids: ["co1"], + }); + }); + + it("omits p_company_ids when empty", async () => { + mockRpc.mockResolvedValue({ data: [{ invite_id: "inv-2", token: "tok-xyz" }], error: null }); + await createInvite({ email: "a@x.com", role: "agent", companyIds: [] }); + expect(mockRpc).toHaveBeenCalledWith("create_invite", { p_email: "a@x.com", p_role: "agent" }); + }); + + it("throws when data is empty", async () => { + mockRpc.mockResolvedValue({ data: [], error: null }); + await expect(createInvite({ email: "a@x.com", role: "agent", companyIds: [] })).rejects.toThrow( + "Failed to create invite", + ); + }); + + it("throws on RPC error", async () => { + mockRpc.mockResolvedValue({ data: null, error: { message: "Forbidden" } }); + await expect(createInvite({ email: "a@x.com", role: "agent", companyIds: [] })).rejects.toEqual({ + message: "Forbidden", + }); + }); +}); + +describe("validateInvite", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns email from RPC", async () => { + mockRpc.mockResolvedValue({ data: [{ email: "a@x.com" }], error: null }); + const result = await validateInvite("tok-abc"); + expect(result).toEqual({ email: "a@x.com" }); + expect(mockRpc).toHaveBeenCalledWith("validate_invite", { p_token: "tok-abc" }); + }); + + it("throws when no data returned", async () => { + mockRpc.mockResolvedValue({ data: [], error: null }); + await expect(validateInvite("bad-token")).rejects.toThrow("Invalid or expired invite link"); + }); + + it("throws on RPC error", async () => { + mockRpc.mockResolvedValue({ data: null, error: { message: "DB error" } }); + await expect(validateInvite("tok")).rejects.toEqual({ message: "DB error" }); + }); +}); diff --git a/src/features/companies/services/invites.ts b/src/features/companies/services/invites.ts new file mode 100644 index 0000000..db63bb0 --- /dev/null +++ b/src/features/companies/services/invites.ts @@ -0,0 +1,39 @@ +import { supabase } from "@/lib/supabase"; +import type { AppRole } from "@/lib/types"; + +type CreateInviteParams = { + email: string; + role: AppRole; + companyIds: string[]; +}; + +export const createInvite = async ({ + email, + role, + companyIds, +}: CreateInviteParams): Promise<{ invite: { id: string }; token: string }> => { + const params: { p_email: string; p_role: AppRole; p_company_ids?: string[] } = { + p_email: email, + p_role: role, + }; + if (companyIds.length > 0) { + params.p_company_ids = companyIds; + } + const { data, error } = await supabase.rpc("create_invite", params); + if (error) throw error; + + const row = data?.[0]; + if (!row) throw new Error("Failed to create invite"); + + return { invite: { id: row.invite_id }, token: row.token }; +}; + +export const validateInvite = async (token: string): Promise<{ email: string }> => { + const { data, error } = await supabase.rpc("validate_invite", { p_token: token }); + if (error) throw error; + + const row = data?.[0]; + if (!row) throw new Error("Invalid or expired invite link"); + + return { email: row.email }; +}; diff --git a/src/features/customers/components/CustomerForm.test.tsx b/src/features/customers/components/CustomerForm.test.tsx index cc8f11b..15793c5 100644 --- a/src/features/customers/components/CustomerForm.test.tsx +++ b/src/features/customers/components/CustomerForm.test.tsx @@ -8,12 +8,15 @@ import type { Customer } from "@/lib/types"; const mockCreateMutateAsync = vi.fn(); const mockUpdateMutateAsync = vi.fn(); -vi.mock("../hooks/useCustomers", () => ({ +vi.mock("../hooks/useCreateCustomer", () => ({ useCreateCustomer: () => ({ mutateAsync: mockCreateMutateAsync, isPending: false, error: null, }), +})); + +vi.mock("../hooks/useUpdateCustomer", () => ({ useUpdateCustomer: () => ({ mutateAsync: mockUpdateMutateAsync, isPending: false, diff --git a/src/features/customers/components/CustomerForm.tsx b/src/features/customers/components/CustomerForm.tsx index 81859cd..2e918b1 100644 --- a/src/features/customers/components/CustomerForm.tsx +++ b/src/features/customers/components/CustomerForm.tsx @@ -5,7 +5,8 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { useCreateCustomer, useUpdateCustomer } from "../hooks/useCustomers"; +import { useCreateCustomer } from "../hooks/useCreateCustomer"; +import { useUpdateCustomer } from "../hooks/useUpdateCustomer"; import type { Customer } from "@/lib/types"; const customerSchema = z.object({ diff --git a/src/features/customers/components/CustomerList.test.tsx b/src/features/customers/components/CustomerList.test.tsx index 6e7eeac..ade6745 100644 --- a/src/features/customers/components/CustomerList.test.tsx +++ b/src/features/customers/components/CustomerList.test.tsx @@ -5,12 +5,6 @@ import { renderWithProviders } from "@/test-utils"; import { CustomerList } from "./CustomerList"; import type { Customer } from "@/lib/types"; -// Mock supabase for the inline user_companies query -const mockFrom = vi.fn(); -vi.mock("@/lib/supabase", () => ({ - supabase: { from: (...args: unknown[]) => mockFrom(...args) }, -})); - // Mock AuthProvider vi.mock("@/features/auth/AuthProvider", () => ({ useAuthContext: () => ({ user: { id: "user-1" } }), @@ -20,7 +14,19 @@ vi.mock("@/features/auth/AuthProvider", () => ({ const mockUseCustomers = vi.fn(); vi.mock("../hooks/useCustomers", () => ({ useCustomers: () => mockUseCustomers(), +})); + +// Mock useUserCompany hook +const mockUseUserCompany = vi.fn(); +vi.mock("../hooks/useUserCompany", () => ({ + useUserCompany: () => mockUseUserCompany(), +})); + +// Mock useCreateCustomer and useUpdateCustomer (used by CustomerForm) +vi.mock("../hooks/useCreateCustomer", () => ({ useCreateCustomer: () => ({ mutateAsync: vi.fn(), isPending: false, error: null }), +})); +vi.mock("../hooks/useUpdateCustomer", () => ({ useUpdateCustomer: () => ({ mutateAsync: vi.fn(), isPending: false, error: null }), })); @@ -29,23 +35,12 @@ const CUSTOMERS: Customer[] = [ { id: "c2", name: "Bob Jones", email: "bob@acme.com", company_id: "co1", created_at: "2026-02-01T00:00:00Z" }, ]; -const makeUserCompanyChain = (companyId: string | null) => { - const chain: Record = {}; - chain.select = vi.fn().mockReturnValue(chain); - chain.eq = vi.fn().mockReturnValue(chain); - chain.single = vi.fn().mockResolvedValue({ - data: companyId ? { company_id: companyId } : null, - error: null, - }); - return chain; -}; - describe("CustomerList", () => { beforeEach(() => vi.clearAllMocks()); it("shows loading state", () => { mockUseCustomers.mockReturnValue({ isLoading: true, isError: false, data: undefined, error: null }); - mockFrom.mockReturnValue(makeUserCompanyChain("co1")); + mockUseUserCompany.mockReturnValue({ data: { company_id: "co1" } }); renderWithProviders(); expect(screen.getByText("Loading customers...")).toBeTruthy(); @@ -58,7 +53,7 @@ describe("CustomerList", () => { data: undefined, error: { message: "Permission denied" }, }); - mockFrom.mockReturnValue(makeUserCompanyChain("co1")); + mockUseUserCompany.mockReturnValue({ data: { company_id: "co1" } }); renderWithProviders(); expect(screen.getByText("Permission denied")).toBeTruthy(); @@ -66,7 +61,7 @@ describe("CustomerList", () => { it("shows empty state", async () => { mockUseCustomers.mockReturnValue({ isLoading: false, isError: false, data: [], error: null }); - mockFrom.mockReturnValue(makeUserCompanyChain("co1")); + mockUseUserCompany.mockReturnValue({ data: { company_id: "co1" } }); renderWithProviders(); await waitFor(() => expect(screen.getByText("No customers yet")).toBeTruthy()); @@ -74,7 +69,7 @@ describe("CustomerList", () => { it("renders a row per customer with name and email", async () => { mockUseCustomers.mockReturnValue({ isLoading: false, isError: false, data: CUSTOMERS, error: null }); - mockFrom.mockReturnValue(makeUserCompanyChain("co1")); + mockUseUserCompany.mockReturnValue({ data: { company_id: "co1" } }); renderWithProviders(); await waitFor(() => expect(screen.getByText("Alice Smith")).toBeTruthy()); @@ -85,12 +80,7 @@ describe("CustomerList", () => { it("New Customer button is disabled while companyId is loading", async () => { mockUseCustomers.mockReturnValue({ isLoading: false, isError: false, data: [], error: null }); - // userCompany query returns null (loading / unresolved) - const chain: Record = {}; - chain.select = vi.fn().mockReturnValue(chain); - chain.eq = vi.fn().mockReturnValue(chain); - chain.single = vi.fn().mockReturnValue(new Promise(() => {})); // never resolves - mockFrom.mockReturnValue(chain); + mockUseUserCompany.mockReturnValue({ data: undefined }); renderWithProviders(); await waitFor(() => { @@ -101,7 +91,7 @@ describe("CustomerList", () => { it("New Customer button enabled when companyId resolves, and clicking shows form", async () => { mockUseCustomers.mockReturnValue({ isLoading: false, isError: false, data: CUSTOMERS, error: null }); - mockFrom.mockReturnValue(makeUserCompanyChain("co1")); + mockUseUserCompany.mockReturnValue({ data: { company_id: "co1" } }); renderWithProviders(); const btn = await screen.findByRole("button", { name: "New Customer" }); @@ -112,7 +102,7 @@ describe("CustomerList", () => { it("clicking Edit shows pre-filled form for that customer", async () => { mockUseCustomers.mockReturnValue({ isLoading: false, isError: false, data: CUSTOMERS, error: null }); - mockFrom.mockReturnValue(makeUserCompanyChain("co1")); + mockUseUserCompany.mockReturnValue({ data: { company_id: "co1" } }); renderWithProviders(); await waitFor(() => expect(screen.getByText("Alice Smith")).toBeTruthy()); diff --git a/src/features/customers/components/CustomerList.tsx b/src/features/customers/components/CustomerList.tsx index 4197fa5..186d539 100644 --- a/src/features/customers/components/CustomerList.tsx +++ b/src/features/customers/components/CustomerList.tsx @@ -9,9 +9,8 @@ import { TableRow, } from "@/components/ui/table"; import { useCustomers } from "../hooks/useCustomers"; +import { useUserCompany } from "../hooks/useUserCompany"; import { useAuthContext } from "@/features/auth/AuthProvider"; -import { supabase } from "@/lib/supabase"; -import { useQuery } from "@tanstack/react-query"; import { CustomerForm } from "./CustomerForm"; import type { Customer } from "@/lib/types"; @@ -21,21 +20,8 @@ export const CustomerList = () => { const [showCreate, setShowCreate] = useState(false); const [editingCustomer, setEditingCustomer] = useState(null); - // Get the CM's company ID - const { data: userCompany } = useQuery({ - queryKey: ["user-company", user?.id], - queryFn: async () => { - if (!user) throw new Error("Not authenticated"); - const { data, error } = await supabase - .from("user_companies") - .select("company_id") - .eq("user_id", user.id) - .single(); - if (error) throw error; - return data; - }, - enabled: !!user, - }); + const { data: userCompany } = useUserCompany(user?.id); + const companyId = userCompany?.company_id; if (isLoading) { return

Loading customers...

; @@ -45,8 +31,6 @@ export const CustomerList = () => { return

{error.message}

; } - const companyId = userCompany?.company_id; - return (
diff --git a/src/features/customers/hooks/useCreateCustomer.ts b/src/features/customers/hooks/useCreateCustomer.ts new file mode 100644 index 0000000..5075584 --- /dev/null +++ b/src/features/customers/hooks/useCreateCustomer.ts @@ -0,0 +1,10 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { createCustomer } from "../services/customers"; + +export const useCreateCustomer = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createCustomer, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["customers"] }), + }); +}; diff --git a/src/features/customers/hooks/useCustomers.test.tsx b/src/features/customers/hooks/useCustomers.test.tsx index f62abb7..457b2c5 100644 --- a/src/features/customers/hooks/useCustomers.test.tsx +++ b/src/features/customers/hooks/useCustomers.test.tsx @@ -2,27 +2,21 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { type ReactNode } from "react"; -import { useCustomers, useCreateCustomer, useUpdateCustomer } from "./useCustomers"; - -const mockFrom = vi.fn(); - -vi.mock("@/lib/supabase", () => ({ - supabase: { - from: (...args: unknown[]) => mockFrom(...args), - }, +import { useCustomers } from "./useCustomers"; +import { useCreateCustomer } from "./useCreateCustomer"; +import { useUpdateCustomer } from "./useUpdateCustomer"; + +const mockFetchCustomers = vi.fn(); +const mockCreateCustomer = vi.fn(); +const mockUpdateCustomer = vi.fn(); + +vi.mock("../services/customers", () => ({ + fetchCustomers: () => mockFetchCustomers(), + createCustomer: (args: unknown) => mockCreateCustomer(args), + updateCustomer: (args: unknown) => mockUpdateCustomer(args), + fetchUserCompany: vi.fn(), })); -const makeChain = (terminal: () => Promise) => { - const chain: Record = {}; - chain.select = vi.fn().mockReturnValue(chain); - chain.insert = vi.fn().mockReturnValue(chain); - chain.update = vi.fn().mockReturnValue(chain); - chain.eq = vi.fn().mockReturnValue(chain); - chain.order = vi.fn().mockImplementation(terminal); - chain.single = vi.fn().mockImplementation(terminal); - return chain; -}; - const createWrapper = () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, @@ -39,8 +33,7 @@ describe("useCustomers", () => { const customers = [ { id: "c1", name: "Alice", email: "alice@acme.com", company_id: "co1", created_at: "" }, ]; - const terminal = vi.fn().mockResolvedValue({ data: customers, error: null }); - mockFrom.mockReturnValue(makeChain(terminal)); + mockFetchCustomers.mockResolvedValue(customers); const { result } = renderHook(() => useCustomers(), { wrapper: createWrapper() }); @@ -49,8 +42,7 @@ describe("useCustomers", () => { }); it("propagates query error", async () => { - const terminal = vi.fn().mockResolvedValue({ data: null, error: { message: "Permission denied" } }); - mockFrom.mockReturnValue(makeChain(terminal)); + mockFetchCustomers.mockRejectedValue(new Error("Permission denied")); const { result } = renderHook(() => useCustomers(), { wrapper: createWrapper() }); @@ -61,27 +53,20 @@ describe("useCustomers", () => { describe("useCreateCustomer", () => { beforeEach(() => vi.clearAllMocks()); - it("inserts with correct payload mapping camelCase to snake_case", async () => { + it("calls createCustomer service with args and invalidates cache", async () => { const created = { id: "c1", name: "Bob", email: "bob@acme.com", company_id: "co1", created_at: "" }; - const terminal = vi.fn().mockResolvedValue({ data: created, error: null }); - const insertChain = makeChain(terminal); - mockFrom.mockReturnValue(insertChain); + mockCreateCustomer.mockResolvedValue(created); const { result } = renderHook(() => useCreateCustomer(), { wrapper: createWrapper() }); result.current.mutate({ name: "Bob", email: "bob@acme.com", companyId: "co1" }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(insertChain.insert).toHaveBeenCalledWith({ - name: "Bob", - email: "bob@acme.com", - company_id: "co1", - }); + expect(mockCreateCustomer).toHaveBeenCalledWith({ name: "Bob", email: "bob@acme.com", companyId: "co1" }); }); it("propagates mutation error", async () => { - const terminal = vi.fn().mockResolvedValue({ data: null, error: { message: "Unique violation" } }); - mockFrom.mockReturnValue(makeChain(terminal)); + mockCreateCustomer.mockRejectedValue(new Error("Unique violation")); const { result } = renderHook(() => useCreateCustomer(), { wrapper: createWrapper() }); @@ -94,24 +79,20 @@ describe("useCreateCustomer", () => { describe("useUpdateCustomer", () => { beforeEach(() => vi.clearAllMocks()); - it("updates with name and email only — no company_id in payload", async () => { + it("calls updateCustomer service with args", async () => { const updated = { id: "c1", name: "Alice Updated", email: "a@acme.com", company_id: "co1", created_at: "" }; - const terminal = vi.fn().mockResolvedValue({ data: updated, error: null }); - const updateChain = makeChain(terminal); - mockFrom.mockReturnValue(updateChain); + mockUpdateCustomer.mockResolvedValue(updated); const { result } = renderHook(() => useUpdateCustomer(), { wrapper: createWrapper() }); result.current.mutate({ id: "c1", name: "Alice Updated", email: "a@acme.com" }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(updateChain.update).toHaveBeenCalledWith({ name: "Alice Updated", email: "a@acme.com" }); - expect(updateChain.eq).toHaveBeenCalledWith("id", "c1"); + expect(mockUpdateCustomer).toHaveBeenCalledWith({ id: "c1", name: "Alice Updated", email: "a@acme.com" }); }); it("propagates mutation error", async () => { - const terminal = vi.fn().mockResolvedValue({ data: null, error: { message: "Not found" } }); - mockFrom.mockReturnValue(makeChain(terminal)); + mockUpdateCustomer.mockRejectedValue(new Error("Not found")); const { result } = renderHook(() => useUpdateCustomer(), { wrapper: createWrapper() }); diff --git a/src/features/customers/hooks/useCustomers.ts b/src/features/customers/hooks/useCustomers.ts index ffaae56..71f8ca6 100644 --- a/src/features/customers/hooks/useCustomers.ts +++ b/src/features/customers/hooks/useCustomers.ts @@ -1,79 +1,9 @@ -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { supabase } from "@/lib/supabase"; +import { useQuery } from "@tanstack/react-query"; +import { fetchCustomers } from "../services/customers"; import type { Customer } from "@/lib/types"; -import type { TablesInsert, TablesUpdate } from "@/lib/database.types"; -export const useCustomers = () => { - return useQuery({ +export const useCustomers = () => + useQuery({ queryKey: ["customers"], - queryFn: async () => { - const { data, error } = await supabase - .from("customers") - .select("*") - .order("name"); - if (error) throw error; - return data; - }, + queryFn: fetchCustomers, }); -}; - -export const useCreateCustomer = () => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async ({ - name, - email, - companyId, - }: { - name: string; - email: string; - companyId: string; - }) => { - const payload: TablesInsert<"customers"> = { - name, - email, - company_id: companyId, - }; - const { data, error } = await supabase - .from("customers") - .insert(payload) - .select() - .single(); - if (error) throw error; - return data; - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["customers"] }); - }, - }); -}; - -export const useUpdateCustomer = () => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async ({ - id, - name, - email, - }: { - id: string; - name: string; - email: string; - }) => { - const payload: TablesUpdate<"customers"> = { name, email }; - const { data, error } = await supabase - .from("customers") - .update(payload) - .eq("id", id) - .select() - .single(); - if (error) throw error; - return data; - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["customers"] }); - }, - }); -}; diff --git a/src/features/customers/hooks/useUpdateCustomer.ts b/src/features/customers/hooks/useUpdateCustomer.ts new file mode 100644 index 0000000..2ef8957 --- /dev/null +++ b/src/features/customers/hooks/useUpdateCustomer.ts @@ -0,0 +1,10 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { updateCustomer } from "../services/customers"; + +export const useUpdateCustomer = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: updateCustomer, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["customers"] }), + }); +}; diff --git a/src/features/customers/hooks/useUserCompany.ts b/src/features/customers/hooks/useUserCompany.ts new file mode 100644 index 0000000..08f32fc --- /dev/null +++ b/src/features/customers/hooks/useUserCompany.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetchUserCompany } from "../services/customers"; + +export const useUserCompany = (userId: string | undefined) => + useQuery({ + queryKey: ["user-company", userId], + queryFn: () => { + if (!userId) throw new Error("Not authenticated"); + return fetchUserCompany(userId); + }, + enabled: !!userId, + }); diff --git a/src/features/customers/services/customers.test.ts b/src/features/customers/services/customers.test.ts new file mode 100644 index 0000000..b3db6dc --- /dev/null +++ b/src/features/customers/services/customers.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + fetchCustomers, + createCustomer, + updateCustomer, + fetchUserCompany, +} from "./customers"; + +const mockFrom = vi.fn(); +vi.mock("@/lib/supabase", () => ({ + supabase: { from: (...args: unknown[]) => mockFrom(...args) }, +})); + +const makeChain = (resolve: unknown) => { + const chain: Record = {}; + chain.select = vi.fn().mockReturnValue(chain); + chain.insert = vi.fn().mockReturnValue(chain); + chain.update = vi.fn().mockReturnValue(chain); + chain.eq = vi.fn().mockReturnValue(chain); + chain.order = vi.fn().mockResolvedValue(resolve); + chain.single = vi.fn().mockResolvedValue(resolve); + return chain; +}; + +describe("fetchCustomers", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns ordered customers", async () => { + const rows = [{ id: "c1", name: "Alice", email: "a@x.com", company_id: "co1", created_at: "" }]; + mockFrom.mockReturnValue(makeChain({ data: rows, error: null })); + const result = await fetchCustomers(); + expect(result).toEqual(rows); + }); + + it("throws on error", async () => { + mockFrom.mockReturnValue(makeChain({ data: null, error: { message: "Permission denied" } })); + await expect(fetchCustomers()).rejects.toEqual({ message: "Permission denied" }); + }); +}); + +describe("createCustomer", () => { + beforeEach(() => vi.clearAllMocks()); + + it("inserts with company_id snake_case and returns row", async () => { + const row = { id: "c1", name: "Bob", email: "b@x.com", company_id: "co1", created_at: "" }; + const chain = makeChain({ data: row, error: null }); + mockFrom.mockReturnValue(chain); + + const result = await createCustomer({ name: "Bob", email: "b@x.com", companyId: "co1" }); + expect(result).toEqual(row); + expect(chain.insert).toHaveBeenCalledWith({ name: "Bob", email: "b@x.com", company_id: "co1" }); + }); + + it("throws on error", async () => { + mockFrom.mockReturnValue(makeChain({ data: null, error: { message: "Unique violation" } })); + await expect(createCustomer({ name: "Bob", email: "b@x.com", companyId: "co1" })).rejects.toEqual({ + message: "Unique violation", + }); + }); +}); + +describe("updateCustomer", () => { + beforeEach(() => vi.clearAllMocks()); + + it("updates name and email only — no company_id", async () => { + const row = { id: "c1", name: "Alice Updated", email: "a@x.com", company_id: "co1", created_at: "" }; + const chain = makeChain({ data: row, error: null }); + mockFrom.mockReturnValue(chain); + + const result = await updateCustomer({ id: "c1", name: "Alice Updated", email: "a@x.com" }); + expect(result).toEqual(row); + expect(chain.update).toHaveBeenCalledWith({ name: "Alice Updated", email: "a@x.com" }); + expect(chain.eq).toHaveBeenCalledWith("id", "c1"); + }); + + it("throws on error", async () => { + mockFrom.mockReturnValue(makeChain({ data: null, error: { message: "Not found" } })); + await expect(updateCustomer({ id: "c1", name: "Alice", email: "a@x.com" })).rejects.toEqual({ + message: "Not found", + }); + }); +}); + +describe("fetchUserCompany", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns company_id for user", async () => { + const chain = makeChain({ data: { company_id: "co1" }, error: null }); + mockFrom.mockReturnValue(chain); + + const result = await fetchUserCompany("user-1"); + expect(result).toEqual({ company_id: "co1" }); + expect(chain.eq).toHaveBeenCalledWith("user_id", "user-1"); + }); + + it("throws on error", async () => { + mockFrom.mockReturnValue(makeChain({ data: null, error: { message: "Not found" } })); + await expect(fetchUserCompany("user-1")).rejects.toEqual({ message: "Not found" }); + }); +}); diff --git a/src/features/customers/services/customers.ts b/src/features/customers/services/customers.ts new file mode 100644 index 0000000..85fec76 --- /dev/null +++ b/src/features/customers/services/customers.ts @@ -0,0 +1,63 @@ +import { supabase } from "@/lib/supabase"; +import type { Customer } from "@/lib/types"; +import type { TablesInsert, TablesUpdate } from "@/lib/database.types"; + +export const fetchCustomers = async (): Promise => { + const { data, error } = await supabase.from("customers").select("*").order("name"); + if (error) throw error; + return data; +}; + +export const createCustomer = async ({ + name, + email, + companyId, +}: { + name: string; + email: string; + companyId: string; +}): Promise => { + const payload: TablesInsert<"customers"> = { name, email, company_id: companyId }; + const { data, error } = await supabase + .from("customers") + .insert(payload) + .select() + .single(); + if (error) throw error; + return data; +}; + +export const updateCustomer = async ({ + id, + name, + email, +}: { + id: string; + name: string; + email: string; +}): Promise => { + const payload: TablesUpdate<"customers"> = { name, email }; + const { data, error } = await supabase + .from("customers") + .update(payload) + .eq("id", id) + .select() + .single(); + if (error) throw error; + return data; +}; + +/** + * Returns the company assigned to the given user. + * Intended for customer managers, who are assigned to exactly one company. + * Agents may belong to multiple companies and should not use this function. + */ +export const fetchUserCompany = async (userId: string): Promise<{ company_id: string }> => { + const { data, error } = await supabase + .from("user_companies") + .select("company_id") + .eq("user_id", userId) + .single(); + if (error) throw error; + return data; +};