Helmet CORP blocked /uploads images from eene-dashboard.vercel.app. Also add photo file upload to db:push-remote and db:push-photos script. Co-authored-by: Cursor <cursoragent@cursor.com>
302 lines
9.6 KiB
TypeScript
302 lines
9.6 KiB
TypeScript
/**
|
|
* 로컬 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<string> {
|
|
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<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);
|
|
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<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 (!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<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());
|