/** * 배포 서버(Render) 데이터 → 로컬 PostgreSQL 복사 * 사용: npm run db:sync-remote * 환경변수 SOURCE_API_URL 로 원본 변경 가능 */ import 'dotenv/config'; import bcrypt from 'bcrypt'; import { PrismaClient, type Priority, type TaskStatus } from '@prisma/client'; const prisma = new PrismaClient(); const SOURCE = (process.env.SOURCE_API_URL || 'https://eene-dashboard-backend.onrender.com').replace(/\/$/, ''); const SECTIONS = ['인사관리', '학습성장', '운영관리']; function normalizeSection(section: string | null | undefined): string | null { if (!section) return null; if (section === '전산관리' || section === '운영지원') return '운영관리'; if (section === '성장지원') return '학습성장'; return section; } type RemoteUser = { id: string; name: string; department?: string | null }; type RemoteTask = { id: string; title: string; description?: string | null; status: TaskStatus; priority: Priority; quarter: string; category?: string | null; section?: string | null; tag?: string | null; taskType?: string | null; progress: number; issueNote?: string | null; startDate?: string | null; dueDate?: string | null; showDate: boolean; showDescription: boolean; showStatus: boolean; showIssue: boolean; showProgress: boolean; creatorId: string; assigneeId?: string | null; pmMemberId?: string | null; creator?: RemoteUser; assignee?: RemoteUser | null; assigneeMembers?: Array<{ id: string; name: string; rank?: string | null; role?: string | null; cell?: string | null; contact?: string | null; photoUrl?: string | null; sortOrder?: number; }>; pmMember?: { id: string; name: string; rank?: string | null; role?: string | null; cell?: string | null; contact?: string | null; photoUrl?: string | null; sortOrder?: number; } | null; milestones?: Array<{ id: string; title: string; description?: string | null; startDate?: string | null; dueDate?: string | null; progress: number; links?: string | null; completedAt?: string | null; order: number; }>; details?: Array<{ id: string; content: string; authorName?: string | null; milestoneId?: string | null; updatedBy: string; createdAt: string; updatedAt: string; author?: RemoteUser; }>; kpiMetrics?: Array<{ id: string; quarter: string; target: number; actual: number; unit?: string | null; }>; files?: Array<{ id: string; filename: string; originalName: string; displayName?: string | null; sortOrder: number; mimetype: string; size: number; path: string; milestoneId?: string | null; uploadedBy: string; createdAt: string; }>; }; async function fetchJson(path: string): Promise { const res = await fetch(`${SOURCE}${path}`); if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`); return res.json() as Promise; } async function ensureUser(remote?: RemoteUser | null, fallbackEmail?: string): Promise { const pw = await bcrypt.hash('imported!', 10); const email = fallbackEmail || `${(remote?.name || 'user').replace(/\s/g, '')}@import.local`; const user = await prisma.user.upsert({ where: { email }, update: { name: remote?.name || email }, create: { email, password: pw, name: remote?.name || '사용자', role: 'MEMBER', department: remote?.department || 'EENE', }, }); return user.id; } async function ensureTeamMember(data: { name: string; rank?: string | null; role?: string | null; cell?: string | null; contact?: string | null; photoUrl?: string | null; sortOrder?: number; }): Promise { const existing = await prisma.teamMember.findFirst({ where: { name: data.name, cell: data.cell ?? null }, }); if (existing) { return prisma.teamMember .update({ where: { id: existing.id }, data: { rank: data.rank ?? existing.rank, role: data.role ?? existing.role, contact: data.contact ?? existing.contact, photoUrl: data.photoUrl ?? existing.photoUrl, sortOrder: data.sortOrder ?? existing.sortOrder, isActive: true, }, }) .then((m) => m.id); } const created = await prisma.teamMember.create({ data: { name: data.name, rank: data.rank ?? null, role: data.role ?? null, cell: data.cell ?? null, contact: data.contact ?? null, photoUrl: data.photoUrl ?? null, sortOrder: data.sortOrder ?? 0, }, }); return created.id; } async function clearLocalData() { await prisma.file.deleteMany({}); await prisma.taskDetail.deleteMany({}); await prisma.taskAssignee.deleteMany({}); await prisma.milestone.deleteMany({}); await prisma.kpiMetric.deleteMany({}); await prisma.task.deleteMany({}); } async function syncColumnConfigs() { for (const key of SECTIONS) { try { const config = await fetchJson<{ key: string; title: string; titleEn?: string | null; subtitle?: string | null; cardOrder?: string | null; }>(`/api/columns/${encodeURIComponent(key)}`); await prisma.columnConfig.upsert({ where: { key }, create: { key, title: config.title, titleEn: config.titleEn ?? '', subtitle: config.subtitle ?? '', cardOrder: config.cardOrder ?? null, }, update: { title: config.title, titleEn: config.titleEn ?? '', subtitle: config.subtitle ?? '', cardOrder: config.cardOrder ?? null, }, }); console.log(` ✓ column: ${key}`); } catch (err) { console.warn(` ⚠ column skip ${key}:`, (err as Error).message); } } } async function syncTeamMembersFromTasks(tasks: RemoteTask[]) { const seen = new Set(); for (const task of tasks) { const members = [ ...(task.assigneeMembers ?? []), ...(task.pmMember ? [task.pmMember] : []), ]; for (const m of members) { const key = `${m.name}|${m.cell ?? ''}`; if (seen.has(key)) continue; seen.add(key); await ensureTeamMember(m); } } } async function main() { console.log(`📡 Source: ${SOURCE}`); console.log('📥 Fetching remote tasks...'); const list = await fetchJson('/api/tasks'); console.log(` Found ${list.length} tasks`); const fullTasks: RemoteTask[] = []; for (const item of list) { const full = await fetchJson(`/api/tasks/${item.id}`); fullTasks.push(full); } console.log('🗑️ Clearing local task data...'); await clearLocalData(); const adminId = await ensureUser({ id: '', name: '관리자' }, 'admin@eene.com'); const userMap = new Map(); userMap.set('admin', adminId); console.log('👥 Syncing team members...'); try { const remoteMembers = await fetchJson< Array<{ name: string; rank?: string | null; role?: string | null; cell?: string | null; contact?: string | null; photoUrl?: string | null; sortOrder?: number; }> >('/api/team-members'); for (const m of remoteMembers) await ensureTeamMember(m); console.log(` ${remoteMembers.length} team members from API`); } catch { await syncTeamMembersFromTasks(fullTasks); console.log(' team members inferred from tasks'); } console.log('📋 Importing tasks...'); for (const remote of fullTasks) { let creatorId = userMap.get(remote.creatorId); if (!creatorId) { creatorId = await ensureUser(remote.creator, `creator-${remote.creatorId}@import.local`); userMap.set(remote.creatorId, creatorId); } let assigneeId: string | null = null; if (remote.assignee) { let mapped = userMap.get(remote.assignee.id); if (!mapped) { mapped = await ensureUser(remote.assignee, `assignee-${remote.assignee.id}@import.local`); userMap.set(remote.assignee.id, mapped); } assigneeId = mapped; } let pmMemberId: string | null = null; if (remote.pmMember) { pmMemberId = await ensureTeamMember(remote.pmMember); } const task = await prisma.task.create({ data: { title: remote.title, description: remote.description ?? null, status: remote.status, priority: remote.priority, quarter: remote.quarter, category: remote.category ?? null, section: normalizeSection(remote.section), tag: remote.tag ?? null, taskType: remote.taskType ?? null, progress: remote.progress ?? 0, issueNote: remote.issueNote ?? null, startDate: remote.startDate ? new Date(remote.startDate) : null, dueDate: remote.dueDate ? new Date(remote.dueDate) : null, showDate: remote.showDate, showDescription: remote.showDescription, showStatus: remote.showStatus, showIssue: remote.showIssue, showProgress: remote.showProgress, creatorId, assigneeId, pmMemberId, }, }); const milestoneIdMap = new Map(); for (const ms of remote.milestones ?? []) { const created = await prisma.milestone.create({ data: { taskId: task.id, title: ms.title, description: ms.description ?? null, startDate: ms.startDate ? new Date(ms.startDate) : null, dueDate: ms.dueDate ? new Date(ms.dueDate) : null, progress: ms.progress ?? 0, links: ms.links ?? null, completedAt: ms.completedAt ? new Date(ms.completedAt) : null, order: ms.order ?? 0, }, }); milestoneIdMap.set(ms.id, created.id); } for (const d of remote.details ?? []) { let authorId = userMap.get(d.updatedBy); if (!authorId) { authorId = await ensureUser(d.author, `author-${d.updatedBy}@import.local`); userMap.set(d.updatedBy, authorId); } await prisma.taskDetail.create({ data: { taskId: task.id, milestoneId: d.milestoneId ? milestoneIdMap.get(d.milestoneId) ?? null : null, content: d.content, authorName: d.authorName ?? null, updatedBy: authorId, createdAt: new Date(d.createdAt), updatedAt: new Date(d.updatedAt), }, }); } for (const k of remote.kpiMetrics ?? []) { await prisma.kpiMetric.create({ data: { taskId: task.id, quarter: k.quarter, target: k.target, actual: k.actual, unit: k.unit ?? null, }, }); } const assigneeMemberIds: string[] = []; for (const m of remote.assigneeMembers ?? []) { assigneeMemberIds.push(await ensureTeamMember(m)); } if (assigneeMemberIds.length > 0) { await prisma.taskAssignee.createMany({ data: [...new Set(assigneeMemberIds)].map((memberId) => ({ taskId: task.id, memberId })), }); } for (const f of remote.files ?? []) { let uploaderId = userMap.get(f.uploadedBy); if (!uploaderId) { uploaderId = creatorId; userMap.set(f.uploadedBy, uploaderId); } await prisma.file.create({ data: { taskId: task.id, milestoneId: f.milestoneId ? milestoneIdMap.get(f.milestoneId) ?? null : null, filename: f.filename, originalName: f.originalName, displayName: f.displayName ?? null, sortOrder: f.sortOrder ?? 0, mimetype: f.mimetype, size: f.size, path: f.path, uploadedBy: uploaderId, createdAt: new Date(f.createdAt), }, }); } console.log(` ✓ ${remote.title}`); } console.log('📐 Syncing column order...'); await syncColumnConfigs(); const count = await prisma.task.count(); console.log(`\n✅ Done! Local DB now has ${count} tasks (from ${SOURCE})`); console.log(' Refresh http://localhost:3000'); } main() .catch((err) => { console.error('❌ Sync failed:', err); process.exit(1); }) .finally(() => prisma.$disconnect());