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>
421 lines
12 KiB
TypeScript
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());
|