Add member identifiers table for ID documents (DL, passport, school ID)

member_identifier table with type, value, issuing authority, expiry,
front/back image storage (base64 in Postgres), primary flag. CRUD
endpoints under /members/:memberId/identifiers. Zod schemas with
constrained type enum.
This commit is contained in:
Ryan Moon
2026-03-28 09:38:01 -05:00
parent 727275af59
commit c7b460c0bf
7 changed files with 208 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
-- Member identity documents (driver's license, passport, school ID, etc.)
CREATE TABLE IF NOT EXISTS "member_identifier" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"member_id" uuid NOT NULL REFERENCES "member"("id"),
"company_id" uuid NOT NULL REFERENCES "company"("id"),
"type" varchar(50) NOT NULL,
"label" varchar(100),
"value" varchar(255) NOT NULL,
"issuing_authority" varchar(255),
"issued_date" date,
"expires_at" date,
"image_front" text,
"image_back" text,
"notes" text,
"is_primary" boolean NOT NULL DEFAULT false,
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
"updated_at" timestamp with time zone NOT NULL DEFAULT now()
);

View File

@@ -71,6 +71,13 @@
"when": 1774703400000,
"tag": "0009_member_number",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1774704000000,
"tag": "0010_member_identifiers",
"breakpoints": true
}
]
}

View File

@@ -62,6 +62,31 @@ export const members = pgTable('member', {
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
export const memberIdentifiers = pgTable('member_identifier', {
id: uuid('id').primaryKey().defaultRandom(),
memberId: uuid('member_id')
.notNull()
.references(() => members.id),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id),
type: varchar('type', { length: 50 }).notNull(),
label: varchar('label', { length: 100 }),
value: varchar('value', { length: 255 }).notNull(),
issuingAuthority: varchar('issuing_authority', { length: 255 }),
issuedDate: date('issued_date'),
expiresAt: date('expires_at'),
imageFront: text('image_front'),
imageBack: text('image_back'),
notes: text('notes'),
isPrimary: boolean('is_primary').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
export type MemberIdentifier = typeof memberIdentifiers.$inferSelect
export type MemberIdentifierInsert = typeof memberIdentifiers.$inferInsert
export const processorEnum = pgEnum('payment_processor', ['stripe', 'global_payments'])
export const accountProcessorLinks = pgTable('account_processor_link', {

View File

@@ -11,10 +11,13 @@ import {
PaymentMethodUpdateSchema,
TaxExemptionCreateSchema,
TaxExemptionUpdateSchema,
MemberIdentifierCreateSchema,
MemberIdentifierUpdateSchema,
} from '@forte/shared/schemas'
import {
AccountService,
MemberService,
MemberIdentifierService,
ProcessorLinkService,
PaymentMethodService,
TaxExemptionService,
@@ -129,6 +132,42 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.send(member)
})
// --- Member Identifiers ---
app.post('/members/:memberId/identifiers', { preHandler: [app.authenticate] }, async (request, reply) => {
const { memberId } = request.params as { memberId: string }
const parsed = MemberIdentifierCreateSchema.safeParse({ ...(request.body as object), memberId })
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const identifier = await MemberIdentifierService.create(app.db, request.companyId, parsed.data)
return reply.status(201).send(identifier)
})
app.get('/members/:memberId/identifiers', { preHandler: [app.authenticate] }, async (request, reply) => {
const { memberId } = request.params as { memberId: string }
const identifiers = await MemberIdentifierService.listByMember(app.db, request.companyId, memberId)
return reply.send({ data: identifiers })
})
app.patch('/identifiers/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = MemberIdentifierUpdateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const identifier = await MemberIdentifierService.update(app.db, request.companyId, id, parsed.data)
if (!identifier) return reply.status(404).send({ error: { message: 'Identifier not found', statusCode: 404 } })
return reply.send(identifier)
})
app.delete('/identifiers/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
const { id } = request.params as { id: string }
const identifier = await MemberIdentifierService.delete(app.db, request.companyId, id)
if (!identifier) return reply.status(404).send({ error: { message: 'Identifier not found', statusCode: 404 } })
return reply.send(identifier)
})
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

@@ -3,6 +3,7 @@ import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import {
accounts,
members,
memberIdentifiers,
accountProcessorLinks,
accountPaymentMethods,
taxExemptions,
@@ -10,6 +11,8 @@ import {
import type {
AccountCreateInput,
AccountUpdateInput,
MemberIdentifierCreateInput,
MemberIdentifierUpdateInput,
ProcessorLinkCreateInput,
ProcessorLinkUpdateInput,
PaymentMethodCreateInput,
@@ -564,3 +567,92 @@ export const TaxExemptionService = {
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,
imageFrontUrl: input.imageFrontUrl,
imageBackUrl: input.imageBackUrl,
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
},
}