/** * 로컬 PostgreSQL → 배포 서버(Render API) 데이터 업로드 * 사용: npm run db:push-remote * 환경변수 TARGET_API_URL 로 대상 변경 가능 */ import 'dotenv/config'; import fs from 'fs'; import path from 'path'; import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); const TARGET = (process.env.TARGET_API_URL || 'https://eene-dashboard-backend.onrender.com').replace(/\/$/, ''); const SECTIONS = ['인사관리', '학습성장', '운영관리']; const PHOTOS_ONLY = process.argv.includes('--photos-only'); const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR || path.join(__dirname, '../../uploads')); const TEAM_DIR = path.join(UPLOAD_DIR, 'team'); function resolveLocalPhotoPath(photoUrl: string | null | undefined): string | null { if (!photoUrl || /^https?:\/\//i.test(photoUrl) || photoUrl.startsWith('data:')) return null; const filename = photoUrl.replace(/^\/uploads\/team\//, '').replace(/^uploads\/team\//, ''); if (!filename || filename.includes('..')) return null; const filePath = path.join(TEAM_DIR, filename); return fs.existsSync(filePath) ? filePath : null; } function mimeForExt(ext: string): string { if (ext === '.png') return 'image/png'; if (ext === '.webp') return 'image/webp'; if (ext === '.gif') return 'image/gif'; return 'image/jpeg'; } async function uploadPhotoToRemote(filePath: string): Promise { const buf = fs.readFileSync(filePath); const ext = path.extname(filePath).toLowerCase(); const form = new FormData(); form.append('photo', new Blob([buf], { type: mimeForExt(ext) }), path.basename(filePath)); const res = await fetch(`${TARGET}/api/team-members/photo`, { method: 'POST', body: form }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`photo upload failed: ${res.status} ${text}`); } const data = (await res.json()) as { url: string }; return data.url; } async function api(method: string, path: string, body?: unknown): Promise { const res = await fetch(`${TARGET}${path}`, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined, }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`${method} ${path} → ${res.status} ${text}`); } if (res.status === 204) return undefined as T; return res.json() as Promise; } function memberKey(name: string, cell: string | null) { return `${name}|${cell ?? ''}`; } async function ensureRemoteApiReady() { const res = await fetch(`${TARGET}/api/team-members`); if (res.status === 404) { throw new Error( '배포 서버에 team-members API가 없습니다. 코드 배포(Render) 완료 후 다시 실행하세요.', ); } if (!res.ok) throw new Error(`team-members check failed: ${res.status}`); } async function syncTeamMembers(): Promise> { const locals = await prisma.teamMember.findMany({ where: { isActive: true }, orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }], }); type RemoteMember = { id: string; name: string; cell: string | null; rank?: string | null; role?: string | null; contact?: string | null; photoUrl?: string | null; sortOrder?: number; }; let remotes: RemoteMember[] = []; try { remotes = await api('GET', '/api/team-members?all=1'); } catch { remotes = await api('GET', '/api/team-members'); } const remoteByKey = new Map(remotes.map((m) => [memberKey(m.name, m.cell), m])); const idMap = new Map(); for (const local of locals) { const key = memberKey(local.name, local.cell); let photoUrl = local.photoUrl; const localPhotoPath = resolveLocalPhotoPath(local.photoUrl); if (localPhotoPath) { try { photoUrl = await uploadPhotoToRemote(localPhotoPath); console.log(` 📷 ${local.name} — ${path.basename(localPhotoPath)}`); } catch (err) { console.warn(` ⚠ ${local.name} photo skip:`, (err as Error).message); } } else if (local.photoUrl) { console.warn(` ⚠ ${local.name} — local file not found: ${local.photoUrl}`); } const payload = { name: local.name, rank: local.rank, role: local.role, cell: local.cell, contact: local.contact, photoUrl, sortOrder: local.sortOrder, isActive: true, }; const existing = remoteByKey.get(key); if (existing) { await api('PATCH', `/api/team-members/${existing.id}`, payload); idMap.set(local.id, existing.id); console.log(` ✓ team ${local.name} (updated)`); } else { const created = await api('POST', '/api/team-members', payload); idMap.set(local.id, created.id); remoteByKey.set(key, created); console.log(` ✓ team ${local.name} (created)`); } } return idMap; } async function clearRemoteTasks() { const remoteTasks = await api>('GET', '/api/tasks'); for (const t of remoteTasks) { await api('DELETE', `/api/tasks/${t.id}`); } console.log(` removed ${remoteTasks.length} remote tasks`); } async function syncTasks(memberIdMap: Map) { const tasks = await prisma.task.findMany({ include: { milestones: { orderBy: { order: 'asc' } }, details: { orderBy: { createdAt: 'asc' } }, kpiMetrics: true, taskAssignees: true, pmMember: true, }, orderBy: { createdAt: 'asc' }, }); for (const task of tasks) { const assigneeMemberIds = task.taskAssignees .map((ta) => memberIdMap.get(ta.memberId)) .filter((id): id is string => Boolean(id)); const pmMemberId = task.pmMemberId ? memberIdMap.get(task.pmMemberId) ?? null : null; const created = await api<{ id: string }>('POST', '/api/tasks', { title: task.title, description: task.description, status: task.status, priority: task.priority, quarter: task.quarter, category: task.category, section: task.section, tag: task.tag, taskType: task.taskType, progress: task.progress, issueNote: task.issueNote, startDate: task.startDate?.toISOString() ?? null, dueDate: task.dueDate?.toISOString() ?? null, showDate: task.showDate, showDescription: task.showDescription, showStatus: task.showStatus, showIssue: task.showIssue, showProgress: task.showProgress, pmMemberId, assigneeMemberIds, }); const milestoneIdMap = new Map(); for (const ms of task.milestones) { const remoteMs = await api<{ id: string }>('POST', `/api/milestones/${created.id}`, { title: ms.title, description: ms.description, startDate: ms.startDate?.toISOString() ?? null, dueDate: ms.dueDate?.toISOString() ?? null, progress: ms.progress, links: ms.links, }); milestoneIdMap.set(ms.id, remoteMs.id); } for (const d of task.details) { await api('POST', `/api/details/${created.id}`, { content: d.content, authorName: d.authorName, milestoneId: d.milestoneId ? milestoneIdMap.get(d.milestoneId) ?? null : null, }); } for (const k of task.kpiMetrics) { await api('POST', '/api/kpi', { taskId: created.id, quarter: k.quarter, target: k.target, actual: k.actual, unit: k.unit, }); } console.log(` ✓ task ${task.title}`); } return tasks.length; } async function syncColumnConfigs() { const configs = await prisma.columnConfig.findMany(); for (const config of configs) { await api('PATCH', `/api/columns/${encodeURIComponent(config.key)}`, { title: config.title, titleEn: config.titleEn, subtitle: config.subtitle, cardOrder: config.cardOrder, }); console.log(` ✓ column ${config.key}`); } for (const key of SECTIONS) { if (!configs.some((c) => c.key === key)) { const local = await prisma.columnConfig.findUnique({ where: { key } }); if (local) continue; try { await api('GET', `/api/columns/${encodeURIComponent(key)}`); } catch { /* ensure exists on remote */ } } } } async function main() { console.log(`📤 Target: ${TARGET}`); console.log('🔍 Checking remote API...'); await ensureRemoteApiReady(); const localTasks = await prisma.task.count(); const localMembers = await prisma.teamMember.count({ where: { isActive: true } }); console.log(` Local: ${localTasks} tasks, ${localMembers} team members`); if (!PHOTOS_ONLY && localTasks === 0) { throw new Error('로컬 DB에 업무가 없습니다. 먼저 데이터가져오기.bat 또는 작업을 진행하세요.'); } console.log(`👥 Uploading team members${PHOTOS_ONLY ? ' + photos' : ''}...`); console.log(` Local photos dir: ${TEAM_DIR}`); const memberIdMap = await syncTeamMembers(); if (PHOTOS_ONLY) { console.log('\n✅ Photos sync complete!'); console.log(' Site: https://eene-dashboard.vercel.app/'); return; } console.log('🗑️ Clearing remote tasks...'); await clearRemoteTasks(); console.log('📋 Uploading tasks...'); await syncTasks(memberIdMap); console.log('📐 Uploading column order...'); await syncColumnConfigs(); const remoteTasks = await api('GET', '/api/tasks'); console.log(`\n✅ Done! Remote now has ${remoteTasks.length} tasks`); console.log(` Site: https://eene-dashboard.vercel.app/`); } main() .catch((err) => { console.error('❌ Push failed:', err); process.exit(1); }) .finally(() => prisma.$disconnect());