Add member address, state normalization, account inheritance, fix member form

- Address field on member table (jsonb, same format as account)
- Members inherit email, phone, address from account when not provided
- State normalization: "Texas" → "TX", "california" → "CA" via shared util
- Member form drops zodResolver to fix optional field validation flashing
- Account name auto-format: "First Last - Account"
- US state lookup with full name + code support
This commit is contained in:
Ryan Moon
2026-03-28 12:31:02 -05:00
parent ce2a61ced9
commit b9e984cfa3
10 changed files with 243 additions and 28 deletions

View File

@@ -1,7 +1,4 @@
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { MemberCreateSchema } from '@forte/shared/schemas'
import type { MemberCreateInput } from '@forte/shared/schemas'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -21,34 +18,40 @@ export function MemberForm({ accountId, defaultValues, onSubmit, loading }: Memb
handleSubmit,
watch,
formState: { errors },
} = useForm<MemberCreateInput>({
resolver: zodResolver(MemberCreateSchema),
} = useForm({
defaultValues: {
accountId,
firstName: defaultValues?.firstName ?? '',
lastName: defaultValues?.lastName ?? '',
dateOfBirth: defaultValues?.dateOfBirth ?? undefined,
isMinor: defaultValues?.isMinor ?? undefined,
email: defaultValues?.email ?? undefined,
phone: defaultValues?.phone ?? undefined,
notes: defaultValues?.notes ?? undefined,
dateOfBirth: defaultValues?.dateOfBirth ?? '',
isMinor: defaultValues?.isMinor ?? false,
email: defaultValues?.email ?? '',
phone: defaultValues?.phone ?? '',
notes: defaultValues?.notes ?? '',
},
})
const dateOfBirth = watch('dateOfBirth')
function handleFormSubmit(data: Record<string, unknown>) {
// Strip empty strings to undefined for the API
const cleaned: Record<string, unknown> = { accountId }
for (const [key, value] of Object.entries(data)) {
cleaned[key] = value === '' ? undefined : value
}
onSubmit(cleaned)
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<input type="hidden" {...register('accountId')} />
<form onSubmit={handleSubmit(handleFormSubmit)} noValidate className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">First Name *</Label>
<Input id="firstName" {...register('firstName')} />
<Input id="firstName" {...register('firstName', { required: 'Required' })} />
{errors.firstName && <p className="text-sm text-destructive">{errors.firstName.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Last Name *</Label>
<Input id="lastName" {...register('lastName')} />
<Input id="lastName" {...register('lastName', { required: 'Required' })} />
{errors.lastName && <p className="text-sm text-destructive">{errors.lastName.message}</p>}
</div>
</div>

View File

@@ -28,7 +28,7 @@ function NewAccountPage() {
// Auto-generate account name from member if not provided
if (!accountData.name && memberFirstName && memberLastName) {
accountData.name = `${memberLastName}, ${memberFirstName}`
accountData.name = `${memberFirstName} ${memberLastName} - Account`
}
const account = await accountMutations.create(accountData)

View File

@@ -255,4 +255,106 @@ describe('Top-level member routes', () => {
expect(res.json().dateOfBirth).toBeNull()
})
})
describe('Account inheritance', () => {
it('inherits email, phone from account when not provided', async () => {
const acctRes = await app.inject({
method: 'POST',
url: '/v1/accounts',
headers: { authorization: `Bearer ${token}` },
payload: { name: 'Email Family', email: 'family@test.com', phone: '555-1234' },
})
const res = await app.inject({
method: 'POST',
url: `/v1/accounts/${acctRes.json().id}/members`,
headers: { authorization: `Bearer ${token}` },
payload: { firstName: 'Child', lastName: 'One' },
})
expect(res.statusCode).toBe(201)
expect(res.json().email).toBe('family@test.com')
expect(res.json().phone).toBe('555-1234')
})
it('uses member email when provided instead of account', async () => {
const acctRes = await app.inject({
method: 'POST',
url: '/v1/accounts',
headers: { authorization: `Bearer ${token}` },
payload: { name: 'Override Family', email: 'family@test.com' },
})
const res = await app.inject({
method: 'POST',
url: `/v1/accounts/${acctRes.json().id}/members`,
headers: { authorization: `Bearer ${token}` },
payload: { firstName: 'Adult', lastName: 'Own', email: 'own@test.com' },
})
expect(res.statusCode).toBe(201)
expect(res.json().email).toBe('own@test.com')
})
it('inherits address from account when not provided', async () => {
const acctRes = await app.inject({
method: 'POST',
url: '/v1/accounts',
headers: { authorization: `Bearer ${token}` },
payload: {
name: 'Address Family',
address: { street: '123 Main St', city: 'Austin', state: 'TX', zip: '78701' },
},
})
const res = await app.inject({
method: 'POST',
url: `/v1/accounts/${acctRes.json().id}/members`,
headers: { authorization: `Bearer ${token}` },
payload: { firstName: 'Kid', lastName: 'Home' },
})
expect(res.statusCode).toBe(201)
expect(res.json().address.city).toBe('Austin')
expect(res.json().address.state).toBe('TX')
})
})
describe('State normalization', () => {
it('normalizes state name to code on account', async () => {
const res = await app.inject({
method: 'POST',
url: '/v1/accounts',
headers: { authorization: `Bearer ${token}` },
payload: { name: 'State Test', address: { state: 'texas' } },
})
expect(res.statusCode).toBe(201)
expect(res.json().address.state).toBe('TX')
})
it('normalizes full state name to code', async () => {
const res = await app.inject({
method: 'POST',
url: '/v1/accounts',
headers: { authorization: `Bearer ${token}` },
payload: { name: 'State Name Test', address: { state: 'California' } },
})
expect(res.statusCode).toBe(201)
expect(res.json().address.state).toBe('CA')
})
it('leaves unrecognized state codes as-is', async () => {
const res = await app.inject({
method: 'POST',
url: '/v1/accounts',
headers: { authorization: `Bearer ${token}` },
payload: { name: 'Foreign Test', address: { state: 'ON' } },
})
expect(res.statusCode).toBe(201)
expect(res.json().address.state).toBe('ON')
})
})
})

View File

@@ -0,0 +1,2 @@
-- Add address to member table
ALTER TABLE "member" ADD COLUMN "address" jsonb;

View File

@@ -78,6 +78,13 @@
"when": 1774704000000,
"tag": "0010_member_identifiers",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1774710000000,
"tag": "0011_member_address",
"breakpoints": true
}
]
}

