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

@@ -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)