From c05cc942fba316dfe1db7461accc7e7c3e3f9b3d Mon Sep 17 00:00:00 2001 From: Jerome Date: Tue, 7 Apr 2026 12:11:12 +0200 Subject: [PATCH 01/26] docs: add Phase 1 design spec --- .gitignore | 3 + .../2026-04-07-serverdesk-phase1-design.md | 331 ++++++++++++++++++ 2 files changed, 334 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-07-serverdesk-phase1-design.md diff --git a/.gitignore b/.gitignore index a1512db..9c54d18 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ dist-ssr # Supabase supabase/.temp/ + +# Brainstorm visual companion +.superpowers/ diff --git a/docs/superpowers/specs/2026-04-07-serverdesk-phase1-design.md b/docs/superpowers/specs/2026-04-07-serverdesk-phase1-design.md new file mode 100644 index 0000000..3913ab7 --- /dev/null +++ b/docs/superpowers/specs/2026-04-07-serverdesk-phase1-design.md @@ -0,0 +1,331 @@ +# ServerDesk Phase 1 — Design Spec + +**Goal:** Build the core ServerDesk application: database schema, authentication with invite system, company/agent/customer management, and ticket CRUD with message threads. All scoped by role via Supabase RLS. + +**Phase 2 (later):** Email integration (inbound/outbound via Edge Functions), dashboard with stats, Supabase Realtime subscriptions. + +--- + +## Architecture + +Feature-first module structure. Each feature (`auth`, `tickets`, `companies`, etc.) contains its own components, hooks, and types. Supabase queries live in feature-specific hooks using TanStack Query for caching. Auth state managed via a shared `AuthProvider` context. RLS handles all authorization at the database level. + +**Stack:** React 19, TypeScript (strict), Vite, Tailwind CSS v4, shadcn/ui, Supabase (Auth, Database, RLS), React Router, TanStack Query, zod, react-hook-form, Vitest, Playwright. + +--- + +## Database Schema + +### Enums + +- `app_role`: `admin`, `agent`, `customer_manager` +- `ticket_status`: `open`, `in_progress`, `waiting`, `resolved`, `closed` +- `ticket_priority`: `low`, `medium`, `high`, `urgent` +- `sender_type`: `customer`, `agent`, `system` + +### Tables + +#### `profiles` +| Column | Type | Constraints | +|--------|------|-------------| +| user_id | uuid | PK, FK auth.users ON DELETE CASCADE | +| name | text | NOT NULL | +| role | app_role | NOT NULL | +| created_at | timestamptz | DEFAULT now() | + +#### `companies` +| Column | Type | Constraints | +|--------|------|-------------| +| id | uuid | PK, DEFAULT gen_random_uuid() | +| name | text | NOT NULL | +| created_at | timestamptz | DEFAULT now() | +| updated_at | timestamptz | DEFAULT now() | + +#### `user_companies` +Junction table for both agents and customer managers. Agents can have multiple rows (multiple companies). Customer managers have one row. + +| Column | Type | Constraints | +|--------|------|-------------| +| user_id | uuid | PK, FK auth.users ON DELETE CASCADE | +| company_id | uuid | PK, FK companies ON DELETE CASCADE | +| created_at | timestamptz | DEFAULT now() | + +#### `invites` +| Column | Type | Constraints | +|--------|------|-------------| +| id | uuid | PK, DEFAULT gen_random_uuid() | +| email | text | NOT NULL | +| role | app_role | NOT NULL | +| token | text | UNIQUE, NOT NULL | +| invited_by | uuid | FK auth.users | +| used_at | timestamptz | nullable | +| expires_at | timestamptz | NOT NULL | +| created_at | timestamptz | DEFAULT now() | + +#### `invite_companies` +Junction table linking invites to companies. Required for customer_manager invites (exactly one company). Optional for agent invites (zero or more companies). + +| Column | Type | Constraints | +|--------|------|-------------| +| invite_id | uuid | PK, FK invites ON DELETE CASCADE | +| company_id | uuid | PK, FK companies ON DELETE CASCADE | +| created_at | timestamptz | DEFAULT now() | + +#### `customers` +| Column | Type | Constraints | +|--------|------|-------------| +| id | uuid | PK, DEFAULT gen_random_uuid() | +| email | text | UNIQUE, NOT NULL | +| name | text | NOT NULL | +| company_id | uuid | FK companies ON DELETE CASCADE | +| created_at | timestamptz | DEFAULT now() | + +#### `tickets` +| Column | Type | Constraints | +|--------|------|-------------| +| id | uuid | PK, DEFAULT gen_random_uuid() | +| subject | text | NOT NULL | +| description | text | | +| status | ticket_status | NOT NULL, DEFAULT 'open' | +| priority | ticket_priority | NOT NULL, DEFAULT 'medium' | +| customer_id | uuid | FK customers | +| company_id | uuid | FK companies ON DELETE CASCADE | +| created_by | uuid | nullable, FK auth.users | +| created_at | timestamptz | DEFAULT now() | +| updated_at | timestamptz | DEFAULT now() | + +`created_by` is the app user who created the ticket (null when created via email in Phase 2). + +#### `ticket_messages` +| Column | Type | Constraints | +|--------|------|-------------| +| id | uuid | PK, DEFAULT gen_random_uuid() | +| ticket_id | uuid | FK tickets ON DELETE CASCADE | +| sender_type | sender_type | NOT NULL | +| sender_id | uuid | nullable | +| body | text | NOT NULL | +| created_at | timestamptz | DEFAULT now() | + +`sender_id` references auth.users (for agents/CMs) or customers (for customers). The `sender_type` enum disambiguates. + +### Trigger: auto-create profile on signup + +On `INSERT` into `auth.users`: + +1. Count existing profiles. If zero → create profile with role `admin`, done. +2. Otherwise, look up `invites` by the new user's email WHERE `used_at IS NULL` AND `expires_at > now()`. +3. If no valid invite found → raise exception (block signup). +4. Create profile with role from invite. +5. Copy rows from `invite_companies` into `user_companies` for the new user. +6. Set `used_at = now()` on the invite. + +### RLS Policies + +All tables have RLS enabled. + +**Admin** (role = 'admin'): +- Full SELECT, INSERT, UPDATE, DELETE on all tables. + +**Agent** (role = 'agent'): +- `tickets`: SELECT, UPDATE WHERE `company_id IN (SELECT company_id FROM user_companies WHERE user_id = auth.uid())` +- `ticket_messages`: SELECT, INSERT WHERE ticket's `company_id` is in agent's companies +- `companies`: SELECT WHERE `id IN (SELECT company_id FROM user_companies WHERE user_id = auth.uid())` +- `profiles`: SELECT own profile + +**Customer Manager** (role = 'customer_manager'): +- `tickets`: SELECT, INSERT, UPDATE WHERE `company_id IN (SELECT company_id FROM user_companies WHERE user_id = auth.uid())` +- `ticket_messages`: SELECT, INSERT WHERE ticket's `company_id` is in their company +- `customers`: SELECT, INSERT, UPDATE WHERE `company_id IN (SELECT company_id FROM user_companies WHERE user_id = auth.uid())` +- `companies`: SELECT own company +- `profiles`: SELECT own profile + +--- + +## Auth Flow + +### First user (admin) +1. User visits the app. No users exist. +2. App shows open registration form (email, password, name). +3. On submit → Supabase creates auth.user → trigger creates profile with `admin` role. +4. User is logged in, redirected to dashboard. + +### Invite signup (agent / customer manager) +1. Admin generates an invite link from the Companies page (for CM) or Agents page (for agent). +2. Invited person visits `/signup/:token`. +3. App validates the token (checks it exists, is unused, not expired). +4. Shows signup form pre-filled with email from invite. User enters password and name. +5. On submit → Supabase creates auth.user → trigger creates profile + user_companies from invite. +6. User is logged in, redirected to dashboard. + +### Login +- Standard email/password via `supabase.auth.signInWithPassword()`. +- After login, fetch profile (role) to determine navigation. + +### Logout +- `supabase.auth.signOut()` → redirect to `/login`. + +### Route protection +- `AuthProvider` context: exposes `user`, `profile`, `loading`, `signIn`, `signUp`, `signOut`. +- `ProtectedRoute` component: redirects to `/login` if not authenticated. +- `RoleGuard` component: checks user's role against allowed roles for a route. + +--- + +## Navigation & Pages + +### Per-role navigation + +| Page | Admin | Agent | Customer Manager | +|------|-------|-------|-----------------| +| Dashboard | Stats: companies, agents, tickets | Stats: tickets | Stats: customers, tickets | +| Tickets | All tickets, all filters | Scoped to assigned companies | Scoped to their company | +| Companies | CRUD + invite CM button | — | — | +| Agents | List + invite + assign companies | — | — | +| Customers | — | — | CRUD, scoped to company | + +### Page details + +**Dashboard** (`/`) +- Role-specific stat cards (counts) and ticket status breakdown. +- Phase 1: static counts via Supabase queries. Phase 2: realtime + charts. + +**Tickets** (`/tickets`) +- List view with columns: subject, status, priority, customer, company (admin only), date. +- Filters: status, priority, company (admin only). +- Click row → ticket detail page. + +**Ticket Detail** (`/tickets/:id`) +- Header: subject, status badge, priority badge, customer info, company. +- Actions: change status, change priority (agents and CMs). +- Message thread: chronological list of messages (chat-like). Each message shows sender name, type, timestamp. +- Reply form at bottom (agents and CMs). + +**Companies** (`/companies`, admin only) +- List with company name. +- "New Company" button → form (name only). +- Click row → company detail. +- Company detail: edit name, "Invite Customer Manager" button. +- Invite CM button → dialog: enter email → generates invite link → shows link + copy button. + +**Agents** (`/agents`, admin only) +- List with name, email, assigned companies (as tags/badges). +- "Invite Agent" button → dialog: enter email, optional company multi-select → generates link → copy button. +- Click agent → edit page: assign/remove companies via multi-select. + +**Customers** (`/customers`, customer manager only) +- List with name, email. +- "New Customer" button → form (name, email). Company is auto-set to CM's company. +- Edit customer: update name, email. + +**Login** (`/login`) +- Email + password form. +- If no users exist, redirect to `/signup` (open registration for first admin). + +**Signup** (`/signup/:token`) +- Validates token. Shows error if invalid/expired/used. +- Pre-fills email from invite. Fields: email (read-only), name, password. + +--- + +## Feature Module Structure + +``` +src/features/ +├── auth/ +│ ├── components/ +│ │ ├── LoginForm.tsx +│ │ ├── SignupForm.tsx +│ │ └── RoleGuard.tsx +│ ├── hooks/ +│ │ ├── useAuth.ts # signIn, signUp, signOut +│ │ └── useProfile.ts # fetch current user's profile +│ └── AuthProvider.tsx # context: user, profile, loading +├── tickets/ +│ ├── components/ +│ │ ├── TicketList.tsx +│ │ ├── TicketDetail.tsx +│ │ ├── TicketMessageThread.tsx +│ │ ├── TicketFilters.tsx +│ │ ├── TicketStatusBadge.tsx +│ │ ├── TicketPriorityBadge.tsx +│ │ └── CreateTicketForm.tsx # CM only +│ ├── hooks/ +│ │ ├── useTickets.ts # list with filters +│ │ ├── useTicket.ts # single ticket +│ │ ├── useTicketMessages.ts +│ │ ├── useCreateMessage.ts +│ │ └── useUpdateTicket.ts # status/priority changes +│ └── types.ts +├── companies/ +│ ├── components/ +│ │ ├── CompanyList.tsx +│ │ ├── CompanyForm.tsx +│ │ ├── CompanyDetail.tsx +│ │ └── InviteCMDialog.tsx +│ ├── hooks/ +│ │ ├── useCompanies.ts +│ │ ├── useCompany.ts +│ │ └── useCreateInvite.ts +│ └── types.ts +├── agents/ +│ ├── components/ +│ │ ├── AgentList.tsx +│ │ ├── InviteAgentDialog.tsx +│ │ └── AssignCompaniesDialog.tsx +│ ├── hooks/ +│ │ ├── useAgents.ts +│ │ ├── useAgent.ts +│ │ └── useAgentCompanies.ts # assign/remove +│ └── types.ts +├── customers/ +│ ├── components/ +│ │ ├── CustomerList.tsx +│ │ └── CustomerForm.tsx +│ ├── hooks/ +│ │ └── useCustomers.ts +│ └── types.ts +└── dashboard/ + ├── components/ + │ ├── DashboardStats.tsx + │ └── StatCard.tsx + ├── hooks/ + │ └── useDashboardStats.ts + └── types.ts +``` + +--- + +## Shared Components & Utilities + +- `src/components/ui/` — shadcn primitives (Button, Input, Dialog, Table, Badge, Select, etc.) +- `src/layouts/AppLayout.tsx` — sidebar nav + main content area. Nav items filtered by role. +- `src/layouts/AuthLayout.tsx` — centered card layout for login/signup. +- `src/lib/supabase.ts` — Supabase client (exists). +- `src/lib/types.ts` — shared enums and types (exists, will be extended with generated DB types). + +--- + +## Testing Strategy + +**Unit tests (Vitest):** +- Hooks: test query/mutation logic with mocked Supabase client. +- Components: test rendering, user interactions, form validation. +- Utils: test any helper functions. + +**E2E tests (Playwright):** +- Full auth flow: first admin signup, invite agent, agent signup, login/logout. +- Ticket CRUD: create ticket (as CM), view list, open detail, reply, change status. +- Company management: create company, invite CM. +- Agent management: invite agent, assign companies. +- Role scoping: verify agent can't see admin pages, CM can't see other companies' data. + +--- + +## Out of Scope (Phase 2) + +- Email inbound/outbound (Edge Functions + webhook) +- Supabase Realtime subscriptions (live updates) +- Dashboard charts and analytics +- File attachments on tickets +- Notification system +- Search functionality From 099dfd013e77907d3c30697973390c02d36b6833 Mon Sep 17 00:00:00 2001 From: Jerome Date: Tue, 7 Apr 2026 12:22:55 +0200 Subject: [PATCH 02/26] docs: add Phase 1 implementation plan (15 tasks) --- .../plans/2026-04-07-phase1-implementation.md | 4102 +++++++++++++++++ 1 file changed, 4102 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-07-phase1-implementation.md diff --git a/docs/superpowers/plans/2026-04-07-phase1-implementation.md b/docs/superpowers/plans/2026-04-07-phase1-implementation.md new file mode 100644 index 0000000..71a78e2 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-phase1-implementation.md @@ -0,0 +1,4102 @@ +# ServerDesk Phase 1 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:** Build the core ServerDesk application with database schema, authentication with invite system, company/agent/customer management, and ticket CRUD with message threads — all scoped by role via Supabase RLS. + +**Architecture:** Feature-first React SPA. Each feature module contains its own components, hooks, and types. Supabase handles auth and database with RLS for authorization. TanStack Query manages server state. Auth state via React Context. + +**Tech Stack:** React 19, TypeScript (strict), Vite, Tailwind CSS v4, shadcn/ui, Supabase, React Router, TanStack Query, zod, react-hook-form, Vitest, Playwright + +**Spec:** `docs/superpowers/specs/2026-04-07-serverdesk-phase1-design.md` + +--- + +## File Structure + +### New files to create + +``` +supabase/migrations/ +├── 00001_create_enums.sql +├── 00002_create_tables.sql +├── 00003_create_rls_policies.sql +└── 00004_create_trigger.sql + +src/features/auth/ +├── AuthProvider.tsx +├── components/ +│ ├── LoginForm.tsx +│ ├── SignupForm.tsx +│ ├── ProtectedRoute.tsx +│ └── RoleGuard.tsx +└── hooks/ + ├── useAuth.ts + └── useProfile.ts + +src/features/companies/ +├── components/ +│ ├── CompanyList.tsx +│ ├── CompanyForm.tsx +│ ├── CompanyDetail.tsx +│ └── InviteCMDialog.tsx +├── hooks/ +│ ├── useCompanies.ts +│ ├── useCompany.ts +│ └── useCreateInvite.ts +└── types.ts + +src/features/agents/ +├── components/ +│ ├── AgentList.tsx +│ ├── InviteAgentDialog.tsx +│ └── AssignCompaniesDialog.tsx +├── hooks/ +│ ├── useAgents.ts +│ ├── useAgent.ts +│ └── useAgentCompanies.ts +└── types.ts + +src/features/customers/ +├── components/ +│ ├── CustomerList.tsx +│ └── CustomerForm.tsx +├── hooks/ +│ └── useCustomers.ts +└── types.ts + +src/features/tickets/ +├── components/ +│ ├── TicketList.tsx +│ ├── TicketDetail.tsx +│ ├── TicketMessageThread.tsx +│ ├── TicketFilters.tsx +│ ├── TicketStatusBadge.tsx +│ ├── TicketPriorityBadge.tsx +│ └── CreateTicketForm.tsx +├── hooks/ +│ ├── useTickets.ts +│ ├── useTicket.ts +│ ├── useTicketMessages.ts +│ ├── useCreateMessage.ts +│ └── useUpdateTicket.ts +└── types.ts + +src/features/dashboard/ +├── components/ +│ ├── DashboardStats.tsx +│ └── StatCard.tsx +├── hooks/ +│ └── useDashboardStats.ts +└── types.ts +``` + +### Existing files to modify + +``` +src/App.tsx — wrap with AuthProvider +src/routes/index.tsx — real routes with ProtectedRoute/RoleGuard +src/layouts/AppLayout.tsx — role-based sidebar navigation +src/lib/supabase.ts — add typed client with Database generic +src/lib/types.ts — update with generated DB types +``` + +--- + +## Phase A: Database Foundation + +### Task 1: Create database enums and tables migration + +**Files:** +- Create: `supabase/migrations/00001_create_enums_and_tables.sql` + +- [ ] **Step 1: Write the migration SQL** + +Create `supabase/migrations/00001_create_enums_and_tables.sql`: + +```sql +-- Enums +CREATE TYPE app_role AS ENUM ('admin', 'agent', 'customer_manager'); +CREATE TYPE ticket_status AS ENUM ('open', 'in_progress', 'waiting', 'resolved', 'closed'); +CREATE TYPE ticket_priority AS ENUM ('low', 'medium', 'high', 'urgent'); +CREATE TYPE sender_type AS ENUM ('customer', 'agent', 'system'); + +-- Profiles (one per auth.user) +CREATE TABLE profiles ( + user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + name text NOT NULL, + role app_role NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Companies +CREATE TABLE companies ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- User-company assignments (agents + customer managers) +CREATE TABLE user_companies ( + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + company_id uuid NOT NULL REFERENCES companies(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, company_id) +); + +-- Invites +CREATE TABLE invites ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email text NOT NULL, + role app_role NOT NULL, + token text UNIQUE NOT NULL, + invited_by uuid NOT NULL REFERENCES auth.users(id), + used_at timestamptz, + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Invite-company assignments (which companies the invite grants) +CREATE TABLE invite_companies ( + invite_id uuid NOT NULL REFERENCES invites(id) ON DELETE CASCADE, + company_id uuid NOT NULL REFERENCES companies(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (invite_id, company_id) +); + +-- Customers (managed by customer managers) +CREATE TABLE customers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email text UNIQUE NOT NULL, + name text NOT NULL, + company_id uuid NOT NULL REFERENCES companies(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Tickets +CREATE TABLE tickets ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + subject text NOT NULL, + description text, + status ticket_status NOT NULL DEFAULT 'open', + priority ticket_priority NOT NULL DEFAULT 'medium', + customer_id uuid NOT NULL REFERENCES customers(id), + company_id uuid NOT NULL REFERENCES companies(id) ON DELETE CASCADE, + created_by uuid REFERENCES auth.users(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- Ticket messages +CREATE TABLE ticket_messages ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + ticket_id uuid NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, + sender_type sender_type NOT NULL, + sender_id uuid, + body text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Indexes for common queries +CREATE INDEX idx_user_companies_user ON user_companies(user_id); +CREATE INDEX idx_user_companies_company ON user_companies(company_id); +CREATE INDEX idx_tickets_company ON tickets(company_id); +CREATE INDEX idx_tickets_status ON tickets(status); +CREATE INDEX idx_tickets_customer ON tickets(customer_id); +CREATE INDEX idx_ticket_messages_ticket ON ticket_messages(ticket_id); +CREATE INDEX idx_customers_company ON customers(company_id); +CREATE INDEX idx_invites_token ON invites(token); +CREATE INDEX idx_invites_email ON invites(email); +``` + +- [ ] **Step 2: Verify migration applies** + +Start Supabase local (if not running) and apply: + +```bash +npx supabase start +npx supabase db reset +``` + +Expected: Migration applies without errors. Check Supabase Studio at http://127.0.0.1:54323 to verify tables exist. + +- [ ] **Step 3: Commit** + +```bash +git add supabase/migrations/00001_create_enums_and_tables.sql +git commit -m "feat(db): create enums and tables for ServerDesk schema" +``` + +--- + +### Task 2: Create RLS policies + +**Files:** +- Create: `supabase/migrations/00002_create_rls_policies.sql` + +- [ ] **Step 1: Write RLS policies** + +Create `supabase/migrations/00002_create_rls_policies.sql`: + +```sql +-- Enable RLS on all tables +ALTER TABLE profiles ENABLE ROW LEVEL SECURITY; +ALTER TABLE companies ENABLE ROW LEVEL SECURITY; +ALTER TABLE user_companies ENABLE ROW LEVEL SECURITY; +ALTER TABLE invites ENABLE ROW LEVEL SECURITY; +ALTER TABLE invite_companies ENABLE ROW LEVEL SECURITY; +ALTER TABLE customers ENABLE ROW LEVEL SECURITY; +ALTER TABLE tickets ENABLE ROW LEVEL SECURITY; +ALTER TABLE ticket_messages ENABLE ROW LEVEL SECURITY; + +-- Helper: get current user's role +CREATE OR REPLACE FUNCTION auth.user_role() +RETURNS app_role +LANGUAGE sql +STABLE +SECURITY DEFINER +AS $$ + SELECT role FROM profiles WHERE user_id = auth.uid() +$$; + +-- Helper: get current user's company IDs +CREATE OR REPLACE FUNCTION auth.user_company_ids() +RETURNS SETOF uuid +LANGUAGE sql +STABLE +SECURITY DEFINER +AS $$ + SELECT company_id FROM user_companies WHERE user_id = auth.uid() +$$; + +-- ================== +-- PROFILES +-- ================== + +-- Users can read their own profile +CREATE POLICY profiles_select_own ON profiles + FOR SELECT USING (user_id = auth.uid()); + +-- Admin can read all profiles +CREATE POLICY profiles_select_admin ON profiles + FOR SELECT USING (auth.user_role() = 'admin'); + +-- Admin can update any profile +CREATE POLICY profiles_update_admin ON profiles + FOR UPDATE USING (auth.user_role() = 'admin'); + +-- Users can update their own profile (name only, not role) +CREATE POLICY profiles_update_own ON profiles + FOR UPDATE USING (user_id = auth.uid()); + +-- ================== +-- COMPANIES +-- ================== + +-- Admin: full CRUD +CREATE POLICY companies_admin ON companies + FOR ALL USING (auth.user_role() = 'admin'); + +-- Agent: read own companies +CREATE POLICY companies_select_agent ON companies + FOR SELECT USING ( + auth.user_role() = 'agent' + AND id IN (SELECT auth.user_company_ids()) + ); + +-- Customer manager: read own company +CREATE POLICY companies_select_cm ON companies + FOR SELECT USING ( + auth.user_role() = 'customer_manager' + AND id IN (SELECT auth.user_company_ids()) + ); + +-- ================== +-- USER_COMPANIES +-- ================== + +-- Admin: full access +CREATE POLICY user_companies_admin ON user_companies + FOR ALL USING (auth.user_role() = 'admin'); + +-- Users can read their own assignments +CREATE POLICY user_companies_select_own ON user_companies + FOR SELECT USING (user_id = auth.uid()); + +-- ================== +-- INVITES +-- ================== + +-- Admin: full access +CREATE POLICY invites_admin ON invites + FOR ALL USING (auth.user_role() = 'admin'); + +-- Anyone can read invite by token (for signup validation) +-- Using a permissive policy for anonymous access during signup +CREATE POLICY invites_select_by_token ON invites + FOR SELECT USING (true); + +-- ================== +-- INVITE_COMPANIES +-- ================== + +-- Admin: full access +CREATE POLICY invite_companies_admin ON invite_companies + FOR ALL USING (auth.user_role() = 'admin'); + +-- Readable during signup (linked to invite) +CREATE POLICY invite_companies_select ON invite_companies + FOR SELECT USING (true); + +-- ================== +-- CUSTOMERS +-- ================== + +-- Admin: full access +CREATE POLICY customers_admin ON customers + FOR ALL USING (auth.user_role() = 'admin'); + +-- Customer manager: CRUD on own company's customers +CREATE POLICY customers_select_cm ON customers + FOR SELECT USING ( + auth.user_role() = 'customer_manager' + AND company_id IN (SELECT auth.user_company_ids()) + ); + +CREATE POLICY customers_insert_cm ON customers + FOR INSERT WITH CHECK ( + auth.user_role() = 'customer_manager' + AND company_id IN (SELECT auth.user_company_ids()) + ); + +CREATE POLICY customers_update_cm ON customers + FOR UPDATE USING ( + auth.user_role() = 'customer_manager' + AND company_id IN (SELECT auth.user_company_ids()) + ); + +-- ================== +-- TICKETS +-- ================== + +-- Admin: full access +CREATE POLICY tickets_admin ON tickets + FOR ALL USING (auth.user_role() = 'admin'); + +-- Agent: read/update tickets in their companies +CREATE POLICY tickets_select_agent ON tickets + FOR SELECT USING ( + auth.user_role() = 'agent' + AND company_id IN (SELECT auth.user_company_ids()) + ); + +CREATE POLICY tickets_update_agent ON tickets + FOR UPDATE USING ( + auth.user_role() = 'agent' + AND company_id IN (SELECT auth.user_company_ids()) + ); + +-- Customer manager: read/insert/update tickets in their company +CREATE POLICY tickets_select_cm ON tickets + FOR SELECT USING ( + auth.user_role() = 'customer_manager' + AND company_id IN (SELECT auth.user_company_ids()) + ); + +CREATE POLICY tickets_insert_cm ON tickets + FOR INSERT WITH CHECK ( + auth.user_role() = 'customer_manager' + AND company_id IN (SELECT auth.user_company_ids()) + ); + +CREATE POLICY tickets_update_cm ON tickets + FOR UPDATE USING ( + auth.user_role() = 'customer_manager' + AND company_id IN (SELECT auth.user_company_ids()) + ); + +-- ================== +-- TICKET_MESSAGES +-- ================== + +-- Admin: full access +CREATE POLICY ticket_messages_admin ON ticket_messages + FOR ALL USING (auth.user_role() = 'admin'); + +-- Agent: read/insert messages for tickets in their companies +CREATE POLICY ticket_messages_select_agent ON ticket_messages + FOR SELECT USING ( + auth.user_role() = 'agent' + AND ticket_id IN ( + SELECT id FROM tickets WHERE company_id IN (SELECT auth.user_company_ids()) + ) + ); + +CREATE POLICY ticket_messages_insert_agent ON ticket_messages + FOR INSERT WITH CHECK ( + auth.user_role() = 'agent' + AND ticket_id IN ( + SELECT id FROM tickets WHERE company_id IN (SELECT auth.user_company_ids()) + ) + ); + +-- Customer manager: read/insert messages for tickets in their company +CREATE POLICY ticket_messages_select_cm ON ticket_messages + FOR SELECT USING ( + auth.user_role() = 'customer_manager' + AND ticket_id IN ( + SELECT id FROM tickets WHERE company_id IN (SELECT auth.user_company_ids()) + ) + ); + +CREATE POLICY ticket_messages_insert_cm ON ticket_messages + FOR INSERT WITH CHECK ( + auth.user_role() = 'customer_manager' + AND ticket_id IN ( + SELECT id FROM tickets WHERE company_id IN (SELECT auth.user_company_ids()) + ) + ); +``` + +- [ ] **Step 2: Apply and verify** + +```bash +npx supabase db reset +``` + +Expected: Both migrations apply without errors. + +- [ ] **Step 3: Commit** + +```bash +git add supabase/migrations/00002_create_rls_policies.sql +git commit -m "feat(db): add RLS policies for all tables" +``` + +--- + +### Task 3: Create signup trigger + +**Files:** +- Create: `supabase/migrations/00003_create_signup_trigger.sql` + +- [ ] **Step 1: Write the trigger function** + +Create `supabase/migrations/00003_create_signup_trigger.sql`: + +```sql +-- Trigger function: auto-create profile on signup +CREATE OR REPLACE FUNCTION handle_new_user() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + _profile_count int; + _invite record; + _user_name text; +BEGIN + -- Get the name from user metadata (passed during signup) + _user_name := COALESCE( + NEW.raw_user_meta_data ->> 'name', + split_part(NEW.email, '@', 1) + ); + + -- Check if this is the first user + SELECT count(*) INTO _profile_count FROM profiles; + + IF _profile_count = 0 THEN + -- First user becomes admin + INSERT INTO profiles (user_id, name, role) + VALUES (NEW.id, _user_name, 'admin'); + RETURN NEW; + END IF; + + -- Look up valid invite + SELECT * INTO _invite + FROM invites + WHERE email = NEW.email + AND used_at IS NULL + AND expires_at > now() + ORDER BY created_at DESC + LIMIT 1; + + IF _invite IS NULL THEN + RAISE EXCEPTION 'No valid invite found for %', NEW.email; + END IF; + + -- Create profile with role from invite + INSERT INTO profiles (user_id, name, role) + VALUES (NEW.id, _user_name, _invite.role); + + -- Copy invite_companies to user_companies + INSERT INTO user_companies (user_id, company_id) + SELECT NEW.id, ic.company_id + FROM invite_companies ic + WHERE ic.invite_id = _invite.id; + + -- Mark invite as used + UPDATE invites SET used_at = now() WHERE id = _invite.id; + + RETURN NEW; +END; +$$; + +-- Attach trigger to auth.users +CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW + EXECUTE FUNCTION handle_new_user(); +``` + +- [ ] **Step 2: Apply and verify** + +```bash +npx supabase db reset +``` + +Expected: All 3 migrations apply without errors. + +- [ ] **Step 3: Commit** + +```bash +git add supabase/migrations/00003_create_signup_trigger.sql +git commit -m "feat(db): add signup trigger for auto-profile creation" +``` + +--- + +### Task 4: Generate TypeScript types from database + +**Files:** +- Create: `src/lib/database.types.ts` +- Modify: `src/lib/supabase.ts` +- Modify: `src/lib/types.ts` + +- [ ] **Step 1: Generate types** + +```bash +npx supabase gen types typescript --local > src/lib/database.types.ts +``` + +- [ ] **Step 2: Update Supabase client with typed client** + +Replace `src/lib/supabase.ts`: + +```ts +import { createClient } from "@supabase/supabase-js"; +import type { Database } from "./database.types"; + +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; +const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; + +if (!supabaseUrl || !supabaseAnonKey) { + throw new Error("Missing Supabase environment variables"); +} + +export const supabase = createClient(supabaseUrl, supabaseAnonKey); +``` + +- [ ] **Step 3: Update shared types** + +Replace `src/lib/types.ts`: + +```ts +import type { Database } from "./database.types"; + +// Database row types +export type Profile = Database["public"]["Tables"]["profiles"]["Row"]; +export type Company = Database["public"]["Tables"]["companies"]["Row"]; +export type UserCompany = Database["public"]["Tables"]["user_companies"]["Row"]; +export type Invite = Database["public"]["Tables"]["invites"]["Row"]; +export type InviteCompany = Database["public"]["Tables"]["invite_companies"]["Row"]; +export type Customer = Database["public"]["Tables"]["customers"]["Row"]; +export type Ticket = Database["public"]["Tables"]["tickets"]["Row"]; +export type TicketMessage = Database["public"]["Tables"]["ticket_messages"]["Row"]; + +// Enum types +export type AppRole = Database["public"]["Enums"]["app_role"]; +export type TicketStatus = Database["public"]["Enums"]["ticket_status"]; +export type TicketPriority = Database["public"]["Enums"]["ticket_priority"]; +export type SenderType = Database["public"]["Enums"]["sender_type"]; +``` + +- [ ] **Step 4: Verify build** + +```bash +npm run build +``` + +Expected: Build succeeds with no type errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/database.types.ts src/lib/supabase.ts src/lib/types.ts +git commit -m "feat(db): generate TypeScript types from database schema" +``` + +--- + +## Phase B: Authentication + +### Task 5: AuthProvider and auth hooks + +**Files:** +- Create: `src/features/auth/hooks/useAuth.ts` +- Create: `src/features/auth/hooks/useProfile.ts` +- Create: `src/features/auth/AuthProvider.tsx` + +- [ ] **Step 1: Create useAuth hook** + +Create `src/features/auth/hooks/useAuth.ts`: + +```ts +import { supabase } from "@/lib/supabase"; + +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, + ) => { + const { error } = await supabase.auth.signUp({ + email, + password, + options: { + data: { name }, + }, + }); + if (error) throw error; + }; + + const signOut = async () => { + const { error } = await supabase.auth.signOut(); + if (error) throw error; + }; + + return { signIn, signUp, signOut }; +}; +``` + +- [ ] **Step 2: Create useProfile hook** + +Create `src/features/auth/hooks/useProfile.ts`: + +```ts +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/lib/supabase"; +import type { Profile } from "@/lib/types"; + +export const useProfile = (userId: string | undefined) => { + return useQuery({ + queryKey: ["profile", userId], + queryFn: async () => { + if (!userId) return null; + const { data, error } = await supabase + .from("profiles") + .select("*") + .eq("user_id", userId) + .single(); + if (error) throw error; + return data; + }, + enabled: !!userId, + }); +}; +``` + +- [ ] **Step 3: Create AuthProvider** + +Create `src/features/auth/AuthProvider.tsx`: + +```tsx +import { + createContext, + useContext, + useEffect, + useState, + type ReactNode, +} from "react"; +import type { User, Session } from "@supabase/supabase-js"; +import { supabase } from "@/lib/supabase"; +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, +}); + +export const useAuthContext = () => useContext(AuthContext); + +export const AuthProvider = ({ children }: { children: ReactNode }) => { + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + supabase.auth.getSession().then(({ data: { session } }) => { + setSession(session); + setLoading(false); + }); + + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange((_event, session) => { + setSession(session); + }); + + return () => subscription.unsubscribe(); + }, []); + + const user = session?.user ?? null; + const { data: profile } = useProfile(user?.id); + + return ( + + {children} + + ); +}; +``` + +- [ ] **Step 4: Verify build** + +```bash +npm run build +``` + +Expected: Build succeeds. The AuthProvider is not wired into the app yet — that happens in Task 7. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/auth/ +git commit -m "feat(auth): add AuthProvider, useAuth, and useProfile hooks" +``` + +--- + +### Task 6: Login and Signup pages + +**Files:** +- Create: `src/features/auth/components/LoginForm.tsx` +- Create: `src/features/auth/components/SignupForm.tsx` + +- [ ] **Step 1: Install required shadcn components** + +```bash +npx shadcn@latest add input label card separator -y +``` + +- [ ] **Step 2: Create LoginForm** + +Create `src/features/auth/components/LoginForm.tsx`: + +```tsx +import { useState } from "react"; +import { useNavigate } from "react-router"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod/v4"; +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 { useAuth } from "../hooks/useAuth"; + +const loginSchema = z.object({ + email: z.email(), + password: z.string().min(6, "Password must be at least 6 characters"), +}); + +type LoginValues = z.infer; + +export const LoginForm = () => { + const navigate = useNavigate(); + const { signIn } = useAuth(); + const [error, setError] = useState(null); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(loginSchema), + }); + + const onSubmit = async (values: LoginValues) => { + try { + setError(null); + await signIn(values.email, values.password); + navigate("/"); + } catch (err) { + setError(err instanceof Error ? err.message : "Login failed"); + } + }; + + return ( + + + Sign in to ServerDesk + + +
+ {error && ( +
+ {error} +
+ )} +
+ + + {errors.email && ( +

{errors.email.message}

+ )} +
+
+ + + {errors.password && ( +

+ {errors.password.message} +

+ )} +
+ +
+
+
+ ); +}; +``` + +- [ ] **Step 3: Create SignupForm** + +Create `src/features/auth/components/SignupForm.tsx`: + +```tsx +import { useState, useEffect } from "react"; +import { useNavigate, useParams } from "react-router"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod/v4"; +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 { useAuth } from "../hooks/useAuth"; + +const signupSchema = z.object({ + email: z.email(), + name: z.string().min(1, "Name is required"), + password: z.string().min(6, "Password must be at least 6 characters"), +}); + +type SignupValues = z.infer; + +export const SignupForm = () => { + const navigate = useNavigate(); + const { token } = useParams<{ token?: string }>(); + const { signUp } = useAuth(); + const [error, setError] = useState(null); + const [inviteEmail, setInviteEmail] = useState(null); + const [validating, setValidating] = useState(!!token); + + const { + register, + handleSubmit, + setValue, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(signupSchema), + }); + + useEffect(() => { + if (!token) { + setValidating(false); + return; + } + + const validateToken = async () => { + const { data, error } = await supabase + .from("invites") + .select("email, used_at, expires_at") + .eq("token", token) + .single(); + + if (error || !data) { + setError("Invalid invite link"); + setValidating(false); + return; + } + + if (data.used_at) { + setError("This invite has already been used"); + setValidating(false); + return; + } + + if (new Date(data.expires_at) < new Date()) { + setError("This invite has expired"); + setValidating(false); + return; + } + + setInviteEmail(data.email); + setValue("email", data.email); + setValidating(false); + }; + + validateToken(); + }, [token, setValue]); + + const onSubmit = async (values: SignupValues) => { + try { + setError(null); + await signUp(values.email, values.password, values.name); + navigate("/"); + } catch (err) { + setError(err instanceof Error ? err.message : "Signup failed"); + } + }; + + if (validating) { + return ( + + +