View File

@@ -56,6 +56,12 @@ export const members = pgTable('member', {
isMinor: boolean('is_minor').notNull().default(false),
email: varchar('email', { length: 255 }),
phone: varchar('phone', { length: 50 }),
address: jsonb('address').$type<{
street?: string
city?: string
state?: string
zip?: string
}>(),
notes: text('notes'),
legacyId: varchar('legacy_id', { length: 255 }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),

View File

@@ -21,7 +21,7 @@ import type {
TaxExemptionUpdateInput,
PaginationInput,
} from '@forte/shared/schemas'
import { isMinor } from '@forte/shared/utils'
import { isMinor, normalizeStateCode } from '@forte/shared/utils'
import {
withPagination,
withSort,
@@ -49,6 +49,14 @@ async function generateUniqueNumber(
return String(Math.floor(10000000 + Math.random() * 90000000))
}
function normalizeAddress(address?: { street?: string; city?: string; state?: string; zip?: string } | null) {
if (!address) return address
return {
...address,
state: address.state ? (normalizeStateCode(address.state) ?? address.state) : address.state,
}
}
export const AccountService = {
async create(db: PostgresJsDatabase, companyId: string, input: AccountCreateInput) {
const accountNumber = await generateUniqueNumber(db, accounts, accounts.accountNumber, companyId, accounts.companyId)
@@ -61,7 +69,7 @@ export const AccountService = {
name: input.name,
email: input.email,
phone: input.phone,
address: input.address,
address: normalizeAddress(input.address),
billingMode: input.billingMode,
notes: input.notes,
})
@@ -155,9 +163,9 @@ export const MemberService = {
lastName: string
dateOfBirth?: string
isMinor?: boolean
isPrimary?: boolean
email?: string
phone?: string
address?: { street?: string; city?: string; state?: string; zip?: string }
notes?: string
},
) {
@@ -165,6 +173,17 @@ export const MemberService = {
const minor = input.isMinor ?? (input.dateOfBirth ? isMinor(input.dateOfBirth) : false)
const memberNumber = await generateUniqueNumber(db, members, members.memberNumber, companyId, members.companyId)
// Inherit email, phone, address from account if not provided
const [account] = await db
.select()
.from(accounts)
.where(eq(accounts.id, input.accountId))
.limit(1)
const email = input.email ?? account?.email ?? undefined
const phone = input.phone ?? account?.phone ?? undefined
const address = normalizeAddress(input.address ?? account?.address ?? undefined)
const [member] = await db
.insert(members)
.values({
@@ -175,18 +194,12 @@ export const MemberService = {
lastName: input.lastName,
dateOfBirth: input.dateOfBirth,
isMinor: minor,
email: input.email,
phone: input.phone,
email,
phone,
address,
notes: input.notes,
})
.returning()
// Auto-set as primary if this is the only member on the account
const [account] = await db
.select()
.from(accounts)
.where(eq(accounts.id, input.accountId))
.limit(1)
if (account && !account.primaryMemberId) {
await db
.update(accounts)

View File

@@ -0,0 +1,43 @@
import { describe, it, expect } from 'bun:test'
import { normalizeStateCode, isValidStateCode } from '../../src/utils/states.js'
describe('normalizeStateCode', () => {
it('normalizes two-letter codes', () => {
expect(normalizeStateCode('tx')).toBe('TX')
expect(normalizeStateCode('TX')).toBe('TX')
expect(normalizeStateCode('Tx')).toBe('TX')
expect(normalizeStateCode(' ca ')).toBe('CA')
})
it('normalizes full state names', () => {
expect(normalizeStateCode('Texas')).toBe('TX')
expect(normalizeStateCode('texas')).toBe('TX')
expect(normalizeStateCode('CALIFORNIA')).toBe('CA')
expect(normalizeStateCode('New York')).toBe('NY')
expect(normalizeStateCode('north carolina')).toBe('NC')
})
it('returns null for invalid input', () => {
expect(normalizeStateCode('ZZ')).toBeNull()
expect(normalizeStateCode('Narnia')).toBeNull()
expect(normalizeStateCode('')).toBeNull()
})
it('handles territories', () => {
expect(normalizeStateCode('DC')).toBe('DC')
expect(normalizeStateCode('Puerto Rico')).toBe('PR')
expect(normalizeStateCode('GU')).toBe('GU')
})
})
describe('isValidStateCode', () => {
it('returns true for valid codes', () => {
expect(isValidStateCode('TX')).toBe(true)
expect(isValidStateCode('ca')).toBe(true)
})
it('returns false for invalid codes', () => {
expect(isValidStateCode('ZZ')).toBe(false)
expect(isValidStateCode('Texas')).toBe(false)
})
})

View File

@@ -1,2 +1,3 @@
export { formatCurrency, roundCurrency, toCents, toDollars } from './currency.js'
export { capBillingDay, todayISO, isMinor } from './dates.js'
export { US_STATES, STATE_CODES, isValidStateCode, normalizeStateCode } from './states.js'

View File

@@ -0,0 +1,38 @@
export const US_STATES: Record<string, string> = {
AL: 'Alabama', AK: 'Alaska', AZ: 'Arizona', AR: 'Arkansas', CA: 'California',
CO: 'Colorado', CT: 'Connecticut', DE: 'Delaware', FL: 'Florida', GA: 'Georgia',
HI: 'Hawaii', ID: 'Idaho', IL: 'Illinois', IN: 'Indiana', IA: 'Iowa',
KS: 'Kansas', KY: 'Kentucky', LA: 'Louisiana', ME: 'Maine', MD: 'Maryland',
MA: 'Massachusetts', MI: 'Michigan', MN: 'Minnesota', MS: 'Mississippi', MO: 'Missouri',
MT: 'Montana', NE: 'Nebraska', NV: 'Nevada', NH: 'New Hampshire', NJ: 'New Jersey',
NM: 'New Mexico', NY: 'New York', NC: 'North Carolina', ND: 'North Dakota', OH: 'Ohio',
OK: 'Oklahoma', OR: 'Oregon', PA: 'Pennsylvania', RI: 'Rhode Island', SC: 'South Carolina',
SD: 'South Dakota', TN: 'Tennessee', TX: 'Texas', UT: 'Utah', VT: 'Vermont',
VA: 'Virginia', WA: 'Washington', WV: 'West Virginia', WI: 'Wisconsin', WY: 'Wyoming',
DC: 'District of Columbia', PR: 'Puerto Rico', VI: 'Virgin Islands', GU: 'Guam',
AS: 'American Samoa', MP: 'Northern Mariana Islands',
}
export const STATE_CODES = Object.keys(US_STATES)
export function isValidStateCode(code: string): boolean {
return code.toUpperCase().trim() in US_STATES
}
const STATE_NAME_TO_CODE: Record<string, string> = Object.fromEntries(
Object.entries(US_STATES).map(([code, name]) => [name.toLowerCase(), code]),
)
export function normalizeStateCode(input: string): string | null {
const trimmed = input.trim()
const upper = trimmed.toUpperCase()
// Direct code match (TX, CA, etc.)
if (upper in US_STATES) return upper
// Full name match (Texas, california, etc.)
const byName = STATE_NAME_TO_CODE[trimmed.toLowerCase()]
if (byName) return byName
return null
}