Add top-level members list, primary member on account, member move, combined create flows

- GET /v1/members with search across all members (includes account name)
- POST /members/:id/move with optional accountId (creates new account if omitted)
- primary_member_id on account table, auto-set when first member added
- isMinor flag on member create (manual override when no DOB provided)
- Account search now includes member names
- New account form includes primary contact fields, auto-generates name
- Members page in sidebar with global search
This commit is contained in:
Ryan Moon
2026-03-28 09:08:06 -05:00
parent 7c64a928e1
commit 572af05a3f
16 changed files with 796 additions and 77 deletions

View File

@@ -0,0 +1,2 @@
-- Add primary_member_id to account table
ALTER TABLE "account" ADD COLUMN "primary_member_id" uuid;

View File

@@ -57,6 +57,13 @@
"when": 1774662300000,
"tag": "0007_accounts_lookups",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1774702800000,
"tag": "0008_member_primary_account",
"breakpoints": true
}
]
}

View File

@@ -31,6 +31,7 @@ export const accounts = pgTable('account', {
zip?: string
}>(),
billingMode: billingModeEnum('billing_mode').notNull().default('consolidated'),
primaryMemberId: uuid('primary_member_id'),
notes: text('notes'),
isActive: boolean('is_active').notNull().default(true),
legacyId: varchar('legacy_id', { length: 255 }),

View File

@@ -63,7 +63,15 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.send(account)
})
// --- Members ---
// --- Members (top-level) ---
app.get('/members', { preHandler: [app.authenticate] }, async (request, reply) => {
const params = PaginationSchema.parse(request.query)
const result = await MemberService.list(app.db, request.companyId, params)
return reply.send(result)
})
// --- Members (scoped to account) ---
app.post('/accounts/:accountId/members', { preHandler: [app.authenticate] }, async (request, reply) => {
const { accountId } = request.params as { accountId: string }
@@ -100,6 +108,27 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.send(member)
})
app.post('/members/:id/move', { preHandler: [app.authenticate] }, async (request, reply) => {
const { id } = request.params as { id: string }
const { accountId } = (request.body as { accountId?: string }) ?? {}
let targetAccountId = accountId
// If no accountId provided, create a new account from the member's name
if (!targetAccountId) {
const member = await MemberService.getById(app.db, request.companyId, id)
if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } })
const account = await AccountService.create(app.db, request.companyId, {
name: `${member.firstName} ${member.lastName}`,
})
targetAccountId = account.id
}
const member = await MemberService.move(app.db, request.companyId, id, targetAccountId)
if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } })
return reply.send(member)
})
app.delete('/members/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
const { id } = request.params as { id: string }
const member = await MemberService.delete(app.db, request.companyId, id)

View File

@@ -1,4 +1,4 @@
import { eq, and, sql, count } from 'drizzle-orm'
import { eq, and, sql, count, exists } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import {
accounts,
@@ -77,10 +77,26 @@ export const AccountService = {
async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) {
const baseWhere = and(eq(accounts.companyId, companyId), eq(accounts.isActive, true))
const searchCondition = params.q
const accountSearch = params.q
? buildSearchCondition(params.q, [accounts.name, accounts.email, accounts.phone, accounts.accountNumber])
: undefined
// Also search across member names on this account
const memberSearch = params.q
? exists(
db.select({ id: members.id })
.from(members)
.where(and(
eq(members.accountId, accounts.id),
buildSearchCondition(params.q, [members.firstName, members.lastName, members.email])!,
)),
)
: undefined
const searchCondition = accountSearch && memberSearch
? sql`(${accountSearch} OR ${memberSearch})`
: undefined
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, typeof accounts.name> = {
@@ -112,12 +128,15 @@ export const MemberService = {
firstName: string
lastName: string
dateOfBirth?: string
isMinor?: boolean
isPrimary?: boolean
email?: string
phone?: string
notes?: string
},
) {
const minor = input.dateOfBirth ? isMinor(input.dateOfBirth) : false
// isMinor: explicit flag wins, else derive from DOB, else false
const minor = input.isMinor ?? (input.dateOfBirth ? isMinor(input.dateOfBirth) : false)
const [member] = await db
.insert(members)
@@ -134,6 +153,19 @@ export const MemberService = {
})
.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)
.set({ primaryMemberId: member.id, updatedAt: new Date() })
.where(eq(accounts.id, input.accountId))
}
return member
},
@@ -147,6 +179,53 @@ export const MemberService = {
return member ?? null
},
async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) {
const baseWhere = eq(members.companyId, companyId)
const searchCondition = params.q
? buildSearchCondition(params.q, [members.firstName, members.lastName, members.email, members.phone])
: undefined
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, typeof members.firstName> = {
first_name: members.firstName,
last_name: members.lastName,
email: members.email,
created_at: members.createdAt,
}
let query = db.select({
id: members.id,
accountId: members.accountId,
companyId: members.companyId,
firstName: members.firstName,
lastName: members.lastName,
dateOfBirth: members.dateOfBirth,
isMinor: members.isMinor,
email: members.email,
phone: members.phone,
notes: members.notes,
legacyId: members.legacyId,
createdAt: members.createdAt,
updatedAt: members.updatedAt,
accountName: accounts.name,
})
.from(members)
.leftJoin(accounts, eq(members.accountId, accounts.id))
.where(where)
.$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, members.lastName)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(members).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
async listByAccount(
db: PostgresJsDatabase,
companyId: string,
@@ -181,13 +260,18 @@ export const MemberService = {
firstName?: string
lastName?: string
dateOfBirth?: string
isMinor?: boolean
email?: string
phone?: string
notes?: string
},
) {
const updates: Record<string, unknown> = { ...input, updatedAt: new Date() }
if (input.dateOfBirth) {
// isMinor: explicit flag wins, else derive from DOB if provided
if (input.isMinor !== undefined) {
updates.isMinor = input.isMinor
} else if (input.dateOfBirth) {
updates.isMinor = isMinor(input.dateOfBirth)
}
@@ -200,6 +284,32 @@ export const MemberService = {
return member ?? null
},
async move(db: PostgresJsDatabase, companyId: string, memberId: string, targetAccountId: string) {
const member = await this.getById(db, companyId, memberId)
if (!member) return null
const [updated] = await db
.update(members)
.set({ accountId: targetAccountId, updatedAt: new Date() })
.where(and(eq(members.id, memberId), eq(members.companyId, companyId)))
.returning()
// If target account has no primary, set this member
const [targetAccount] = await db
.select()
.from(accounts)
.where(and(eq(accounts.id, targetAccountId), eq(accounts.companyId, companyId)))
.limit(1)
if (targetAccount && !targetAccount.primaryMemberId) {
await db
.update(accounts)
.set({ primaryMemberId: memberId, updatedAt: new Date() })
.where(eq(accounts.id, targetAccountId))
}
return updated
},
async delete(db: PostgresJsDatabase, companyId: string, id: string) {
const [member] = await db
.delete(members)