diff --git a/packages/backend/src/db/migrations/0010_member_identifiers.sql b/packages/backend/src/db/migrations/0010_member_identifiers.sql new file mode 100644 index 0000000..7c5da5c --- /dev/null +++ b/packages/backend/src/db/migrations/0010_member_identifiers.sql @@ -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() +); diff --git a/packages/backend/src/db/migrations/meta/_journal.json b/packages/backend/src/db/migrations/meta/_journal.json index e26a400..3a5f5c5 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1774703400000, "tag": "0009_member_number", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1774704000000, + "tag": "0010_member_identifiers", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/src/db/schema/accounts.ts b/packages/backend/src/db/schema/accounts.ts index b5b0970..8a30600 100644 --- a/packages/backend/src/db/schema/accounts.ts +++ b/packages/backend/src/db/schema/accounts.ts @@ -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', { diff --git a/packages/backend/src/routes/v1/accounts.ts b/packages/backend/src/routes/v1/accounts.ts index c0078f5..d63b133 100644 --- a/packages/backend/src/routes/v1/accounts.ts +++ b/packages/backend/src/routes/v1/accounts.ts @@ -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) diff --git a/packages/backend/src/services/account.service.ts b/packages/backend/src/services/account.service.ts index c64ea91..f095f66 100644 --- a/packages/backend/src/services/account.service.ts +++ b/packages/backend/src/services/account.service.ts @@ -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 + }, +} diff --git a/packages/shared/src/schemas/account.schema.ts b/packages/shared/src/schemas/account.schema.ts index 9db519c..0f991ae 100644 --- a/packages/shared/src/schemas/account.schema.ts +++ b/packages/shared/src/schemas/account.schema.ts @@ -48,6 +48,29 @@ export const AccountSearchSchema = z.object({ q: z.string().min(1).max(255), }) +// --- Member Identifier --- + +export const IdentifierType = z.enum(['drivers_license', 'passport', 'school_id']) +export type IdentifierType = z.infer + +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 + +export const MemberIdentifierUpdateSchema = MemberIdentifierCreateSchema.omit({ memberId: true }).partial() +export type MemberIdentifierUpdateInput = z.infer + // --- Account Processor Link --- export const ProcessorLinkCreateSchema = z.object({ diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index fd21844..88f12e8 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -13,6 +13,8 @@ export { MemberCreateSchema, MemberUpdateSchema, AccountSearchSchema, + MemberIdentifierCreateSchema, + MemberIdentifierUpdateSchema, ProcessorLinkCreateSchema, ProcessorLinkUpdateSchema, PaymentMethodCreateSchema, @@ -27,6 +29,8 @@ export type { AccountUpdateInput, MemberCreateInput, MemberUpdateInput, + MemberIdentifierCreateInput, + MemberIdentifierUpdateInput, ProcessorLinkCreateInput, ProcessorLinkUpdateInput, PaymentMethodCreateInput,