+ Validating invite... +

+
+
+ ); + } + + if (error && !inviteEmail && token) { + return ( + + +

{error}

+
+
+ ); + } + + return ( + + + + {token ? "Complete your registration" : "Create admin account"} + + + +
+ {error && ( +
+ {error} +
+ )} +
+ + + {errors.email && ( +

{errors.email.message}

+ )} +
+
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+
+ + + {errors.password && ( +

+ {errors.password.message} +

+ )} +
+ +
+
+
+ ); +}; +``` + +- [ ] **Step 4: Verify build** + +```bash +npm run build +``` + +Expected: Build succeeds. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/auth/components/ src/components/ui/ +git commit -m "feat(auth): add LoginForm and SignupForm components" +``` + +--- + +### Task 7: Route protection and app wiring + +**Files:** +- Create: `src/features/auth/components/ProtectedRoute.tsx` +- Create: `src/features/auth/components/RoleGuard.tsx` +- Modify: `src/routes/index.tsx` +- Modify: `src/App.tsx` +- Modify: `src/layouts/AppLayout.tsx` + +- [ ] **Step 1: Create ProtectedRoute** + +Create `src/features/auth/components/ProtectedRoute.tsx`: + +```tsx +import { Navigate, Outlet } from "react-router"; +import { useAuthContext } from "../AuthProvider"; + +export const ProtectedRoute = () => { + const { user, loading } = useAuthContext(); + + if (loading) { + return ( +
+

Loading...

+
+ ); + } + + if (!user) { + return ; + } + + return ; +}; +``` + +- [ ] **Step 2: Create RoleGuard** + +Create `src/features/auth/components/RoleGuard.tsx`: + +```tsx +import { Navigate, Outlet } from "react-router"; +import { useAuthContext } from "../AuthProvider"; +import type { AppRole } from "@/lib/types"; + +type RoleGuardProps = { + allowedRoles: AppRole[]; +}; + +export const RoleGuard = ({ allowedRoles }: RoleGuardProps) => { + const { profile, loading } = useAuthContext(); + + if (loading || !profile) { + return null; + } + + if (!allowedRoles.includes(profile.role)) { + return ; + } + + return ; +}; +``` + +- [ ] **Step 3: Update AppLayout with role-based navigation** + +Replace `src/layouts/AppLayout.tsx`: + +```tsx +import { NavLink, Outlet } from "react-router"; +import { useAuthContext } from "@/features/auth/AuthProvider"; +import { useAuth } from "@/features/auth/hooks/useAuth"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +const navItems = [ + { to: "/", label: "Dashboard", roles: ["admin", "agent", "customer_manager"] }, + { to: "/tickets", label: "Tickets", roles: ["admin", "agent", "customer_manager"] }, + { to: "/companies", label: "Companies", roles: ["admin"] }, + { to: "/agents", label: "Agents", roles: ["admin"] }, + { to: "/customers", label: "Customers", roles: ["customer_manager"] }, +] as const; + +export const AppLayout = () => { + const { profile, user } = useAuthContext(); + const { signOut } = useAuth(); + + const visibleItems = navItems.filter( + (item) => profile && (item.roles as readonly string[]).includes(profile.role), + ); + + return ( +
+ +
+ +
+
+ ); +}; +``` + +- [ ] **Step 4: Update routes** + +Replace `src/routes/index.tsx`: + +```tsx +import { createBrowserRouter } from "react-router"; +import { AuthLayout } from "@/layouts/AuthLayout"; +import { AppLayout } from "@/layouts/AppLayout"; +import { ProtectedRoute } from "@/features/auth/components/ProtectedRoute"; +import { RoleGuard } from "@/features/auth/components/RoleGuard"; +import { LoginForm } from "@/features/auth/components/LoginForm"; +import { SignupForm } from "@/features/auth/components/SignupForm"; + +export const router = createBrowserRouter([ + { + element: , + children: [ + { path: "/login", element: }, + { path: "/signup", element: }, + { path: "/signup/:token", element: }, + ], + }, + { + element: , + children: [ + { + element: , + children: [ + { path: "/", element:
Dashboard — coming soon
}, + { + path: "/tickets", + element:
Tickets — coming soon
, + }, + { + element: , + children: [ + { + path: "/companies", + element:
Companies — coming soon
, + }, + { + path: "/agents", + element:
Agents — coming soon
, + }, + ], + }, + { + element: , + children: [ + { + path: "/customers", + element:
Customers — coming soon
, + }, + ], + }, + ], + }, + ], + }, +]); +``` + +- [ ] **Step 5: Wrap App with AuthProvider** + +Replace `src/App.tsx`: + +```tsx +import { RouterProvider } from "react-router"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { AuthProvider } from "@/features/auth/AuthProvider"; +import { router } from "@/routes"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60, + retry: 1, + }, + }, +}); + +export const App = () => { + return ( + + + + + + ); +}; +``` + +- [ ] **Step 6: Verify build** + +```bash +npm run build +``` + +Expected: Build succeeds. + +- [ ] **Step 7: Commit** + +```bash +git add src/features/auth/components/ src/routes/index.tsx src/App.tsx src/layouts/AppLayout.tsx +git commit -m "feat(auth): add route protection, role guards, and app wiring" +``` + +--- + +## Phase C: Admin Features + +### Task 8: Companies — hooks and list page + +**Files:** +- Create: `src/features/companies/types.ts` +- Create: `src/features/companies/hooks/useCompanies.ts` +- Create: `src/features/companies/hooks/useCompany.ts` +- Create: `src/features/companies/components/CompanyList.tsx` +- Modify: `src/routes/index.tsx` + +- [ ] **Step 1: Install shadcn table and dialog components** + +```bash +npx shadcn@latest add table dialog badge textarea select -y +``` + +- [ ] **Step 2: Create company types** + +Create `src/features/companies/types.ts`: + +```ts +import type { Company } from "@/lib/types"; + +export type CompanyWithCounts = Company & { + customer_count: number; + ticket_count: number; +}; +``` + +- [ ] **Step 3: Create useCompanies hook** + +Create `src/features/companies/hooks/useCompanies.ts`: + +```ts +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { supabase } from "@/lib/supabase"; +import type { Company } from "@/lib/types"; + +export const useCompanies = () => { + return useQuery({ + queryKey: ["companies"], + queryFn: async () => { + const { data, error } = await supabase + .from("companies") + .select("*") + .order("name"); + if (error) throw error; + return data; + }, + }); +}; + +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, updated_at: new Date().toISOString() }) + .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"] }); + }, + }); +}; +``` + +- [ ] **Step 4: Create useCompany hook** + +Create `src/features/companies/hooks/useCompany.ts`: + +```ts +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/lib/supabase"; +import type { Company } from "@/lib/types"; + +export const useCompany = (id: string | undefined) => { + return useQuery({ + queryKey: ["company", id], + queryFn: async () => { + if (!id) return null; + const { data, error } = await supabase + .from("companies") + .select("*") + .eq("id", id) + .single(); + if (error) throw error; + return data; + }, + enabled: !!id, + }); +}; +``` + +- [ ] **Step 5: Create CompanyList component** + +Create `src/features/companies/components/CompanyList.tsx`: + +```tsx +import { useState } from "react"; +import { Link } from "react-router"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useCompanies } from "../hooks/useCompanies"; +import { CompanyForm } from "./CompanyForm"; + +export const CompanyList = () => { + const { data: companies, isLoading } = useCompanies(); + const [showCreate, setShowCreate] = useState(false); + + if (isLoading) { + return

Loading companies...

; + } + + return ( +
+
+

Companies

+ +
+ + {showCreate && ( + setShowCreate(false)} /> + )} + + + + + Name + Created + + + + + {companies?.map((company) => ( + + {company.name} + + {new Date(company.created_at).toLocaleDateString()} + + + + + + + + ))} + {companies?.length === 0 && ( + + + No companies yet + + + )} + +
+
+ ); +}; +``` + +- [ ] **Step 6: Create CompanyForm component** + +Create `src/features/companies/components/CompanyForm.tsx`: + +```tsx +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod/v4"; +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"; + +const companySchema = z.object({ + name: z.string().min(1, "Company name is required"), +}); + +type CompanyValues = z.infer; + +type CompanyFormProps = { + onClose: () => void; +}; + +export const CompanyForm = ({ onClose }: CompanyFormProps) => { + const createCompany = useCreateCompany(); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(companySchema), + }); + + const onSubmit = async (values: CompanyValues) => { + await createCompany.mutateAsync(values.name); + onClose(); + }; + + return ( + + +
+
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+ + +
+
+
+ ); +}; +``` + +- [ ] **Step 7: Wire CompanyList into routes** + +Update `src/routes/index.tsx` — replace the companies placeholder route: + +Change: +```tsx +{ path: "/companies", element:
Companies — coming soon
} +``` +To: +```tsx +{ path: "/companies", element: } +``` + +Add the import at the top: +```tsx +import { CompanyList } from "@/features/companies/components/CompanyList"; +``` + +- [ ] **Step 8: Verify build** + +```bash +npm run build +``` + +- [ ] **Step 9: Commit** + +```bash +git add src/features/companies/ src/components/ui/ src/routes/index.tsx +git commit -m "feat(companies): add company list, create form, and hooks" +``` + +--- + +### Task 9: Company detail page with CM invite + +**Files:** +- Create: `src/features/companies/components/CompanyDetail.tsx` +- Create: `src/features/companies/components/InviteCMDialog.tsx` +- Create: `src/features/companies/hooks/useCreateInvite.ts` +- Modify: `src/routes/index.tsx` + +- [ ] **Step 1: Create useCreateInvite hook** + +Create `src/features/companies/hooks/useCreateInvite.ts`: + +```ts +import { useMutation } from "@tanstack/react-query"; +import { supabase } from "@/lib/supabase"; +import type { AppRole } from "@/lib/types"; + +type CreateInviteParams = { + email: string; + role: AppRole; + companyIds: string[]; +}; + +const generateToken = () => { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return Array.from(array, (b) => b.toString(16).padStart(2, "0")).join(""); +}; + +export const useCreateInvite = () => { + return useMutation({ + mutationFn: async ({ email, role, companyIds }: CreateInviteParams) => { + const token = generateToken(); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 7); // 7 day expiry + + const { data: invite, error: inviteError } = await supabase + .from("invites") + .insert({ + email, + role, + token, + invited_by: (await supabase.auth.getUser()).data.user!.id, + expires_at: expiresAt.toISOString(), + }) + .select() + .single(); + + if (inviteError) throw inviteError; + + if (companyIds.length > 0) { + const { error: icError } = await supabase + .from("invite_companies") + .insert( + companyIds.map((companyId) => ({ + invite_id: invite.id, + company_id: companyId, + })), + ); + if (icError) throw icError; + } + + return { invite, token }; + }, + }); +}; +``` + +- [ ] **Step 2: Create InviteCMDialog** + +Create `src/features/companies/components/InviteCMDialog.tsx`: + +```tsx +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod/v4"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { useCreateInvite } from "../hooks/useCreateInvite"; + +const inviteSchema = z.object({ + email: z.email("Please enter a valid email"), +}); + +type InviteValues = z.infer; + +type InviteCMDialogProps = { + companyId: string; + companyName: string; +}; + +export const InviteCMDialog = ({ + companyId, + companyName, +}: InviteCMDialogProps) => { + const [open, setOpen] = useState(false); + const [inviteLink, setInviteLink] = useState(null); + const [copied, setCopied] = useState(false); + const createInvite = useCreateInvite(); + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(inviteSchema), + }); + + const onSubmit = async (values: InviteValues) => { + const result = await createInvite.mutateAsync({ + email: values.email, + role: "customer_manager", + companyIds: [companyId], + }); + setInviteLink(`${window.location.origin}/signup/${result.token}`); + }; + + const handleCopy = async () => { + if (inviteLink) { + await navigator.clipboard.writeText(inviteLink); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const handleClose = () => { + setOpen(false); + setInviteLink(null); + setCopied(false); + reset(); + }; + + return ( + (v ? setOpen(true) : handleClose())}> + + + + + + Invite Customer Manager to {companyName} + + {inviteLink ? ( +
+

+ Share this link with the customer manager: +

+
+ + +
+ +
+ ) : ( +
+
+ + + {errors.email && ( +

+ {errors.email.message} +

+ )} +
+ +
+ )} +
+
+ ); +}; +``` + +- [ ] **Step 3: Create CompanyDetail** + +Create `src/features/companies/components/CompanyDetail.tsx`: + +```tsx +import { useState } from "react"; +import { useParams, useNavigate } from "react-router"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod/v4"; +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 { useCompany } from "../hooks/useCompany"; +import { useUpdateCompany, useDeleteCompany } from "../hooks/useCompanies"; +import { InviteCMDialog } from "./InviteCMDialog"; + +const editSchema = z.object({ + name: z.string().min(1, "Company name is required"), +}); + +type EditValues = z.infer; + +export const CompanyDetail = () => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { data: company, isLoading } = useCompany(id); + const updateCompany = useUpdateCompany(); + const deleteCompany = useDeleteCompany(); + const [editing, setEditing] = useState(false); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + values: { name: company?.name ?? "" }, + resolver: zodResolver(editSchema), + }); + + if (isLoading) { + return

