diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-11 09:02:00 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-11 09:02:00 +0000 |
| commit | cbb4c7fe0b94459162ad5e998bc05cd293e0ff96 (patch) | |
| tree | 0a26712f7685e4f6511e637b9a81269d90a47c8f /app/api/auth/signup-with-vendor.tsx | |
| parent | eb654f88214095f71be142b989e620fd28db3f69 (diff) | |
(대표님) 입찰, EDP 변경사항 대응, spreadJS 오류 수정, 벤더실사 수정
Diffstat (limited to 'app/api/auth/signup-with-vendor.tsx')
| -rw-r--r-- | app/api/auth/signup-with-vendor.tsx | 491 |
1 files changed, 491 insertions, 0 deletions
diff --git a/app/api/auth/signup-with-vendor.tsx b/app/api/auth/signup-with-vendor.tsx new file mode 100644 index 00000000..1274d59b --- /dev/null +++ b/app/api/auth/signup-with-vendor.tsx @@ -0,0 +1,491 @@ +// 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<any, any, any>, + 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<any, any, any>, + 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<any, any, any>, + 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 } + ) + } +} |
