// app/api/auth/signup-with-vendor/route.ts import { NextRequest, NextResponse } from 'next/server' import { unstable_noStore } from 'next/cache' import { revalidateTag } from 'next/cache' import { eq, and } from 'drizzle-orm' import { PgTransaction } from 'drizzle-orm/pg-core' import db from '@/db/db' import { users, vendors, vendorContacts, vendorAttachments, userConsents, consentLogs, policyVersions } from '@/db/schema' import { insertVendor } from '@/lib/vendors/repository' import { getErrorMessage } from '@/lib/handle-error' import { saveFile, SaveFileResult } from '@/lib/file-stroage' // Types interface AccountData { name: string email: string phone: string country: string } interface VendorData { vendorName: string vendorCode?: string address?: string country?: string phone?: string email: string website?: string status?: string taxId: string vendorTypeId: number items?: string representativeName?: string representativeBirth?: string representativeEmail?: string representativePhone?: string corporateRegistrationNumber?: string representativeWorkExpirence?: boolean contacts: ContactData[] } interface ContactData { contactName: string contactPosition?: string contactDepartment?: string contactTask?: string contactEmail: string contactPhone?: string isPrimary?: boolean } interface ConsentData { privacy_policy: { agreed: boolean version: string } terms_of_service: { agreed: boolean version: string } marketing: { agreed: boolean version: string } } interface CompleteSignupData { account: AccountData vendor: VendorData consents: ConsentData } // File attachment types const FILE_TYPES = { BUSINESS_REGISTRATION: 'BUSINESS_REGISTRATION', ISO_CERTIFICATION: 'ISO_CERTIFICATION', CREDIT_REPORT: 'CREDIT_REPORT', BANK_ACCOUNT_COPY: 'BANK_ACCOUNT_COPY' } as const type FileType = typeof FILE_TYPES[keyof typeof FILE_TYPES] // 보안 강화된 파일 저장 함수 async function storeVendorFiles( tx: PgTransaction, vendorId: number, files: File[], attachmentType: FileType, userId?: number ) { const vendorDirectory = `vendors/${vendorId}` for (const file of files) { console.log(`📄 업체 파일 저장 시작: ${file.name} (타입: ${attachmentType})`) // 보안 강화된 파일 저장 const saveResult: SaveFileResult = await saveFile({ file, directory: vendorDirectory, originalName: file.name, userId: userId?.toString(), }) if (!saveResult.success) { throw new Error(`파일 저장 실패 (${file.name}): ${saveResult.error}`) } // 파일 정보 DB에 저장 await tx.insert(vendorAttachments).values({ vendorId, fileName: saveResult.originalName || file.name, filePath: saveResult.publicPath!, attachmentType, }) console.log(`✅ 업체 파일 저장 완료: ${saveResult.fileName}`) } } // 사용자 계정 생성 함수 (승인 대기 상태) async function createUserAccount( tx: PgTransaction, accountData: AccountData, vendorId?: number ) { console.log(`👤 사용자 계정 생성: ${accountData.email} (승인 대기 상태)`) // 국가코드에 따른 언어 설정 const language = accountData.country === 'KR' ? 'ko' : 'en' // 사용자 생성 (승인 대기 상태로) const [newUser] = await tx.insert(users).values({ name: accountData.name, email: accountData.email.toLowerCase(), phone: accountData.phone, domain: 'partners', // 파트너 도메인 companyId: vendorId || null, language, // 🌍 국가코드 기반 언어 설정 (KR → 'ko', 그외 → 'en') // 🔒 승인 대기 상태로 설정 isActive: false, // 관리자 승인 후 활성화 예정 // 보안 관련 초기 설정 mfaEnabled: false, isLocked: false, failedLoginAttempts: 0, passwordChangeRequired: false, // 패스워드 링크로 설정 예정 // 동의 관련 초기 설정 requiresConsentUpdate: false, lastConsentUpdate: new Date(), }).returning() console.log(`✅ 사용자 계정 생성 완료: ${newUser.email} (ID: ${newUser.id}, 언어: ${language})`) return newUser } // 동의 정보 저장 함수 async function saveUserConsents( tx: PgTransaction, userId: number, consents: ConsentData, clientIP?: string, userAgent?: string ) { const timestamp = new Date() // 각 동의 타입에 대해 처리 const consentTypes = [ { type: 'privacy_policy' as const, data: consents.privacy_policy }, { type: 'terms_of_service' as const, data: consents.terms_of_service }, { type: 'marketing' as const, data: consents.marketing } ] for (const { type, data } of consentTypes) { // 동의 상태 저장 await tx.insert(userConsents).values({ userId, consentType: type, consentStatus: data.agreed, policyVersion: data.version, consentedAt: timestamp, ipAddress: clientIP || null, userAgent: userAgent || null, }) // 동의 로그 저장 await tx.insert(consentLogs).values({ userId, consentType: type, action: 'consent', oldStatus: null, // 신규 사용자이므로 이전 상태 없음 newStatus: data.agreed, policyVersion: data.version, ipAddress: clientIP || null, userAgent: userAgent || null, actionTimestamp: timestamp, additionalData: { source: 'signup', initialConsent: true } }) } // users 테이블의 동의 관련 필드 업데이트 await tx.update(users) .set({ lastConsentUpdate: timestamp, consentVersion: consents.privacy_policy.version, // 대표 버전으로 privacy policy 사용 requiresConsentUpdate: false, updatedAt: timestamp }) .where(eq(users.id, userId)) } // 유효성 검사 함수들 function validateAccountData(account: AccountData): string[] { const errors: string[] = [] if (!account.name?.trim()) errors.push('이름은 필수입니다.') if (!account.email?.trim()) errors.push('이메일은 필수입니다.') if (!account.phone?.trim()) errors.push('전화번호는 필수입니다.') if (!account.country?.trim()) errors.push('국가는 필수입니다.') // 이메일 형식 검증 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ if (account.email && !emailRegex.test(account.email)) { errors.push('유효한 이메일 형식이 아닙니다.') } return errors } function validateVendorData(vendor: VendorData): string[] { const errors: string[] = [] if (!vendor.vendorName?.trim()) errors.push('업체명은 필수입니다.') if (!vendor.vendorTypeId) errors.push('업체 유형은 필수입니다.') if (!vendor.items?.trim()) errors.push('공급품목은 필수입니다.') if (!vendor.taxId?.trim()) errors.push('사업자등록번호는 필수입니다.') if (!vendor.country?.trim()) errors.push('국가는 필수입니다.') if (!vendor.phone?.trim()) errors.push('대표 전화번호는 필수입니다.') if (!vendor.email?.trim()) errors.push('대표 이메일은 필수입니다.') // 연락처 검증 if (!vendor.contacts?.length) { errors.push('최소 1명의 담당자 정보가 필요합니다.') } else { vendor.contacts.forEach((contact, index) => { if (!contact.contactName?.trim()) { errors.push(`담당자 ${index + 1}의 이름은 필수입니다.`) } if (!contact.contactEmail?.trim()) { errors.push(`담당자 ${index + 1}의 이메일은 필수입니다.`) } }) } return errors } function validateConsents(consents: ConsentData): string[] { const errors: string[] = [] // 필수 동의 항목 확인 if (!consents.privacy_policy?.agreed) { errors.push('개인정보 처리방침에 동의해주세요.') } if (!consents.terms_of_service?.agreed) { errors.push('서비스 이용약관에 동의해주세요.') } // 버전 정보 확인 if (!consents.privacy_policy?.version) { errors.push('개인정보 처리방침 버전 정보가 없습니다.') } if (!consents.terms_of_service?.version) { errors.push('서비스 이용약관 버전 정보가 없습니다.') } return errors } export async function POST(request: NextRequest) { unstable_noStore() try { const formData = await request.formData() // 완전한 가입 데이터 파싱 const completeDataString = formData.get('completeData') as string if (!completeDataString) { return NextResponse.json( { error: '가입 정보가 누락되었습니다.' }, { status: 400 } ) } const completeData: CompleteSignupData = JSON.parse(completeDataString) const { account, vendor, consents } = completeData // 유효성 검사 const accountErrors = validateAccountData(account) const vendorErrors = validateVendorData(vendor) const consentErrors = validateConsents(consents) const allErrors = [...accountErrors, ...vendorErrors, ...consentErrors] if (allErrors.length > 0) { return NextResponse.json( { error: allErrors.join('\n') }, { status: 400 } ) } // 파일 추출 const businessRegistrationFiles = formData.getAll('businessRegistration') as File[] const isoCertificationFiles = formData.getAll('isoCertification') as File[] const creditReportFiles = formData.getAll('creditReport') as File[] const bankAccountFiles = formData.getAll('bankAccount') as File[] // 필수 파일 검증 if (businessRegistrationFiles.length === 0) { return NextResponse.json( { error: '사업자등록증을 업로드해주세요.' }, { status: 400 } ) } if (isoCertificationFiles.length === 0) { return NextResponse.json( { error: 'ISO 인증서를 업로드해주세요.' }, { status: 400 } ) } if (creditReportFiles.length === 0) { return NextResponse.json( { error: '신용평가보고서를 업로드해주세요.' }, { status: 400 } ) } if (vendor.country !== "KR" && bankAccountFiles.length === 0) { return NextResponse.json( { error: '대금지급 통장사본을 업로드해주세요.' }, { status: 400 } ) } // 중복 검사 const existingUser = await db .select({ id: users.id }) .from(users) .where(eq(users.email, account.email.toLowerCase())) .limit(1) if (existingUser.length > 0) { return NextResponse.json( { error: '이미 등록된 이메일입니다.' }, { status: 400 } ) } const existingVendor = await db .select({ id: vendors.id }) .from(vendors) .where(eq(vendors.taxId, vendor.taxId)) .limit(1) if (existingVendor.length > 0) { return NextResponse.json( { error: '이미 등록된 사업자등록번호입니다.' }, { status: 400 } ) } // 클라이언트 정보 추출 const clientIP = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || request.ip || '127.0.0.1' const userAgent = request.headers.get('user-agent') || 'Unknown' let newUser: any let newVendor: any // 트랜잭션으로 모든 데이터 생성 await db.transaction(async (tx) => { // 1. 업체 생성 [newVendor] = await insertVendor(tx, { vendorName: vendor.vendorName, vendorCode: vendor.vendorCode || null, address: vendor.address || null, country: vendor.country || null, phone: vendor.phone || null, email: vendor.email, website: vendor.website || null, status: "PENDING_REVIEW", // 관리자 승인 대기 taxId: vendor.taxId, vendorTypeId: vendor.vendorTypeId, items: vendor.items || null, // 한국 사업자 정보 representativeName: vendor.representativeName || null, representativeBirth: vendor.representativeBirth || null, representativeEmail: vendor.representativeEmail || null, representativePhone: vendor.representativePhone || null, corporateRegistrationNumber: vendor.corporateRegistrationNumber || null, representativeWorkExpirence: vendor.representativeWorkExpirence || false, }) // 2. 사용자 계정 생성 (업체 ID와 연결) newUser = await createUserAccount(tx, account, newVendor.id) // 3. 동의 정보 저장 await saveUserConsents(tx, newUser.id, consents, clientIP, userAgent) // 4. 파일 저장 (보안 강화된 파일 저장 함수 사용) if (businessRegistrationFiles.length > 0) { await storeVendorFiles(tx, newVendor.id, businessRegistrationFiles, FILE_TYPES.BUSINESS_REGISTRATION, newUser.id) } if (isoCertificationFiles.length > 0) { await storeVendorFiles(tx, newVendor.id, isoCertificationFiles, FILE_TYPES.ISO_CERTIFICATION, newUser.id) } if (creditReportFiles.length > 0) { await storeVendorFiles(tx, newVendor.id, creditReportFiles, FILE_TYPES.CREDIT_REPORT, newUser.id) } if (bankAccountFiles.length > 0) { await storeVendorFiles(tx, newVendor.id, bankAccountFiles, FILE_TYPES.BANK_ACCOUNT_COPY, newUser.id) } // 5. 담당자 정보 저장 for (const [index, contact] of vendor.contacts.entries()) { await tx.insert(vendorContacts).values({ vendorId: newVendor.id, contactName: contact.contactName, contactPosition: contact.contactPosition || null, contactDepartment: contact.contactDepartment || null, contactTask: contact.contactTask || null, contactEmail: contact.contactEmail, contactPhone: contact.contactPhone || null, isPrimary: index === 0, // 첫 번째 담당자를 주담당자로 설정 }) } }) // 캐시 무효화 revalidateTag("vendors") revalidateTag("users") console.log(`🎉 통합 회원가입 완료:`) console.log(` - 사용자: ${newUser.email} (ID: ${newUser.id})`) console.log(` - 업체: ${newVendor.vendorName} (ID: ${newVendor.id})`) console.log(` - 상태: 승인 대기 (isActive: false)`) console.log(` - 다음 단계: 관리자 승인 → 패스워드 설정 링크 발송`) return NextResponse.json({ message: '회원가입 및 업체 등록이 완료되었습니다.', notice: '관리자 승인 후 계정이 활성화됩니다. 승인 완료 시 패스워드 설정 링크가 이메일로 전송됩니다.', data: { userId: newUser.id, vendorId: newVendor.id, email: newUser.email, status: 'PENDING_APPROVAL', // 승인 대기 상태 isActive: false } }, { status: 201 }) } catch (error) { console.error('통합 회원가입 처리 오류:', error) return NextResponse.json( { error: '회원가입 및 업체 등록 처리 중 오류가 발생했습니다.', details: getErrorMessage(error), notice: '문제가 지속될 경우 관리자에게 문의해주세요.' }, { status: 500 } ) } }