Files
eene_dashboard/backend/scripts/sync-from-remote.ts
EENE Dashboard cf72281c6d feat: quarter board theme, hub column, and team panel UX
Apply preview-style 4-dept layout with center hub, PM/assignee team status linking, task type label updates, and remove task keywords.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 22:09:46 +09:00

421 lines
12 KiB
TypeScript

/**
* 배포 서버(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<T>(path: string): Promise<T> {
const res = await fetch(`${SOURCE}${path}`);
if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`);
return res.json() as Promise<T>;
}
async function ensureUser(remote?: RemoteUser | null, fallbackEmail?: string): Promise<string> {
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<string> {
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<string>();
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<RemoteTask[]>('/api/tasks');
console.log(` Found ${list.length} tasks`);
const fullTasks: RemoteTask[] = [];
for (const item of list) {
const full = await fetchJson<RemoteTask>(`/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<string, string>();
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<string, string>();
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());