Loading...

; + } + + if (!company) { + return

Company not found

; + } + + const onSubmit = async (values: EditValues) => { + await updateCompany.mutateAsync({ id: company.id, name: values.name }); + setEditing(false); + }; + + const handleDelete = async () => { + if (confirm("Delete this company? This will also delete all related data.")) { + await deleteCompany.mutateAsync(company.id); + navigate("/companies"); + } + }; + + return ( +
+
+

{company.name}

+
+ + + +
+
+ + {editing && ( + + + Edit Company + + +
+
+ + + {errors.name && ( +

+ {errors.name.message} +

+ )} +
+ +
+
+
+ )} + + + + Company Info + + +

+ Created:{" "} + {new Date(company.created_at).toLocaleDateString()} +

+
+
+
+ ); +}; +``` + +- [ ] **Step 4: Add company detail route** + +In `src/routes/index.tsx`, add the import and route: + +Add import: +```tsx +import { CompanyDetail } from "@/features/companies/components/CompanyDetail"; +``` + +Add route inside the admin `RoleGuard` children, after the `/companies` route: +```tsx +{ path: "/companies/:id", element: }, +``` + +- [ ] **Step 5: Verify build** + +```bash +npm run build +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/features/companies/ src/routes/index.tsx +git commit -m "feat(companies): add company detail page with CM invite dialog" +``` + +--- + +### Task 10: Agents — list, invite, and company assignment + +**Files:** +- Create: `src/features/agents/types.ts` +- Create: `src/features/agents/hooks/useAgents.ts` +- Create: `src/features/agents/hooks/useAgent.ts` +- Create: `src/features/agents/hooks/useAgentCompanies.ts` +- Create: `src/features/agents/components/AgentList.tsx` +- Create: `src/features/agents/components/InviteAgentDialog.tsx` +- Create: `src/features/agents/components/AssignCompaniesDialog.tsx` +- Modify: `src/routes/index.tsx` + +- [ ] **Step 1: Create agent types** + +Create `src/features/agents/types.ts`: + +```ts +import type { Profile, Company } from "@/lib/types"; + +export type AgentWithCompanies = Profile & { + email: string; + companies: Company[]; +}; +``` + +- [ ] **Step 2: Create useAgents hook** + +Create `src/features/agents/hooks/useAgents.ts`: + +```ts +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/lib/supabase"; +import type { AgentWithCompanies } from "../types"; + +export const useAgents = () => { + return 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; + + // Get emails from auth (via a join on user_id) + // Since we can't directly query auth.users, we'll use the admin API + // For now, get user_companies and companies for each agent + const agents: AgentWithCompanies[] = []; + + for (const profile of profiles) { + const { data: userCompanies } = await supabase + .from("user_companies") + .select("company_id, companies(*)") + .eq("user_id", profile.user_id); + + const { data: userData } = await supabase.auth.admin.getUserById( + profile.user_id, + ); + + agents.push({ + ...profile, + email: userData?.user?.email ?? "", + companies: + userCompanies?.map( + (uc) => uc.companies as unknown as AgentWithCompanies["companies"][number], + ) ?? [], + }); + } + + return agents; + }, + }); +}; +``` + +- [ ] **Step 3: Create useAgentCompanies hook** + +Create `src/features/agents/hooks/useAgentCompanies.ts`: + +```ts +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { supabase } from "@/lib/supabase"; + +export const useUpdateAgentCompanies = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + agentId, + companyIds, + }: { + agentId: string; + companyIds: string[]; + }) => { + // Delete existing assignments + const { error: deleteError } = await supabase + .from("user_companies") + .delete() + .eq("user_id", agentId); + + if (deleteError) throw deleteError; + + // Insert new assignments + if (companyIds.length > 0) { + const { error: insertError } = await supabase + .from("user_companies") + .insert( + companyIds.map((companyId) => ({ + user_id: agentId, + company_id: companyId, + })), + ); + + if (insertError) throw insertError; + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["agents"] }); + }, + }); +}; +``` + +- [ ] **Step 4: Create InviteAgentDialog** + +Create `src/features/agents/components/InviteAgentDialog.tsx`: + +```tsx +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod/v4"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { useCompanies } from "@/features/companies/hooks/useCompanies"; +import { useCreateInvite } from "@/features/companies/hooks/useCreateInvite"; + +const inviteSchema = z.object({ + email: z.email("Please enter a valid email"), +}); + +type InviteValues = z.infer; + +export const InviteAgentDialog = () => { + const [open, setOpen] = useState(false); + const [inviteLink, setInviteLink] = useState(null); + const [copied, setCopied] = useState(false); + const [selectedCompanies, setSelectedCompanies] = useState([]); + const { data: companies } = useCompanies(); + const createInvite = useCreateInvite(); + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(inviteSchema), + }); + + const toggleCompany = (companyId: string) => { + setSelectedCompanies((prev) => + prev.includes(companyId) + ? prev.filter((id) => id !== companyId) + : [...prev, companyId], + ); + }; + + const onSubmit = async (values: InviteValues) => { + const result = await createInvite.mutateAsync({ + email: values.email, + role: "agent", + companyIds: selectedCompanies, + }); + setInviteLink(`${window.location.origin}/signup/${result.token}`); + }; + + const handleCopy = async () => { + if (inviteLink) { + await navigator.clipboard.writeText(inviteLink); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const handleClose = () => { + setOpen(false); + setInviteLink(null); + setCopied(false); + setSelectedCompanies([]); + reset(); + }; + + return ( + (v ? setOpen(true) : handleClose())}> + + + + + + Invite Agent + + {inviteLink ? ( +
+

+ Share this link with the agent: +

+
+ + +
+ +
+ ) : ( +
+
+ + + {errors.email && ( +

+ {errors.email.message} +

+ )} +
+
+ +
+ {companies?.map((company) => ( + + ))} + {companies?.length === 0 && ( +

+ No companies created yet +

+ )} +
+
+ +
+ )} +
+
+ ); +}; +``` + +- [ ] **Step 5: Create AssignCompaniesDialog** + +Create `src/features/agents/components/AssignCompaniesDialog.tsx`: + +```tsx +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { useCompanies } from "@/features/companies/hooks/useCompanies"; +import { useUpdateAgentCompanies } from "../hooks/useAgentCompanies"; +import type { AgentWithCompanies } from "../types"; + +type AssignCompaniesDialogProps = { + agent: AgentWithCompanies; +}; + +export const AssignCompaniesDialog = ({ + agent, +}: AssignCompaniesDialogProps) => { + const [open, setOpen] = useState(false); + const [selectedIds, setSelectedIds] = useState([]); + const { data: companies } = useCompanies(); + const updateCompanies = useUpdateAgentCompanies(); + + useEffect(() => { + if (open) { + setSelectedIds(agent.companies.map((c) => c.id)); + } + }, [open, agent.companies]); + + const toggleCompany = (companyId: string) => { + setSelectedIds((prev) => + prev.includes(companyId) + ? prev.filter((id) => id !== companyId) + : [...prev, companyId], + ); + }; + + const handleSave = async () => { + await updateCompanies.mutateAsync({ + agentId: agent.user_id, + companyIds: selectedIds, + }); + setOpen(false); + }; + + return ( + + + + + + + Assign companies to {agent.name} + +
+
+ +
+ {companies?.map((company) => ( + + ))} +
+
+ +
+
+
+ ); +}; +``` + +- [ ] **Step 6: Create AgentList** + +Create `src/features/agents/components/AgentList.tsx`: + +```tsx +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useAgents } from "../hooks/useAgents"; +import { InviteAgentDialog } from "./InviteAgentDialog"; +import { AssignCompaniesDialog } from "./AssignCompaniesDialog"; + +export const AgentList = () => { + const { data: agents, isLoading } = useAgents(); + + if (isLoading) { + return

Loading agents...

; + } + + return ( +
+
+

Agents

+ +
+ + + + + Name + Email + Companies + + + + + {agents?.map((agent) => ( + + {agent.name} + {agent.email} + +
+ {agent.companies.map((c) => ( + + {c.name} + + ))} + {agent.companies.length === 0 && ( + + No companies + + )} +
+
+ + + +
+ ))} + {agents?.length === 0 && ( + + + No agents yet + + + )} +
+
+
+ ); +}; +``` + +- [ ] **Step 7: Wire AgentList into routes** + +In `src/routes/index.tsx`, add the import: +```tsx +import { AgentList } from "@/features/agents/components/AgentList"; +``` + +Replace the agents placeholder: +```tsx +{ path: "/agents", element: }, +``` + +- [ ] **Step 8: Verify build** + +```bash +npm run build +``` + +- [ ] **Step 9: Commit** + +```bash +git add src/features/agents/ src/routes/index.tsx +git commit -m "feat(agents): add agent list, invite dialog, and company assignment" +``` + +--- + +## Phase D: Customer Manager Features + +### Task 11: Customers — list and form + +**Files:** +- Create: `src/features/customers/types.ts` +- Create: `src/features/customers/hooks/useCustomers.ts` +- Create: `src/features/customers/components/CustomerList.tsx` +- Create: `src/features/customers/components/CustomerForm.tsx` +- Modify: `src/routes/index.tsx` + +- [ ] **Step 1: Create customer types** + +Create `src/features/customers/types.ts`: + +```ts +import type { Customer } from "@/lib/types"; + +export type CustomerFormValues = { + name: string; + email: string; +}; + +export type { Customer }; +``` + +- [ ] **Step 2: Create useCustomers hook** + +Create `src/features/customers/hooks/useCustomers.ts`: + +```ts +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { supabase } from "@/lib/supabase"; +import type { Customer } from "@/lib/types"; + +export const useCustomers = () => { + return useQuery({ + queryKey: ["customers"], + queryFn: async () => { + const { data, error } = await supabase + .from("customers") + .select("*") + .order("name"); + if (error) throw error; + return data; + }, + }); +}; + +export const useCreateCustomer = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + name, + email, + companyId, + }: { + name: string; + email: string; + companyId: string; + }) => { + const { data, error } = await supabase + .from("customers") + .insert({ name, email, company_id: companyId }) + .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 { data, error } = await supabase + .from("customers") + .update({ name, email }) + .eq("id", id) + .select() + .single(); + if (error) throw error; + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["customers"] }); + }, + }); +}; +``` + +- [ ] **Step 3: Create CustomerForm** + +Create `src/features/customers/components/CustomerForm.tsx`: + +```tsx +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod/v4"; +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 type { Customer } from "@/lib/types"; + +const customerSchema = z.object({ + name: z.string().min(1, "Name is required"), + email: z.email("Please enter a valid email"), +}); + +type CustomerValues = z.infer; + +type CustomerFormProps = { + companyId: string; + customer?: Customer; + onClose: () => void; +}; + +export const CustomerForm = ({ + companyId, + customer, + onClose, +}: CustomerFormProps) => { + const createCustomer = useCreateCustomer(); + const updateCustomer = useUpdateCustomer(); + const isEditing = !!customer; + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(customerSchema), + defaultValues: customer + ? { name: customer.name, email: customer.email } + : undefined, + }); + + const onSubmit = async (values: CustomerValues) => { + if (isEditing) { + await updateCustomer.mutateAsync({ + id: customer.id, + name: values.name, + email: values.email, + }); + } else { + await createCustomer.mutateAsync({ + name: values.name, + email: values.email, + companyId, + }); + } + onClose(); + }; + + const isPending = createCustomer.isPending || updateCustomer.isPending; + + return ( + + + {isEditing ? "Edit Customer" : "New Customer"} + + +
+
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+
+ + + {errors.email && ( +

{errors.email.message}

+ )} +
+
+ + +
+
+
+
+ ); +}; +``` + +- [ ] **Step 4: Create CustomerList** + +Create `src/features/customers/components/CustomerList.tsx`: + +```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 { 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"; + +export const CustomerList = () => { + const { data: customers, isLoading } = useCustomers(); + const { user } = useAuthContext(); + 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) return null; + 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, + }); + + if (isLoading) { + return

Loading customers...

; + } + + const companyId = userCompany?.company_id; + + 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 5: Wire CustomerList into routes** + +In `src/routes/index.tsx`, add the import: +```tsx +import { CustomerList } from "@/features/customers/components/CustomerList"; +``` + +Replace the customers placeholder: +```tsx +{ path: "/customers", element: }, +``` + +- [ ] **Step 6: Verify build** + +```bash +npm run build +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/features/customers/ src/routes/index.tsx +git commit -m "feat(customers): add customer list, create/edit form" +``` + +--- + +## Phase E: Ticket Management + +### Task 12: Tickets — hooks and list with filters + +**Files:** +- Create: `src/features/tickets/types.ts` +- Create: `src/features/tickets/hooks/useTickets.ts` +- Create: `src/features/tickets/hooks/useUpdateTicket.ts` +- Create: `src/features/tickets/components/TicketStatusBadge.tsx` +- Create: `src/features/tickets/components/TicketPriorityBadge.tsx` +- Create: `src/features/tickets/components/TicketFilters.tsx` +- Create: `src/features/tickets/components/TicketList.tsx` +- Modify: `src/routes/index.tsx` + +- [ ] **Step 1: Create ticket types** + +Create `src/features/tickets/types.ts`: + +```ts +import type { Ticket, TicketStatus, TicketPriority } from "@/lib/types"; + +export type TicketWithRelations = Ticket & { + customers: { name: string; email: string } | null; + companies: { name: string } | null; +}; + +export type TicketFiltersState = { + status?: TicketStatus; + priority?: TicketPriority; + companyId?: string; +}; +``` + +- [ ] **Step 2: Create useTickets hook** + +Create `src/features/tickets/hooks/useTickets.ts`: + +```ts +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/lib/supabase"; +import type { TicketWithRelations, TicketFiltersState } from "../types"; + +export const useTickets = (filters: TicketFiltersState = {}) => { + return useQuery({ + queryKey: ["tickets", filters], + queryFn: async () => { + let query = supabase + .from("tickets") + .select("*, customers(name, email), companies(name)") + .order("created_at", { ascending: false }); + + if (filters.status) { + query = query.eq("status", filters.status); + } + if (filters.priority) { + query = query.eq("priority", filters.priority); + } + if (filters.companyId) { + query = query.eq("company_id", filters.companyId); + } + + const { data, error } = await query; + if (error) throw error; + return data as TicketWithRelations[]; + }, + }); +}; +``` + +- [ ] **Step 3: Create useUpdateTicket hook** + +Create `src/features/tickets/hooks/useUpdateTicket.ts`: + +```ts +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { supabase } from "@/lib/supabase"; +import type { TicketStatus, TicketPriority } from "@/lib/types"; + +type UpdateTicketParams = { + id: string; + status?: TicketStatus; + priority?: TicketPriority; +}; + +export const useUpdateTicket = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, ...updates }: UpdateTicketParams) => { + const { data, error } = await supabase + .from("tickets") + .update({ ...updates, updated_at: new Date().toISOString() }) + .eq("id", id) + .select() + .single(); + if (error) throw error; + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tickets"] }); + }, + }); +}; +``` + +- [ ] **Step 4: Create TicketStatusBadge and TicketPriorityBadge** + +Create `src/features/tickets/components/TicketStatusBadge.tsx`: + +```tsx +import { Badge } from "@/components/ui/badge"; +import type { TicketStatus } from "@/lib/types"; + +const statusConfig: Record = { + open: { label: "Open", variant: "default" }, + in_progress: { label: "In Progress", variant: "secondary" }, + waiting: { label: "Waiting", variant: "outline" }, + resolved: { label: "Resolved", variant: "secondary" }, + closed: { label: "Closed", variant: "outline" }, +}; + +export const TicketStatusBadge = ({ status }: { status: TicketStatus }) => { + const config = statusConfig[status]; + return {config.label}; +}; +``` + +Create `src/features/tickets/components/TicketPriorityBadge.tsx`: + +```tsx +import { Badge } from "@/components/ui/badge"; +import type { TicketPriority } from "@/lib/types"; + +const priorityConfig: Record = { + low: { label: "Low", variant: "outline" }, + medium: { label: "Medium", variant: "secondary" }, + high: { label: "High", variant: "default" }, + urgent: { label: "Urgent", variant: "destructive" }, +}; + +export const TicketPriorityBadge = ({ priority }: { priority: TicketPriority }) => { + const config = priorityConfig[priority]; + return {config.label}; +}; +``` + +- [ ] **Step 5: Create TicketFilters** + +Create `src/features/tickets/components/TicketFilters.tsx`: + +```tsx +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { useCompanies } from "@/features/companies/hooks/useCompanies"; +import { useAuthContext } from "@/features/auth/AuthProvider"; +import type { TicketFiltersState } from "../types"; +import type { TicketStatus, TicketPriority } from "@/lib/types"; + +type TicketFiltersProps = { + filters: TicketFiltersState; + onChange: (filters: TicketFiltersState) => void; +}; + +const statuses: TicketStatus[] = [ + "open", + "in_progress", + "waiting", + "resolved", + "closed", +]; + +const priorities: TicketPriority[] = ["low", "medium", "high", "urgent"]; + +export const TicketFilters = ({ filters, onChange }: TicketFiltersProps) => { + const { profile } = useAuthContext(); + const { data: companies } = useCompanies(); + const isAdmin = profile?.role === "admin"; + + return ( +
+ + + + + {isAdmin && ( + + )} + + {(filters.status || filters.priority || filters.companyId) && ( + + )} +
+ ); +}; +``` + +- [ ] **Step 6: Create TicketList** + +Create `src/features/tickets/components/TicketList.tsx`: + +```tsx +import { useState } from "react"; +import { Link } from "react-router"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useAuthContext } from "@/features/auth/AuthProvider"; +import { useTickets } from "../hooks/useTickets"; +import { TicketFilters } from "./TicketFilters"; +import { TicketStatusBadge } from "./TicketStatusBadge"; +import { TicketPriorityBadge } from "./TicketPriorityBadge"; +import type { TicketFiltersState } from "../types"; + +export const TicketList = () => { + const { profile } = useAuthContext(); + const [filters, setFilters] = useState({}); + const { data: tickets, isLoading } = useTickets(filters); + const isAdmin = profile?.role === "admin"; + const isCM = profile?.role === "customer_manager"; + + if (isLoading) { + return

Loading tickets...

; + } + + return ( +
+
+

Tickets

+ {isCM && ( + + + + )} +
+ + + + + + + Subject + Status + Priority + Customer + {isAdmin && Company} + Date + + + + {tickets?.map((ticket) => ( + + + + {ticket.subject} + + + + + + + + + {ticket.customers?.name ?? "—"} + {isAdmin && ( + {ticket.companies?.name ?? "—"} + )} + + {new Date(ticket.created_at).toLocaleDateString()} + + + ))} + {tickets?.length === 0 && ( + + + No tickets found + + + )} + +
+
+ ); +}; +``` + +- [ ] **Step 7: Wire TicketList into routes** + +In `src/routes/index.tsx`, add the import: +```tsx +import { TicketList } from "@/features/tickets/components/TicketList"; +``` + +Replace the tickets placeholder: +```tsx +{ path: "/tickets", element: }, +``` + +- [ ] **Step 8: Verify build** + +```bash +npm run build +``` + +- [ ] **Step 9: Commit** + +```bash +git add src/features/tickets/ src/routes/index.tsx +git commit -m "feat(tickets): add ticket list with filters, status/priority badges" +``` + +--- + +### Task 13: Ticket detail page with message thread + +**Files:** +- Create: `src/features/tickets/hooks/useTicket.ts` +- Create: `src/features/tickets/hooks/useTicketMessages.ts` +- Create: `src/features/tickets/hooks/useCreateMessage.ts` +- Create: `src/features/tickets/components/TicketMessageThread.tsx` +- Create: `src/features/tickets/components/TicketDetail.tsx` +- Modify: `src/routes/index.tsx` + +- [ ] **Step 1: Create useTicket hook** + +Create `src/features/tickets/hooks/useTicket.ts`: + +```ts +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/lib/supabase"; +import type { TicketWithRelations } from "../types"; + +export const useTicket = (id: string | undefined) => { + return useQuery({ + queryKey: ["ticket", id], + queryFn: async () => { + if (!id) return null; + const { data, error } = await supabase + .from("tickets") + .select("*, customers(name, email), companies(name)") + .eq("id", id) + .single(); + if (error) throw error; + return data as TicketWithRelations; + }, + enabled: !!id, + }); +}; +``` + +- [ ] **Step 2: Create useTicketMessages hook** + +Create `src/features/tickets/hooks/useTicketMessages.ts`: + +```ts +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/lib/supabase"; +import type { TicketMessage } from "@/lib/types"; + +export type MessageWithSender = TicketMessage & { + sender_name: string; +}; + +export const useTicketMessages = (ticketId: string | undefined) => { + return useQuery({ + queryKey: ["ticket-messages", ticketId], + queryFn: async () => { + if (!ticketId) return []; + const { data, error } = await supabase + .from("ticket_messages") + .select("*") + .eq("ticket_id", ticketId) + .order("created_at", { ascending: true }); + + if (error) throw error; + + // Resolve sender names + const messages: MessageWithSender[] = []; + for (const msg of data) { + let senderName = "System"; + if (msg.sender_type === "agent" && msg.sender_id) { + const { data: profile } = await supabase + .from("profiles") + .select("name") + .eq("user_id", msg.sender_id) + .single(); + senderName = profile?.name ?? "Agent"; + } else if (msg.sender_type === "customer" && msg.sender_id) { + const { data: customer } = await supabase + .from("customers") + .select("name") + .eq("id", msg.sender_id) + .single(); + senderName = customer?.name ?? "Customer"; + } + messages.push({ ...msg, sender_name: senderName }); + } + + return messages; + }, + enabled: !!ticketId, + }); +}; +``` + +- [ ] **Step 3: Create useCreateMessage hook** + +Create `src/features/tickets/hooks/useCreateMessage.ts`: + +```ts +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { supabase } from "@/lib/supabase"; + +type CreateMessageParams = { + ticketId: string; + body: string; + senderType: "agent" | "customer_manager"; + senderId: string; +}; + +export const useCreateMessage = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + ticketId, + body, + senderType, + senderId, + }: CreateMessageParams) => { + const { data, error } = await supabase + .from("ticket_messages") + .insert({ + ticket_id: ticketId, + body, + sender_type: senderType === "customer_manager" ? "agent" : senderType, + sender_id: senderId, + }) + .select() + .single(); + if (error) throw error; + return data; + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: ["ticket-messages", variables.ticketId], + }); + }, + }); +}; +``` + +- [ ] **Step 4: Create TicketMessageThread** + +Create `src/features/tickets/components/TicketMessageThread.tsx`: + +```tsx +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod/v4"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { useAuthContext } from "@/features/auth/AuthProvider"; +import { useTicketMessages } from "../hooks/useTicketMessages"; +import { useCreateMessage } from "../hooks/useCreateMessage"; +import { cn } from "@/lib/utils"; + +const replySchema = z.object({ + body: z.string().min(1, "Message cannot be empty"), +}); + +type ReplyValues = z.infer; + +type TicketMessageThreadProps = { + ticketId: string; +}; + +export const TicketMessageThread = ({ + ticketId, +}: TicketMessageThreadProps) => { + const { user, profile } = useAuthContext(); + const { data: messages, isLoading } = useTicketMessages(ticketId); + const createMessage = useCreateMessage(); + + const { register, handleSubmit, reset, formState: { errors } } = + useForm({ + resolver: zodResolver(replySchema), + }); + + const onSubmit = async (values: ReplyValues) => { + if (!user || !profile) return; + await createMessage.mutateAsync({ + ticketId, + body: values.body, + senderType: profile.role === "agent" ? "agent" : "customer_manager", + senderId: user.id, + }); + reset(); + }; + + if (isLoading) { + return

Loading messages...

; + } + + return ( +
+
+ {messages?.map((msg) => ( +
+
+ {msg.sender_name} + + {new Date(msg.created_at).toLocaleString()} + +
+

{msg.body}

+
+ ))} + {messages?.length === 0 && ( +

+ No messages yet +

+ )} +
+ + {profile && profile.role !== "admin" && ( +
+