Files
eene_dashboard/backend/scripts/sync-to-remote.ts
EENE Dashboard fb2956b0ac feat: team org panel, admin CRUD, local deploy tools, bidirectional data sync
Add TeamMember model and APIs, team status UI, /admin page, local server bats,
and scripts to sync data between local PostgreSQL and Render.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 01:41:00 +09:00

246 lines
7.4 KiB
TypeScript

/**
* 로컬 PostgreSQL → 배포 서버(Render API) 데이터 업로드
* 사용: npm run db:push-remote
* 환경변수 TARGET_API_URL 로 대상 변경 가능
*/
import 'dotenv/config';
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 = ['인사관리', '학습성장', '운영지원', '전산관리'];
async function api<T>(method: string, path: string, body?: unknown): Promise<T> {
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<T>;
}
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<Map<string, string>> {
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<RemoteMember[]>('GET', '/api/team-members?all=1');
} catch {
remotes = await api<RemoteMember[]>('GET', '/api/team-members');
}
const remoteByKey = new Map(remotes.map((m) => [memberKey(m.name, m.cell), m]));
const idMap = new Map<string, string>();
for (const local of locals) {
const key = memberKey(local.name, local.cell);
const payload = {
name: local.name,
rank: local.rank,
role: local.role,
cell: local.cell,
contact: local.contact,
photoUrl: local.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<RemoteMember>('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<Array<{ id: string }>>('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<string, string>) {
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,
keywords: task.keywords,
pmMemberId,
assigneeMemberIds,
});
const milestoneIdMap = new Map<string, string>();
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 (localTasks === 0) {
throw new Error('로컬 DB에 업무가 없습니다. 먼저 데이터가져오기.bat 또는 작업을 진행하세요.');
}
console.log('👥 Uploading team members...');
const memberIdMap = await syncTeamMembers();
console.log('🗑️ Clearing remote tasks...');
await clearRemoteTasks();
console.log('📋 Uploading tasks...');
const count = await syncTasks(memberIdMap);
console.log('📐 Uploading column order...');
await syncColumnConfigs();
const remoteTasks = await api<unknown[]>('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());