fix: production save errors and import HR dashboard data

Resolve invalid task creator IDs, fix API routing and file uploads on Vercel, and replace dummy seed data with HR_Dashboard import.
This commit is contained in:
EENE Dashboard
2026-06-05 22:08:56 +09:00
parent 9abb58e5c8
commit 6066b5682d
12 changed files with 488 additions and 188 deletions

View File

@@ -10,7 +10,8 @@
"db:migrate": "prisma migrate dev",
"db:generate": "prisma generate",
"db:studio": "prisma studio",
"db:seed": "tsx prisma/seed.ts"
"db:seed": "tsx prisma/seed.ts",
"db:import-hr": "tsx scripts/import-hr-data.ts"
},
"dependencies": {
"@prisma/client": "^6.0.0",

View File

@@ -0,0 +1,211 @@
import fs from 'fs';
import path from 'path';
import type { Priority, TaskStatus } from '@prisma/client';
export interface HrProject {
idx?: number;
name: string;
pm?: string;
priority: string;
startDate?: string;
endDate?: string;
status?: string;
category: string;
summary?: string;
content?: string;
briefIntro?: string;
progress?: number;
progressLog?: string;
progressStatus?: string;
issues?: string;
statusText?: string;
isIssue?: boolean;
keywords?: string[];
refLinks?: { name: string; url: string }[];
referenceUrl?: string;
subPhases?: { name: string; status?: string; text?: string }[];
timelineItems?: { startDate?: string; endDate?: string; desc?: string }[];
showOnDashboard?: boolean;
}
export interface MappedTask {
title: string;
description: string | null;
status: TaskStatus;
priority: Priority;
quarter: string;
category: string;
section: string;
taskType: string;
progress: number;
issueNote: string | null;
startDate: Date | null;
dueDate: Date | null;
keywords: string | null;
showDate: boolean;
showDescription: boolean;
showStatus: boolean;
showIssue: boolean;
showProgress: boolean;
milestones: {
title: string;
description: string | null;
progress: number;
links: string | null;
}[];
detailContent: string | null;
}
const SECTION_MAP: Record<string, string> = {
: '인사관리',
: '학습성장',
: '운영지원',
: '전산관리',
};
const STATUS_MAP: Record<string, TaskStatus> = {
: 'IN_PROGRESS',
: 'IN_PROGRESS',
: 'TODO',
: 'DONE',
};
const PHASE_PROGRESS: Record<string, number> = {
완료: 100,
진행중: 50,
미착수: 0,
};
export function defaultHrDataPath(): string {
return path.resolve(__dirname, '../../../HR_Dashboard/data.json');
}
export function loadHrProjects(filePath = defaultHrDataPath()): HrProject[] {
const raw = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(raw) as { PROJECTS?: HrProject[] };
return (data.PROJECTS ?? []).filter((p) => p.showOnDashboard !== false);
}
function parseDate(value?: string): Date | null {
if (!value?.trim()) return null;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? null : d;
}
function mapSection(category: string): string {
return SECTION_MAP[category] ?? category;
}
function mapStatus(status?: string, isRoutine = false): TaskStatus {
if (!status?.trim()) return isRoutine ? 'IN_PROGRESS' : 'TODO';
return STATUS_MAP[status] ?? 'IN_PROGRESS';
}
function mapPriority(priority: string): Priority {
if (priority === '상시') return 'MEDIUM';
if (priority === '높음') return 'HIGH';
if (priority === '낮음') return 'LOW';
return 'MEDIUM';
}
function mapProgress(value?: number): number {
if (value == null || Number.isNaN(value)) return 0;
if (value <= 1) return Math.round(value * 100);
return Math.min(100, Math.round(value));
}
function pickDescription(p: HrProject): string | null {
const candidates = [p.content, p.summary, p.briefIntro].map((v) => v?.trim()).filter(Boolean) as string[];
const best = candidates.find((v) => v.length > 2 && v !== '12');
return best ?? null;
}
function pickIssueNote(p: HrProject): string | null {
const parts: string[] = [];
if (p.isIssue && p.statusText?.trim()) parts.push(p.statusText.trim());
if (p.issues?.trim()) parts.push(p.issues.trim());
if (p.progressLog?.trim() && p.progressLog !== '이슈사항') parts.push(p.progressLog.trim());
return parts.length ? parts.join('\n') : null;
}
function buildLinks(p: HrProject): string | null {
const links: { label: string; url: string }[] = [];
for (const ref of p.refLinks ?? []) {
if (ref.url?.trim()) links.push({ label: ref.name || '참고자료', url: ref.url.trim() });
}
if (p.referenceUrl?.trim()) links.push({ label: '참고링크', url: p.referenceUrl.trim() });
return links.length ? JSON.stringify(links) : null;
}
function buildMilestones(p: HrProject): MappedTask['milestones'] {
const milestones: MappedTask['milestones'] = [];
for (const [i, phase] of (p.subPhases ?? []).entries()) {
milestones.push({
title: phase.name,
description: phase.text?.trim() || null,
progress: PHASE_PROGRESS[phase.status ?? ''] ?? 0,
links: null,
});
}
for (const item of p.timelineItems ?? []) {
if (!item.desc?.trim()) continue;
milestones.push({
title: item.desc.trim(),
description: [item.startDate, item.endDate].filter(Boolean).join(' ~ ') || null,
progress: 0,
links: null,
});
}
if (milestones.length === 0 && buildLinks(p)) {
milestones.push({
title: '참고자료',
description: null,
progress: 0,
links: buildLinks(p),
});
}
return milestones;
}
function buildDetailContent(p: HrProject): string | null {
const content = p.progressStatus?.trim() || p.progressLog?.trim();
if (!content || content === '이슈사항' || content === '12') return null;
return content;
}
export function mapHrProjectToTask(p: HrProject, quarter = '2026-Q2'): MappedTask {
const isRoutine = p.priority === '상시';
const taskType = isRoutine ? '기반업무' : '실행과제';
const visible = !isRoutine;
return {
title: p.name.trim(),
description: pickDescription(p),
status: mapStatus(p.status, isRoutine),
priority: mapPriority(p.priority),
quarter,
category: mapSection(p.category),
section: mapSection(p.category),
taskType,
progress: mapProgress(p.progress),
issueNote: pickIssueNote(p),
startDate: parseDate(p.startDate),
dueDate: parseDate(p.endDate),
keywords: p.keywords?.length ? p.keywords.join(', ') : null,
showDate: visible,
showDescription: visible,
showStatus: visible,
showIssue: visible,
showProgress: visible,
milestones: buildMilestones(p),
detailContent: buildDetailContent(p),
};
}
export function mapAllHrProjects(filePath?: string, quarter = '2026-Q2'): MappedTask[] {
return loadHrProjects(filePath).map((p) => mapHrProjectToTask(p, quarter));
}

View File

@@ -1,13 +1,13 @@
import 'dotenv/config';
import bcrypt from 'bcrypt';
import { PrismaClient } from '@prisma/client';
import { mapAllHrProjects } from './mapHrProjects';
const prisma = new PrismaClient();
async function main() {
console.log('🌱 Seeding database...');
// ─── 사용자 ─────────────────────────────────────────────
const adminPw = await bcrypt.hash('admin1234!', 12);
const memberPw = await bcrypt.hash('member1234!', 12);
@@ -19,141 +19,48 @@ async function main() {
const member = await prisma.user.upsert({
where: { email: 'member@eene.com' },
update: {},
create: { email: 'member@eene.com', password: memberPw, name: '홍길동', role: 'MEMBER', department: 'EENE' },
update: { name: '정성호' },
create: { email: 'member@eene.com', password: memberPw, name: '정성호', role: 'MEMBER', department: 'EENE' },
});
console.log(`✅ Users ready`);
console.log('✅ Users ready');
// ─── 기존 업무 삭제 후 재생성 ─────────────────────────
await prisma.kpiMetric.deleteMany({});
const mapped = mapAllHrProjects();
await prisma.file.deleteMany({});
await prisma.taskDetail.deleteMany({});
await prisma.milestone.deleteMany({});
await prisma.kpiMetric.deleteMany({});
await prisma.task.deleteMany({});
const allTasks = [
// ─── 인사관리 ───────────────────────────────────────
{
title: '그룹 표준 취업규칙 개정안 (HRM)',
description: '유연근무제 및 근태 관리 프로세스 고도화\n노사협의회 안건 조율 및 근로조건 개선안 반영',
status: 'IN_PROGRESS' as const,
priority: 'HIGH' as const,
section: '인사관리',
tag: 'Policy',
taskType: '프로젝트',
progress: 40,
issueNote: '[5.28] 가족사 간 피드백 이견 조율 진행',
startDate: new Date('2026-04-01'),
dueDate: new Date('2026-06-30'),
},
{
title: '상반기 평가 지표(KPI) 보완',
description: '1분기 피드백 기반 부서별 KPI 정렬\n상반기 업적 평가 시뮬레이션 기획',
status: 'IN_PROGRESS' as const,
priority: 'MEDIUM' as const,
section: '인사관리',
tag: 'Performance',
taskType: '상시업무',
progress: 70,
issueNote: null,
startDate: new Date('2026-04-01'),
dueDate: new Date('2026-06-30'),
},
{
title: '가족사 시너지 조직문화 캠페인',
description: '임직원 만족도 조사(Engagement Survey) 설계',
status: 'TODO' as const,
priority: 'LOW' as const,
section: '인사관리',
tag: 'Culture',
taskType: '프로젝트',
progress: 0,
issueNote: null,
startDate: new Date('2026-05-01'),
dueDate: new Date('2026-06-30'),
},
// ─── 학습성장 ───────────────────────────────────────
{
title: '사내 핵심역량 교육체계 수립 (HRD)',
description: '직급별/직무별 필수 역량 가이드라인 도출\n사내 강사 제도 양성 및 콘텐츠 기획 단계',
status: 'IN_PROGRESS' as const,
priority: 'HIGH' as const,
section: '학습성장',
tag: 'Growth',
taskType: '프로젝트',
progress: 20,
issueNote: '[5.28] 사내 강사 풀 확보 및 보상안 검토',
startDate: new Date('2026-04-01'),
dueDate: new Date('2026-06-30'),
},
// ─── 운영지원 ───────────────────────────────────────
{
title: '기술센터 2F 세미나룸 브랜딩 기획',
description: '다목적 교육 공간 활용을 위한 운영 매뉴얼 수립\n공간 아이덴티티 반영 사인물(Signage) 기획',
status: 'REVIEW' as const,
priority: 'MEDIUM' as const,
section: '운영지원',
tag: 'Space',
taskType: '프로젝트',
progress: 80,
issueNote: null,
startDate: new Date('2026-04-01'),
dueDate: new Date('2026-06-30'),
},
{
title: '중대재해법 대응 안전보건 매뉴얼 정비',
description: '현장 점검 기반 소방 및 MSDS 관리 체계 보완\n사내 안전사고 예방 가이드라인 표준화 기획',
status: 'IN_PROGRESS' as const,
priority: 'HIGH' as const,
section: '운영지원',
tag: 'Safety',
taskType: '상시업무',
progress: 30,
issueNote: null,
startDate: new Date('2026-04-01'),
dueDate: new Date('2026-06-30'),
},
{
title: '공용 공간 인프라 개선',
description: '기술센터 로비 및 라운지 환경 정비 기획',
status: 'TODO' as const,
priority: 'LOW' as const,
section: '운영지원',
tag: 'Environment',
taskType: '프로젝트',
progress: 0,
issueNote: null,
startDate: new Date('2026-05-01'),
dueDate: new Date('2026-06-30'),
},
// ─── 전산관리 ───────────────────────────────────────
{
title: '全사 IT자산 수명주기 표준화 구축',
description: '자산 전수조사 데이터 기반 불용 기준 정립\n라이선스 최적화를 통한 비용 절감안 도출',
status: 'IN_PROGRESS' as const,
priority: 'HIGH' as const,
section: '전산관리',
tag: 'Asset',
taskType: '프로젝트',
progress: 60,
issueNote: null,
startDate: new Date('2026-04-01'),
dueDate: new Date('2026-06-30'),
},
];
for (const t of allTasks) {
await prisma.task.create({
for (const t of mapped) {
const { milestones, detailContent, ...taskData } = t;
const task = await prisma.task.create({
data: {
...t,
quarter: '2026-Q2',
category: t.section,
...taskData,
creatorId: admin.id,
assigneeId: member.id,
},
});
for (const [order, ms] of milestones.entries()) {
await prisma.milestone.create({
data: { ...ms, taskId: task.id, order },
});
}
if (detailContent) {
await prisma.taskDetail.create({
data: {
taskId: task.id,
content: detailContent,
updatedBy: admin.id,
},
});
}
}
console.log(`✅ Tasks created: ${allTasks.length}`);
console.log(`✅ Tasks created: ${mapped.length} (HR_Dashboard 데이터)`);
console.log('🎉 Seeding complete!');
}

View File

@@ -0,0 +1,142 @@
import 'dotenv/config';
import { PrismaClient } from '@prisma/client';
import { mapAllHrProjects } from '../prisma/mapHrProjects';
const prisma = new PrismaClient();
const API_BASE = process.env.TARGET_API_URL?.replace(/\/$/, '');
async function importViaPrisma(adminId: string, memberId: string) {
const tasks = mapAllHrProjects();
await prisma.file.deleteMany({});
await prisma.taskDetail.deleteMany({});
await prisma.milestone.deleteMany({});
await prisma.kpiMetric.deleteMany({});
await prisma.task.deleteMany({});
for (const t of tasks) {
const { milestones, detailContent, ...taskData } = t;
const task = await prisma.task.create({
data: {
...taskData,
creatorId: adminId,
assigneeId: memberId,
},
});
for (const [order, ms] of milestones.entries()) {
await prisma.milestone.create({
data: { ...ms, taskId: task.id, order },
});
}
if (detailContent) {
await prisma.taskDetail.create({
data: {
taskId: task.id,
content: detailContent,
updatedBy: adminId,
},
});
}
}
console.log(`✅ Prisma import complete: ${tasks.length} tasks`);
}
async function importViaApi(adminId: string, memberId: string) {
const base = API_BASE!;
const tasks = mapAllHrProjects();
const existingRes = await fetch(`${base}/api/tasks`);
if (!existingRes.ok) throw new Error(`GET /api/tasks failed: ${existingRes.status}`);
const existing = (await existingRes.json()) as { id: string }[];
for (const task of existing) {
const del = await fetch(`${base}/api/tasks/${task.id}`, { method: 'DELETE' });
if (!del.ok && del.status !== 204) throw new Error(`DELETE ${task.id} failed: ${del.status}`);
}
for (const t of tasks) {
const { milestones, detailContent, ...taskData } = t;
const body = {
...taskData,
startDate: taskData.startDate?.toISOString() ?? null,
dueDate: taskData.dueDate?.toISOString() ?? null,
creatorId: adminId,
assigneeId: memberId,
};
const createRes = await fetch(`${base}/api/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!createRes.ok) {
const err = await createRes.text();
throw new Error(`POST task "${t.title}" failed: ${createRes.status} ${err}`);
}
const created = (await createRes.json()) as { id: string };
for (const [order, ms] of milestones.entries()) {
const msRes = await fetch(`${base}/api/milestones/${created.id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...ms, order }),
});
if (!msRes.ok) console.warn(` ⚠ milestone skip: ${t.title} / ${ms.title}`);
}
if (detailContent) {
const detailRes = await fetch(`${base}/api/details/${created.id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: detailContent, authorName: '관리자' }),
});
if (!detailRes.ok) console.warn(` ⚠ detail skip: ${t.title}`);
}
}
console.log(`✅ API import complete: ${tasks.length} tasks → ${base}`);
}
async function main() {
const tasks = mapAllHrProjects();
console.log(`📦 HR_Dashboard → ${tasks.length} tasks mapped`);
let adminId: string;
let memberId: string;
if (API_BASE) {
const res = await fetch(`${API_BASE}/api/tasks`);
const existing = res.ok ? ((await res.json()) as { creatorId: string; assigneeId: string | null }[]) : [];
if (existing.length > 0) {
adminId = existing[0].creatorId;
memberId = existing[0].assigneeId ?? existing[0].creatorId;
} else {
throw new Error('API에 기존 task가 없어 creatorId를 확인할 수 없습니다.');
}
await importViaApi(adminId, memberId);
} else {
const admin = await prisma.user.upsert({
where: { email: 'admin@eene.com' },
update: {},
create: { email: 'admin@eene.com', password: '!', name: '관리자', role: 'ADMIN', department: 'EENE' },
});
const member = await prisma.user.upsert({
where: { email: 'member@eene.com' },
update: { name: '정성호' },
create: { email: 'member@eene.com', password: '!', name: '정성호', role: 'MEMBER', department: 'EENE' },
});
adminId = admin.id;
memberId = member.id;
await importViaPrisma(adminId, memberId);
}
}
main()
.catch((err) => {
console.error('❌ Import failed:', err);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@@ -12,14 +12,20 @@ app.use(helmet());
const allowedOrigins = [
'http://localhost:3000',
'http://172.16.8.248:3000',
'https://eene-dashboard.vercel.app',
process.env.FRONTEND_URL,
].filter(Boolean) as string[];
function isAllowedOrigin(origin: string): boolean {
if (allowedOrigins.includes(origin)) return true;
if (/^https:\/\/[\w-]+\.vercel\.app$/.test(origin)) return true;
return false;
}
app.use(
cors({
origin: (origin, callback) => {
// 같은 서버에서 직접 호출하거나 허용된 origin이면 통과
if (!origin || allowedOrigins.includes(origin)) {
if (!origin || isAllowedOrigin(origin)) {
callback(null, true);
} else {
callback(new Error(`CORS 차단: ${origin}`));

View File

@@ -1,6 +1,22 @@
import { prisma } from './prisma';
import { AppError } from '../middleware/errorHandler';
/** 업무 생성 시 사용할 creatorId (클라이언트의 잘못된 값 무시) */
export async function resolveCreatorId(requested?: string): Promise<string> {
if (requested?.trim() && requested !== 'system') {
const user = await prisma.user.findUnique({ where: { id: requested.trim() } });
if (user) return user.id;
}
const admin = await prisma.user.findFirst({ where: { role: 'ADMIN' }, select: { id: true } });
if (admin) return admin.id;
const anyUser = await prisma.user.findFirst({ select: { id: true } });
if (anyUser) return anyUser.id;
throw new AppError(500, '사용자를 찾을 수 없습니다. 관리자 계정을 먼저 생성해 주세요.');
}
/** task 작성자 또는 관리자 등 유효한 user id 반환 (FK 오류 방지) */
export async function resolveTaskActorId(taskId: string): Promise<string> {
const task = await prisma.task.findUnique({

View File

@@ -1,5 +1,6 @@
import { Router } from 'express';
import { prisma } from '../lib/prisma';
import { resolveCreatorId } from '../lib/resolveUser';
import { AppError } from '../middleware/errorHandler';
const router = Router();
@@ -67,6 +68,8 @@ router.post('/', async (req, res, next) => {
throw new AppError(400, '제목과 분기는 필수입니다.');
}
const creatorId = await resolveCreatorId((req.body as Record<string, string>).creatorId);
const task = await prisma.task.create({
data: {
title,
@@ -89,7 +92,7 @@ router.post('/', async (req, res, next) => {
showProgress: showProgress !== undefined ? showProgress === 'true' || showProgress === true : true,
keywords: keywords || null,
assigneeId: assigneeId || null,
creatorId: (req.body as Record<string, string>).creatorId ?? 'system',
creatorId,
},
});