diff --git a/packages/admin/src/components/accounts/member-form.tsx b/packages/admin/src/components/accounts/member-form.tsx index a2bf035..1f7eb53 100644 --- a/packages/admin/src/components/accounts/member-form.tsx +++ b/packages/admin/src/components/accounts/member-form.tsx @@ -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({ - 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) { + // Strip empty strings to undefined for the API + const cleaned: Record = { accountId } + for (const [key, value] of Object.entries(data)) { + cleaned[key] = value === '' ? undefined : value + } + onSubmit(cleaned) + } + return ( -
- +
- + {errors.firstName &&

{errors.firstName.message}

}
- + {errors.lastName &&

{errors.lastName.message}

}
diff --git a/packages/admin/src/routes/_authenticated/accounts/new.tsx b/packages/admin/src/routes/_authenticated/accounts/new.tsx index 228563d..c384d11 100644 --- a/packages/admin/src/routes/_authenticated/accounts/new.tsx +++ b/packages/admin/src/routes/_authenticated/accounts/new.tsx @@ -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) diff --git a/packages/backend/__tests__/routes/v1/members.test.ts b/packages/backend/__tests__/routes/v1/members.test.ts index 6977c2b..6124dc1 100644 --- a/packages/backend/__tests__/routes/v1/members.test.ts +++ b/packages/backend/__tests__/routes/v1/members.test.ts @@ -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') + }) + }) }) diff --git a/packages/backend/src/db/migrations/0011_member_address.sql b/packages/backend/src/db/migrations/0011_member_address.sql new file mode 100644 index 0000000..bc3a328 --- /dev/null +++ b/packages/backend/src/db/migrations/0011_member_address.sql @@ -0,0 +1,2 @@ +-- Add address to member table +ALTER TABLE "member" ADD COLUMN "address" jsonb; diff --git a/packages/backend/src/db/migrations/meta/_journal.json b/packages/backend/src/db/migrations/meta/_journal.json index 3a5f5c5..9186a9b 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1774704000000, "tag": "0010_member_identifiers", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1774710000000, + "tag": "0011_member_address", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/src/db/schema/accounts.ts b/packages/backend/src/db/schema/accounts.ts index 8a30600..3183d39 100644 --- a/packages/backend/src/db/schema/accounts.ts +++ b/packages/backend/src/db/schema/accounts.ts @@ -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(), diff --git a/packages/backend/src/services/account.service.ts b/packages/backend/src/services/account.service.ts index f095f66..0311957 100644 --- a/packages/backend/src/services/account.service.ts +++ b/packages/backend/src/services/account.service.ts @@ -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) diff --git a/packages/shared/__tests__/utils/states.test.ts b/packages/shared/__tests__/utils/states.test.ts new file mode 100644 index 0000000..f7c93e7 --- /dev/null +++ b/packages/shared/__tests__/utils/states.test.ts @@ -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) + }) +}) diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 8d8b12b..952a3fb 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -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' diff --git a/packages/shared/src/utils/states.ts b/packages/shared/src/utils/states.ts new file mode 100644 index 0000000..93d687b --- /dev/null +++ b/packages/shared/src/utils/states.ts @@ -0,0 +1,38 @@ +export const US_STATES: Record = { + 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 = 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 +}