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:
@@ -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()
|
||||||
|
);
|
||||||
@@ -71,6 +71,13 @@
|
|||||||
"when": 1774703400000,
|
"when": 1774703400000,
|
||||||
"tag": "0009_member_number",
|
"tag": "0009_member_number",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774704000000,
|
||||||
|
"tag": "0010_member_identifiers",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -62,6 +62,31 @@ export const members = pgTable('member', {
|
|||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
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 processorEnum = pgEnum('payment_processor', ['stripe', 'global_payments'])
|
||||||
|
|
||||||
export const accountProcessorLinks = pgTable('account_processor_link', {
|
export const accountProcessorLinks = pgTable('account_processor_link', {
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ import {
|
|||||||
PaymentMethodUpdateSchema,
|
PaymentMethodUpdateSchema,
|
||||||
TaxExemptionCreateSchema,
|
TaxExemptionCreateSchema,
|
||||||
TaxExemptionUpdateSchema,
|
TaxExemptionUpdateSchema,
|
||||||
|
MemberIdentifierCreateSchema,
|
||||||
|
MemberIdentifierUpdateSchema,
|
||||||
} from '@forte/shared/schemas'
|
} from '@forte/shared/schemas'
|
||||||
import {
|
import {
|
||||||
AccountService,
|
AccountService,
|
||||||
MemberService,
|
MemberService,
|
||||||
|
MemberIdentifierService,
|
||||||
ProcessorLinkService,
|
ProcessorLinkService,
|
||||||
PaymentMethodService,
|
PaymentMethodService,
|
||||||
TaxExemptionService,
|
TaxExemptionService,
|
||||||
@@ -129,6 +132,42 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
return reply.send(member)
|
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) => {
|
app.delete('/members/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||||
const { id } = request.params as { id: string }
|
const { id } = request.params as { id: string }
|
||||||
const member = await MemberService.delete(app.db, request.companyId, id)
|
const member = await MemberService.delete(app.db, request.companyId, id)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
|||||||
import {
|
import {
|
||||||
accounts,
|
accounts,
|
||||||
members,
|
members,
|
||||||
|
memberIdentifiers,
|
||||||
accountProcessorLinks,
|
accountProcessorLinks,
|
||||||
accountPaymentMethods,
|
accountPaymentMethods,
|
||||||
taxExemptions,
|
taxExemptions,
|
||||||
@@ -10,6 +11,8 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
AccountCreateInput,
|
AccountCreateInput,
|
||||||
AccountUpdateInput,
|
AccountUpdateInput,
|
||||||
|
MemberIdentifierCreateInput,
|
||||||
|
MemberIdentifierUpdateInput,
|
||||||
ProcessorLinkCreateInput,
|
ProcessorLinkCreateInput,
|
||||||
ProcessorLinkUpdateInput,
|
ProcessorLinkUpdateInput,
|
||||||
PaymentMethodCreateInput,
|
PaymentMethodCreateInput,
|
||||||
@@ -564,3 +567,92 @@ export const TaxExemptionService = {
|
|||||||
return exemption ?? null
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,29 @@ export const AccountSearchSchema = z.object({
|
|||||||
q: z.string().min(1).max(255),
|
q: z.string().min(1).max(255),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Member Identifier ---
|
||||||
|
|
||||||
|
export const IdentifierType = z.enum(['drivers_license', 'passport', 'school_id'])
|
||||||
|
export type IdentifierType = z.infer<typeof IdentifierType>
|
||||||
|
|
||||||
|
export const MemberIdentifierCreateSchema = z.object({
|
||||||
|
memberId: z.string().uuid(),
|
||||||
|
type: IdentifierType,
|
||||||
|
label: z.string().max(100).optional(),
|
||||||
|
value: z.string().min(1).max(255),
|
||||||
|
issuingAuthority: z.string().max(255).optional(),
|
||||||
|
issuedDate: z.string().date().optional(),
|
||||||
|
expiresAt: z.string().date().optional(),
|
||||||
|
imageFrontUrl: z.string().max(500).optional(),
|
||||||
|
imageBackUrl: z.string().max(500).optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
isPrimary: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
export type MemberIdentifierCreateInput = z.infer<typeof MemberIdentifierCreateSchema>
|
||||||
|
|
||||||
|
export const MemberIdentifierUpdateSchema = MemberIdentifierCreateSchema.omit({ memberId: true }).partial()
|
||||||
|
export type MemberIdentifierUpdateInput = z.infer<typeof MemberIdentifierUpdateSchema>
|
||||||
|
|
||||||
// --- Account Processor Link ---
|
// --- Account Processor Link ---
|
||||||
|
|
||||||
export const ProcessorLinkCreateSchema = z.object({
|
export const ProcessorLinkCreateSchema = z.object({
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export {
|
|||||||
MemberCreateSchema,
|
MemberCreateSchema,
|
||||||
MemberUpdateSchema,
|
MemberUpdateSchema,
|
||||||
AccountSearchSchema,
|
AccountSearchSchema,
|
||||||
|
MemberIdentifierCreateSchema,
|
||||||
|
MemberIdentifierUpdateSchema,
|
||||||
ProcessorLinkCreateSchema,
|
ProcessorLinkCreateSchema,
|
||||||
ProcessorLinkUpdateSchema,
|
ProcessorLinkUpdateSchema,
|
||||||
PaymentMethodCreateSchema,
|
PaymentMethodCreateSchema,
|
||||||
@@ -27,6 +29,8 @@ export type {
|
|||||||
AccountUpdateInput,
|
AccountUpdateInput,
|
||||||
MemberCreateInput,
|
MemberCreateInput,
|
||||||
MemberUpdateInput,
|
MemberUpdateInput,
|
||||||
|
MemberIdentifierCreateInput,
|
||||||
|
MemberIdentifierUpdateInput,
|
||||||
ProcessorLinkCreateInput,
|
ProcessorLinkCreateInput,
|
||||||
ProcessorLinkUpdateInput,
|
ProcessorLinkUpdateInput,
|
||||||
PaymentMethodCreateInput,
|
PaymentMethodCreateInput,
|
||||||
|
|||||||
Reference in New Issue
Block a user