Files
lunarfront-app/packages/backend/src/services/account.service.ts
Ryan Moon 760e995ae3 Implement file storage layer with local provider, upload/download API, tests
- StorageProvider interface with LocalProvider (S3 placeholder)
- File table with entity_type/entity_id references, content type, path
- POST /v1/files (multipart upload), GET /v1/files (list by entity),
  GET /v1/files/:id (metadata), GET /v1/files/serve/* (content),
  DELETE /v1/files/:id
- member_identifier drops base64 columns, uses file_id FKs
- File validation: type whitelist, size limits, per-entity max
- Fastify storage plugin injects provider into app
- 6 API tests for upload, list, get, delete, validation
- Test runner kills stale port before starting backend
2026-03-28 15:29:06 -05:00

672 lines
20 KiB
TypeScript

import { eq, and, sql, count, exists } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import {
accounts,
members,
memberIdentifiers,
accountProcessorLinks,
accountPaymentMethods,
taxExemptions,
} from '../db/schema/accounts.js'
import type {
AccountCreateInput,
AccountUpdateInput,
MemberIdentifierCreateInput,
MemberIdentifierUpdateInput,
ProcessorLinkCreateInput,
ProcessorLinkUpdateInput,
PaymentMethodCreateInput,
PaymentMethodUpdateInput,
TaxExemptionCreateInput,
TaxExemptionUpdateInput,
PaginationInput,
} from '@forte/shared/schemas'
import { isMinor, normalizeStateCode } from '@forte/shared/utils'
import {
withPagination,
withSort,
buildSearchCondition,
paginatedResponse,
} from '../utils/pagination.js'
async function generateUniqueNumber(
db: PostgresJsDatabase,
table: typeof accounts | typeof members,
column: typeof accounts.accountNumber | typeof members.memberNumber,
companyId: string,
companyIdColumn: typeof accounts.companyId,
): Promise<string> {
for (let attempt = 0; attempt < 10; attempt++) {
const num = String(Math.floor(100000 + Math.random() * 900000))
const [existing] = await db
.select({ id: table.id })
.from(table)
.where(and(eq(companyIdColumn, companyId), eq(column, num)))
.limit(1)
if (!existing) return num
}
// Fallback to 8 digits if 6-digit space is crowded
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)
const [account] = await db
.insert(accounts)
.values({
companyId,
accountNumber,
name: input.name,
email: input.email,
phone: input.phone,
address: normalizeAddress(input.address),
billingMode: input.billingMode,
notes: input.notes,
})
.returning()
return account
},
async getById(db: PostgresJsDatabase, companyId: string, id: string) {
const [account] = await db
.select()
.from(accounts)
.where(and(eq(accounts.id, id), eq(accounts.companyId, companyId)))
.limit(1)
return account ?? null
},
async update(db: PostgresJsDatabase, companyId: string, id: string, input: AccountUpdateInput) {
const [account] = await db
.update(accounts)
.set({ ...input, updatedAt: new Date() })
.where(and(eq(accounts.id, id), eq(accounts.companyId, companyId)))
.returning()
return account ?? null
},
async softDelete(db: PostgresJsDatabase, companyId: string, id: string) {
const [account] = await db
.update(accounts)
.set({ isActive: false, updatedAt: new Date() })
.where(and(eq(accounts.id, id), eq(accounts.companyId, companyId)))
.returning()
return account ?? null
},
async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) {
const baseWhere = and(eq(accounts.companyId, companyId), eq(accounts.isActive, true))
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> = {
name: accounts.name,
email: accounts.email,
created_at: accounts.createdAt,
account_number: accounts.accountNumber,
}
let query = db.select().from(accounts).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, accounts.name)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(accounts).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
}
export const MemberService = {
async create(
db: PostgresJsDatabase,
companyId: string,
input: {
accountId: string
firstName: string
lastName: string
dateOfBirth?: string
isMinor?: boolean
email?: string
phone?: string
address?: { street?: string; city?: string; state?: string; zip?: string }
notes?: string
},
) {
// isMinor: explicit flag wins, else derive from DOB, else false
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({
companyId,
memberNumber,
accountId: input.accountId,
firstName: input.firstName,
lastName: input.lastName,
dateOfBirth: input.dateOfBirth,
isMinor: minor,
email,
phone,
address,
notes: input.notes,
})
.returning()
if (account && !account.primaryMemberId) {
await db
.update(accounts)
.set({ primaryMemberId: member.id, updatedAt: new Date() })
.where(eq(accounts.id, input.accountId))
}
return member
},
async getById(db: PostgresJsDatabase, companyId: string, id: string) {
const [member] = await db
.select()
.from(members)
.where(and(eq(members.id, id), eq(members.companyId, companyId)))
.limit(1)
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,
accountId: string,
params: PaginationInput,
) {
const where = and(eq(members.companyId, companyId), eq(members.accountId, accountId))
const sortableColumns: Record<string, typeof members.firstName> = {
first_name: members.firstName,
last_name: members.lastName,
created_at: members.createdAt,
}
let query = db.select().from(members).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 update(
db: PostgresJsDatabase,
companyId: string,
id: string,
input: {
firstName?: string
lastName?: string
dateOfBirth?: string
isMinor?: boolean
email?: string
phone?: string
notes?: string
},
) {
const updates: Record<string, unknown> = { ...input, updatedAt: new Date() }
// 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)
}
const [member] = await db
.update(members)
.set(updates)
.where(and(eq(members.id, id), eq(members.companyId, companyId)))
.returning()
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)
.where(and(eq(members.id, id), eq(members.companyId, companyId)))
.returning()
return member ?? null
},
}
export const ProcessorLinkService = {
async create(db: PostgresJsDatabase, companyId: string, input: ProcessorLinkCreateInput) {
const [link] = await db
.insert(accountProcessorLinks)
.values({
companyId,
accountId: input.accountId,
processor: input.processor,
processorCustomerId: input.processorCustomerId,
})
.returning()
return link
},
async getById(db: PostgresJsDatabase, companyId: string, id: string) {
const [link] = await db
.select()
.from(accountProcessorLinks)
.where(and(eq(accountProcessorLinks.id, id), eq(accountProcessorLinks.companyId, companyId)))
.limit(1)
return link ?? null
},
async listByAccount(db: PostgresJsDatabase, companyId: string, accountId: string) {
return db
.select()
.from(accountProcessorLinks)
.where(
and(
eq(accountProcessorLinks.companyId, companyId),
eq(accountProcessorLinks.accountId, accountId),
),
)
},
async update(db: PostgresJsDatabase, companyId: string, id: string, input: ProcessorLinkUpdateInput) {
const [link] = await db
.update(accountProcessorLinks)
.set(input)
.where(and(eq(accountProcessorLinks.id, id), eq(accountProcessorLinks.companyId, companyId)))
.returning()
return link ?? null
},
async delete(db: PostgresJsDatabase, companyId: string, id: string) {
const [link] = await db
.delete(accountProcessorLinks)
.where(and(eq(accountProcessorLinks.id, id), eq(accountProcessorLinks.companyId, companyId)))
.returning()
return link ?? null
},
}
export const PaymentMethodService = {
async create(db: PostgresJsDatabase, companyId: string, input: PaymentMethodCreateInput) {
// If this is the default, unset any existing default for this account
if (input.isDefault) {
await db
.update(accountPaymentMethods)
.set({ isDefault: false })
.where(
and(
eq(accountPaymentMethods.companyId, companyId),
eq(accountPaymentMethods.accountId, input.accountId),
eq(accountPaymentMethods.isDefault, true),
),
)
}
const [method] = await db
.insert(accountPaymentMethods)
.values({
companyId,
accountId: input.accountId,
processor: input.processor,
processorPaymentMethodId: input.processorPaymentMethodId,
cardBrand: input.cardBrand,
lastFour: input.lastFour,
expMonth: input.expMonth,
expYear: input.expYear,
isDefault: input.isDefault,
})
.returning()
return method
},
async getById(db: PostgresJsDatabase, companyId: string, id: string) {
const [method] = await db
.select()
.from(accountPaymentMethods)
.where(and(eq(accountPaymentMethods.id, id), eq(accountPaymentMethods.companyId, companyId)))
.limit(1)
return method ?? null
},
async listByAccount(db: PostgresJsDatabase, companyId: string, accountId: string) {
return db
.select()
.from(accountPaymentMethods)
.where(
and(
eq(accountPaymentMethods.companyId, companyId),
eq(accountPaymentMethods.accountId, accountId),
),
)
},
async update(db: PostgresJsDatabase, companyId: string, id: string, input: PaymentMethodUpdateInput) {
// If setting as default, unset existing default
if (input.isDefault) {
const existing = await this.getById(db, companyId, id)
if (existing) {
await db
.update(accountPaymentMethods)
.set({ isDefault: false })
.where(
and(
eq(accountPaymentMethods.companyId, companyId),
eq(accountPaymentMethods.accountId, existing.accountId),
eq(accountPaymentMethods.isDefault, true),
),
)
}
}
const [method] = await db
.update(accountPaymentMethods)
.set(input)
.where(and(eq(accountPaymentMethods.id, id), eq(accountPaymentMethods.companyId, companyId)))
.returning()
return method ?? null
},
async delete(db: PostgresJsDatabase, companyId: string, id: string) {
const [method] = await db
.delete(accountPaymentMethods)
.where(and(eq(accountPaymentMethods.id, id), eq(accountPaymentMethods.companyId, companyId)))
.returning()
return method ?? null
},
}
export const TaxExemptionService = {
async create(db: PostgresJsDatabase, companyId: string, input: TaxExemptionCreateInput) {
const [exemption] = await db
.insert(taxExemptions)
.values({
companyId,
accountId: input.accountId,
certificateNumber: input.certificateNumber,
certificateType: input.certificateType,
issuingState: input.issuingState,
expiresAt: input.expiresAt,
notes: input.notes,
status: 'pending',
})
.returning()
return exemption
},
async getById(db: PostgresJsDatabase, companyId: string, id: string) {
const [exemption] = await db
.select()
.from(taxExemptions)
.where(and(eq(taxExemptions.id, id), eq(taxExemptions.companyId, companyId)))
.limit(1)
return exemption ?? null
},
async listByAccount(db: PostgresJsDatabase, companyId: string, accountId: string) {
return db
.select()
.from(taxExemptions)
.where(
and(
eq(taxExemptions.companyId, companyId),
eq(taxExemptions.accountId, accountId),
),
)
},
async update(db: PostgresJsDatabase, companyId: string, id: string, input: TaxExemptionUpdateInput) {
const [exemption] = await db
.update(taxExemptions)
.set({ ...input, updatedAt: new Date() })
.where(and(eq(taxExemptions.id, id), eq(taxExemptions.companyId, companyId)))
.returning()
return exemption ?? null
},
async approve(db: PostgresJsDatabase, companyId: string, id: string, approvedBy: string) {
const [exemption] = await db
.update(taxExemptions)
.set({
status: 'approved',
approvedBy,
approvedAt: new Date(),
updatedAt: new Date(),
})
.where(and(eq(taxExemptions.id, id), eq(taxExemptions.companyId, companyId)))
.returning()
return exemption ?? null
},
async revoke(db: PostgresJsDatabase, companyId: string, id: string, revokedBy: string, reason: string) {
const [exemption] = await db
.update(taxExemptions)
.set({
status: 'none',
revokedBy,
revokedAt: new Date(),
revokedReason: reason,
updatedAt: new Date(),
})
.where(and(eq(taxExemptions.id, id), eq(taxExemptions.companyId, companyId)))
.returning()
return exemption ?? null
},
}
export const MemberIdentifierService = {
async create(db: PostgresJsDatabase, companyId: string, input: MemberIdentifierCreateInput) {
// If setting as primary, unset existing primary for this member
if (input.isPrimary) {
await db
.update(memberIdentifiers)
.set({ isPrimary: false })
.where(
and(
eq(memberIdentifiers.memberId, input.memberId),
eq(memberIdentifiers.isPrimary, true),
),
)
}
const [identifier] = await db
.insert(memberIdentifiers)
.values({
companyId,
memberId: input.memberId,
type: input.type,
label: input.label,
value: input.value,
issuingAuthority: input.issuingAuthority,
issuedDate: input.issuedDate,
expiresAt: input.expiresAt,
imageFrontFileId: input.imageFrontFileId,
imageBackFileId: input.imageBackFileId,
notes: input.notes,
isPrimary: input.isPrimary,
})
.returning()
return identifier
},
async listByMember(db: PostgresJsDatabase, companyId: string, memberId: string) {
return db
.select()
.from(memberIdentifiers)
.where(
and(
eq(memberIdentifiers.companyId, companyId),
eq(memberIdentifiers.memberId, memberId),
),
)
},
async getById(db: PostgresJsDatabase, companyId: string, id: string) {
const [identifier] = await db
.select()
.from(memberIdentifiers)
.where(and(eq(memberIdentifiers.id, id), eq(memberIdentifiers.companyId, companyId)))
.limit(1)
return identifier ?? null
},
async update(db: PostgresJsDatabase, companyId: string, id: string, input: MemberIdentifierUpdateInput) {
if (input.isPrimary) {
const existing = await this.getById(db, companyId, id)
if (existing) {
await db
.update(memberIdentifiers)
.set({ isPrimary: false })
.where(
and(
eq(memberIdentifiers.memberId, existing.memberId),
eq(memberIdentifiers.isPrimary, true),
),
)
}
}
const [identifier] = await db
.update(memberIdentifiers)
.set({ ...input, updatedAt: new Date() })
.where(and(eq(memberIdentifiers.id, id), eq(memberIdentifiers.companyId, companyId)))
.returning()
return identifier ?? null
},
async delete(db: PostgresJsDatabase, companyId: string, id: string) {
const [identifier] = await db
.delete(memberIdentifiers)
.where(and(eq(memberIdentifiers.id, id), eq(memberIdentifiers.companyId, companyId)))
.returning()
return identifier ?? null
},
}