👤 Client Records
Manage client personal information, contacts, and address details. Optional fields provide maximum flexibility.
The clients system provides comprehensive client management capabilities including creation, querying, updates, and deletion with built-in user attribution and row-level security.
The clients system supports:
👤 Client Records
Manage client personal information, contacts, and address details. Optional fields provide maximum flexibility.
🔒 Secure by Default
Row-level security ensures users can only modify their own clients. Admins can manage any client.
⚡ Fast Queries
Optimized indexes provide fast queries for name searches, email lookups, and alphabetical sorting.
📋 Flexible Fields
Minimal required field (gender only) with optional name, contact, and address information based on your needs.
import { getDatabase } from '#libs/database'import type { ClientData } from '#libs/database'
const db = getDatabase()
// Minimal client (only required field)const minimalClient: ClientData = { gender: 'prefer_not_to_say', nickname: 'Client A', // Recommended for display in client lists created_by: userId,}
const clientId = await db.insertClient(minimalClient)console.log(`Client created: ${clientId}`)
// Simple client with nameconst clientData: ClientData = { first_name: 'John', last_name: 'Doe', gender: 'male', nickname: 'Johnny', // Will be shown in client lists created_by: userId, // User UUID from Clerk}
const namedClientId = await db.insertClient(clientData)
// Client with full contact informationconst fullClientData: ClientData = { first_name: 'Jane', last_name: 'Smith', gender: 'female', date_of_birth: '1990-03-22', nickname: 'Janie', phone: '5551234567', email: 'jane.smith@example.com', street_address: '456 Oak Ave', city: 'Los Angeles', state: 'CA', zip_code: '90001', veteran_status: false, ethnicity: 'Asian', notes: 'Referred by John Doe, prefers morning appointments', created_by: userId,}
const fullClientId = await db.insertClient(fullClientData)// Get all clients (alphabetical by last name)const allClients = await db.getClients({ limit: 50, order_by: 'last_name',})
// Get user's clientsconst myClients = await db.getClients({ created_by: userId, limit: 20,})
// Get recent clientsconst recentClients = await db.getClients({ order_by: 'created_at', order_direction: 'desc', limit: 10,})
// Optimized list view (selective fields)const clientList = await db.getClients({ fields: ['id', 'first_name', 'last_name', 'phone', 'email'], limit: 100,})| Field | Type | Description | Example | Validation |
|---|---|---|---|---|
| gender | string | Gender identity | ”male” | Enum (see below) |
Gender Values:
male - Male gender identityfemale - Female gender identityother - Other gender identityprefer_not_to_say - User prefers not to disclose| Field | Type | Description | Example | Validation |
|---|---|---|---|---|
| first_name | string | Client first name | ”John” | Max 100 chars |
| last_name | string | Client last name | ”Doe” | Max 100 chars |
| date_of_birth | string | Date of birth | ”1985-06-15” | YYYY-MM-DD, not future |
| nickname | string | Preferred name (primary UI identifier) | “Johnny” | Max 100 chars |
| phone | string | Phone number | ”5551234567” | 10 digits |
| string | Email address | ”john@example.com” | Valid email format | |
| street_address | string | Street address | ”123 Main St” | Max 200 chars |
| city | string | City name | ”San Francisco” | Max 100 chars |
| state | string | State code | ”CA” | 2 letters |
| zip_code | string | Zip code | ”94102” | 5 digits |
| veteran_status | boolean | U.S. Military veteran | true, false, null | Boolean or null |
| ethnicity | string | Self-identified ethnicity | ”Hispanic or Latino” | Max 100 chars |
| notes | string | Internal notes | ”VIP client” | Max 1000 chars |
| created_by | string | User UUID (from Clerk) | “uuid-from-clerk” | Valid UUID |
| Field | Type | Description |
|---|---|---|
| id | string | Unique UUID |
| created_at | string | ISO 8601 timestamp |
| updated_at | string | ISO 8601 timestamp |
Display all clients sorted by name:
import { getDatabase } from '#libs/database'import { formatClientName, formatPhoneNumber } from '#utils/clients'
async function listClientsAlphabetically() { const db = getDatabase()
const clients = await db.getClients({ order_by: 'last_name', order_direction: 'asc', limit: 100, })
clients.forEach(client => { console.log(formatClientName(client)) // "John Doe" if (client.phone) { console.log(` Phone: ${formatPhoneNumber(client.phone)}`) // "(555) 123-4567" } if (client.email) { console.log(` Email: ${client.email}`) } })}Show clients created by specific user:
async function getUserClients(userId: string) { const db = getDatabase()
return await db.getClients({ created_by: userId, order_by: 'last_name', limit: 50, })}Fetch single client with full details:
async function getClientDetails(clientId: string) { const db = getDatabase() const client = await db.getClientById(clientId)
if (!client) { throw new Error('Client not found') }
return client}Modify existing client (only owner or admin):
async function updateClientContact(clientId: string, phone: string, email: string) { const db = getDatabase()
const success = await db.updateClient(clientId, { phone, email, })
if (!success) { throw new Error('Client not found or no permission') }}Remove client (only owner or admin):
async function deleteClient(clientId: string) { const db = getDatabase()
const success = await db.deleteClient(clientId)
if (!success) { throw new Error('Client not found or no permission') }}Filter clients by various criteria:
const myClients = await db.getClients({ created_by: userId,})const page2Clients = await db.getClients({ limit: 10, offset: 10, // Skip first 10 results})const recentClients = await db.getClients({ order_by: 'created_at', order_direction: 'desc', limit: 20,})Control result order:
// Alphabetical by last name (default)const byLastName = await db.getClients({ order_by: 'last_name', order_direction: 'asc',})
// By first nameconst byFirstName = await db.getClients({ order_by: 'first_name', order_direction: 'asc',})
// Newest firstconst newest = await db.getClients({ order_by: 'created_at', order_direction: 'desc',})Select only needed fields for better performance:
// Minimal data for list viewsconst clientList = await db.getClients({ fields: ['id', 'first_name', 'last_name'], limit: 100,})
// With contact infoconst withContacts = await db.getClients({ fields: ['id', 'first_name', 'last_name', 'phone', 'email'], limit: 100,})The clients system provides helper functions for common formatting tasks:
Format client full name:
import { formatClientName } from '#utils/clients'
const fullName = formatClientName(client)// Returns: "John Doe"Format phone number for display:
import { formatPhoneNumber } from '#utils/clients'
const formatted = formatPhoneNumber('5551234567')// Returns: "(555) 123-4567"Calculate age from date of birth:
import { calculateAge } from '#utils/clients'
const age = calculateAge('1985-06-15')// Returns: 39 (as of 2025-01-15)Format full address string:
import { formatAddress } from '#utils/clients'
const address = formatAddress(client)// Returns: "123 Main St, San Francisco, CA 94102"// Or partial: "San Francisco, CA" (if street missing)// Or empty string if no address fieldsImplement paginated client lists:
async function getClientsPage(userId: string, page: number, pageSize: number = 10) { const db = getDatabase() const offset = (page - 1) * pageSize
const [clients, totalCount] = await Promise.all([ db.getClients({ created_by: userId, limit: pageSize, offset, order_by: 'last_name', }), db.getClientsCount({ created_by: userId }), ])
const totalPages = Math.ceil(totalCount / pageSize)
return { clients, pagination: { currentPage: page, pageSize, totalCount, totalPages, hasNextPage: page < totalPages, hasPrevPage: page > 1, }, }}Example Usage:
const result = await getClientsPage(userId, 1, 10)console.log(`Page ${result.pagination.currentPage} of ${result.pagination.totalPages}`)console.log(`Total clients: ${result.pagination.totalCount}`)result.clients.forEach(client => { console.log(formatClientName(client))})Form component for creating/editing clients using React Hook Form + Zod validation:
File: src/components/react/ClientFormRHF.tsx
Props:
type Props = { csrfToken: string // CSRF token for form submission client?: Client | null // For edit mode (optional) apiEndpoint?: string // Custom API endpoint (optional)}Usage in Astro:
---import ClientFormRHF from '#components/react/ClientFormRHF.tsx'import { generateCSRFToken } from '#utils/csrf'
const csrfToken = await generateCSRFToken(Astro.locals.userId)---
<ClientFormRHF client:load csrfToken={csrfToken} />---import ClientFormRHF from '#components/react/ClientFormRHF.tsx'import { generateCSRFToken } from '#utils/csrf'import { getDatabase } from '#libs/database'
const clientId = Astro.params.idconst db = getDatabase()const client = await db.getClientById(clientId)const csrfToken = await generateCSRFToken(Astro.locals.userId)---
<ClientFormRHF client:load client={client} csrfToken={csrfToken} apiEndpoint={`/api/clients/${clientId}`}/>Features:
Implementation Highlights:
The form demonstrates best practices for React Hook Form + @fpkit/acss integration:
// Checkbox using Controller pattern<Controller name="veteran_status" control={control} render={({ field }) => ( <Checkbox id="veteran_status" label="Veteran Status" checked={field.value ?? false} onChange={field.onChange} size="lg" /> )}/>This pattern ensures proper state management and accessibility for controlled components.
The clients dashboard roster. One component renders both layouts — a card grid
and an aligned list — from the same markup, switched by a data-view attribute.
Files: src/components/react/clients/ — ClientsGrid, ClientCard,
ClientsToolbar, ClientsGridPagination, plus utils.ts for the display
helpers.
Props:
type Props = { clients: Client[] lastVisits?: Record<string, string> view?: 'grid' | 'list' canEdit?: boolean emptyMessage?: string now?: Date | undefined}Usage:
---import { ClientsGrid, ClientsGridPagination, ClientsToolbar } from '#components/react/clients'import { getDatabase } from '#libs/database'
const params = Astro.url.searchParamsconst query = (params.get('q') ?? '').trim()const view = params.get('view') === 'list' ? 'list' : 'grid'
const db = getDatabase()const totalCount = await db.getClientsCount(query ? { search: query } : {})const clients = await db.getClients({ limit: 10, order_by: 'last_name', ...(query ? { search: query } : {}),})const lastVisits = await db.getClientLastVisits(clients.map(c => c.id))---
<ClientsToolbar query={query} view={view} matchCount={totalCount} /><ClientsGrid clients={clients} lastVisits={lastVisits} view={view} canEdit /><ClientsGridPagination currentPage={1} totalPages={Math.ceil(totalCount / 10)} totalClients={totalCount} />Features:
lg up, list view adds a shared column rulerServer state: search (?q=), paging (?page=) and layout (?view=) are all
resolved on the server, so a search narrows the whole roster rather than the page
on screen. No framework hydration — the roster, cards and pager are static HTML.
Search-as-you-type: the toolbar is a plain GET form, so Enter, Clear, the
pager links and the no-JS path all work on their own. A small inline script on
src/pages/dashboard/clients/index.astro upgrades it: 300ms after the reader
stops typing it fetches that same ?q= URL and swaps in everything after
.cl-page__head — the roster or its empty state, plus the pager — leaving the
input untouched.
Swapping rather than reloading is load-bearing. A reload destroys the input
mid-word, and every keystroke typed while the new document is on the wire dies
with the old one: pause just long enough to let the search fire, type on, and
the box reads “alarez” for a reader who typed “alvarez”, with the roster
filtered to the term they never typed. The window is one server round trip wide
(260-357ms measured locally), which is why the debounce length does not fix it. e2e/clients-dashboard.authed.spec.ts pins both halves —
that typing never navigates, and that keys typed mid-fetch survive.
Last visit comes from db.getClientLastVisits(ids) — the newest
non-cancelled orders.order_date per client, returned as YYYY-MM-DD. A client
with no visit is absent from the map and renders as “No visits yet”.
Complete validation reference:
| Field | Required | Min/Max | Format | Special Rules |
|---|---|---|---|---|
| first_name | No | 0-100 chars | Text | Trimmed |
| last_name | No | 0-100 chars | Text | Trimmed |
| gender | Yes | N/A | Enum | male, female, other, prefer_not_to_say |
| date_of_birth | No | N/A | YYYY-MM-DD | Valid date, not future |
| phone | No | N/A | Numeric | 10 digits (any format) or empty |
| No | N/A | Valid format if provided | ||
| street_address | No | 0-200 chars | Text | Trimmed |
| city | No | 0-100 chars | Text | Trimmed |
| state | No | 2 chars | Text | 2-letter code (e.g., CA) |
| zip_code | No | N/A | Numeric | 5 digits or empty |
| notes | No | 0-1000 chars | Text | Trimmed, database constraint |
| created_by | No (auto) | N/A | UUID | Set to current user |
All client operations require:
Station Floor (API and pages):
POST /api/clients/create and GET /api/clients/edit/[id] require volunteer or higher (SERVICE_DAY_STATION_ROLES, the policy the Service Day stations guard on); a member gets 403PUT/POST /api/clients/edit/[id]) apply the same floor ahead of the ownership check, so a member who still owns clients created before the create gate gets 403, never a 404 that confirms an id exists/dashboard/clients and /dashboard/clients/create apply the same floor in their frontmatter, before any read; a member is redirected to /dashboard?error=insufficient_permissionsteam_manager floor decides who is offered the link, not who can open the pagePublic Read (RLS):
clients_select_all policy lets any authenticated JWT read every clientmember outOwnership Write:
Admin Override:
super_admin role can update/delete any clientcreated_by)Service Role:
Create a new client record.
Security: Auth + Role (volunteer+) + Rate Limit + CSRF + Validation
Request (JSON):
{ "first_name": "John", "last_name": "Doe", "gender": "male", "phone": "5551234567", "email": "john@example.com", "csrfToken": "token-here"}Response (Success):
{ "success": true, "clientId": "uuid-string"}Retrieve a single client by ID.
Security: Auth + Role (volunteer+)
Response (Success):
{ "success": true, "client": { "id": "uuid", "first_name": "John", "last_name": "Doe", "gender": "male", "phone": "5551234567", "email": "john@example.com", "created_at": "2025-01-14T10:30:00Z", "updated_at": "2025-01-14T10:30:00Z" }}Update an existing client record.
Security: Auth + Role (volunteer+) + Rate Limit + CSRF + Validation + Ownership Check
Request (JSON):
{ "phone": "5559876543", "email": "newemail@example.com", "csrfToken": "token-here"}Response (Success):
{ "success": true, "clientId": "uuid-string"}For large result sets, select only needed fields:
// ✅ FAST - Minimal data transferconst clients = await db.getClients({ fields: ['id', 'first_name', 'last_name'], limit: 100,})
// ⚠️ SLOWER - All fields returnedconst clients = await db.getClients({ limit: 100,})Always use pagination for lists:
// ✅ GOOD - Limited result setconst clients = await db.getClients({ limit: 10, offset: 0,})
// ❌ BAD - Fetches all recordsconst allClients = await db.getClients({ limit: 999999,})Use indexed fields for filtering and sorting:
// ✅ FAST - Uses idx_clients_created_byconst myClients = await db.getClients({ created_by: userId,})
// ✅ FAST - Uses idx_clients_last_nameconst sorted = await db.getClients({ order_by: 'last_name',})Symptom: Client created successfully but not visible in queries
Cause: RLS policy requires authentication
Solution: Ensure JWT token is present in request headers
Symptom: You do not have permission to update this client error
Cause: Attempting to update client owned by another user
Solution: Verify ownership or request super_admin privileges
Symptom: Invalid input error on form submission
Cause: Field doesn’t meet validation requirements
Solution: Check validation rules table above and error details
Symptom: Optional fields showing empty string instead of null
Cause: Form submitting "" for empty fields
Solution: Empty strings are automatically converted to null - no action needed
Technical Reference:
Database Documentation:
Migration History: