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:
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add address to member table
|
||||
ALTER TABLE "member" ADD COLUMN "address" jsonb;
|
||||
@@ -78,6 +78,13 @@
|
||||
"when": 1774704000000,
|
||||
"tag": "0010_member_identifiers",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1774710000000,
|
||||
"tag": "0011_member_address",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user