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:

  • Personal Information: Name, gender, date of birth, nickname tracking
  • Contact Details: Optional phone and email with validation
  • Address Information: Street address, city, state, zip code
  • Additional Information: Veteran status, ethnicity (optional demographic data)
  • Internal Notes: Free-form text field (max 1000 characters)
  • User Attribution: Track who created each client
  • Security: Row-level security with ownership-based access control
  • Performance: Optimized indexes for name searches and lookups

👤 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 name
const 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 information
const 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 clients
const myClients = await db.getClients({
created_by: userId,
limit: 20,
})
// Get recent clients
const 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,
})
FieldTypeDescriptionExampleValidation
genderstringGender identity”male”Enum (see below)

Gender Values:

  • male - Male gender identity
  • female - Female gender identity
  • other - Other gender identity
  • prefer_not_to_say - User prefers not to disclose
FieldTypeDescriptionExampleValidation
first_namestringClient first name”John”Max 100 chars
last_namestringClient last name”Doe”Max 100 chars
date_of_birthstringDate of birth”1985-06-15”YYYY-MM-DD, not future
nicknamestringPreferred name (primary UI identifier)“Johnny”Max 100 chars
phonestringPhone number”5551234567”10 digits
emailstringEmail address”john@example.com”Valid email format
street_addressstringStreet address”123 Main St”Max 200 chars
citystringCity name”San Francisco”Max 100 chars
statestringState code”CA”2 letters
zip_codestringZip code”94102”5 digits
veteran_statusbooleanU.S. Military veterantrue, false, nullBoolean or null
ethnicitystringSelf-identified ethnicity”Hispanic or Latino”Max 100 chars
notesstringInternal notes”VIP client”Max 1000 chars
created_bystringUser UUID (from Clerk)“uuid-from-clerk”Valid UUID
FieldTypeDescription
idstringUnique UUID
created_atstringISO 8601 timestamp
updated_atstringISO 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,
})

Control result order:

// Alphabetical by last name (default)
const byLastName = await db.getClients({
order_by: 'last_name',
order_direction: 'asc',
})
// By first name
const byFirstName = await db.getClients({
order_by: 'first_name',
order_direction: 'asc',
})
// Newest first
const newest = await db.getClients({
order_by: 'created_at',
order_direction: 'desc',
})

Select only needed fields for better performance:

// Minimal data for list views
const clientList = await db.getClients({
fields: ['id', 'first_name', 'last_name'],
limit: 100,
})
// With contact info
const 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 fields

Implement 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} />

Features:

  • Organized fieldsets (Personal, Contact, Address, Notes)
  • Real-time validation with error messages
  • Error summary with anchor links to fields
  • Submission error display
  • CSRF token integration
  • @fpkit/acss components for accessibility
  • Controller pattern for checkboxes (veteran_status field)

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.searchParams
const 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:

  • Three facts per row: the name, the date of birth with age, and the last visit
  • Card grid on a phone; from lg up, list view adds a shared column ruler
  • Absences are stated, not blanked — “Date of birth not recorded”, “No visits yet”
  • A nickname that differs from the legal name shows as a “Goes by” line
  • Every interactive target meets the 44px WCAG 2.5.5 floor

Server 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:

FieldRequiredMin/MaxFormatSpecial Rules
first_nameNo0-100 charsTextTrimmed
last_nameNo0-100 charsTextTrimmed
genderYesN/AEnummale, female, other, prefer_not_to_say
date_of_birthNoN/AYYYY-MM-DDValid date, not future
phoneNoN/ANumeric10 digits (any format) or empty
emailNoN/AEmailValid format if provided
street_addressNo0-200 charsTextTrimmed
cityNo0-100 charsTextTrimmed
stateNo2 charsText2-letter code (e.g., CA)
zip_codeNoN/ANumeric5 digits or empty
notesNo0-1000 charsTextTrimmed, database constraint
created_byNo (auto)N/AUUIDSet to current user

All client operations require:

  • Valid Clerk JWT token
  • User record in database (synced from Clerk)

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 403
  • Updates (PUT/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_permissions
  • The Clients nav link’s team_manager floor decides who is offered the link, not who can open the page
  • All of these run through the service-role client, which bypasses RLS, so until they move to a user-scoped client this check is the only gate

Public Read (RLS):

  • The clients_select_all policy lets any authenticated JWT read every client
  • Enables organization-wide client search; the pages and API above read through the service role instead, so the station floor is what keeps a member out

Ownership Write:

  • Users can only create clients attributed to themselves
  • Users can only update/delete their own clients
  • Prevents impersonation and unauthorized modifications

Admin Override:

  • super_admin role can update/delete any client
  • Ownership cannot be transferred (immutable created_by)
  • Maintains audit trail

Service Role:

  • Unrestricted access for webhooks and system operations
  • Bypasses all RLS restrictions

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 transfer
const clients = await db.getClients({
fields: ['id', 'first_name', 'last_name'],
limit: 100,
})
// ⚠️ SLOWER - All fields returned
const clients = await db.getClients({
limit: 100,
})

Always use pagination for lists:

// ✅ GOOD - Limited result set
const clients = await db.getClients({
limit: 10,
offset: 0,
})
// ❌ BAD - Fetches all records
const allClients = await db.getClients({
limit: 999999,
})

Use indexed fields for filtering and sorting:

// ✅ FAST - Uses idx_clients_created_by
const myClients = await db.getClients({
created_by: userId,
})
// ✅ FAST - Uses idx_clients_last_name
const 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: