Initial commit - EENE Dashboard
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
3153
backend/package-lock.json
generated
Normal file
3153
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
backend/package.json
Normal file
41
backend/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "eene-dashboard-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "EENE 인재성장팀 대시보드 - Backend API",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:generate": "prisma generate",
|
||||
"db:studio": "prisma studio",
|
||||
"db:seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.0.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.0",
|
||||
"helmet": "^8.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"socket.io": "^4.8.0",
|
||||
"uuid": "^10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"prisma": "^6.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
147
backend/prisma/migrations/20260528092950_init/migration.sql
Normal file
147
backend/prisma/migrations/20260528092950_init/migration.sql
Normal file
@@ -0,0 +1,147 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Role" AS ENUM ('ADMIN', 'MANAGER', 'MEMBER');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TaskStatus" AS ENUM ('TODO', 'IN_PROGRESS', 'REVIEW', 'DONE', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Priority" AS ENUM ('LOW', 'MEDIUM', 'HIGH', 'URGENT');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"role" "Role" NOT NULL DEFAULT 'MEMBER',
|
||||
"department" TEXT,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tasks" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"status" "TaskStatus" NOT NULL DEFAULT 'TODO',
|
||||
"priority" "Priority" NOT NULL DEFAULT 'MEDIUM',
|
||||
"quarter" TEXT NOT NULL,
|
||||
"category" TEXT,
|
||||
"dueDate" TIMESTAMP(3),
|
||||
"creatorId" TEXT NOT NULL,
|
||||
"assigneeId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "tasks_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "task_details" (
|
||||
"id" TEXT NOT NULL,
|
||||
"taskId" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"updatedBy" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "task_details_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "kpi_metrics" (
|
||||
"id" TEXT NOT NULL,
|
||||
"taskId" TEXT NOT NULL,
|
||||
"quarter" TEXT NOT NULL,
|
||||
"target" DOUBLE PRECISION NOT NULL,
|
||||
"actual" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"unit" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "kpi_metrics_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "files" (
|
||||
"id" TEXT NOT NULL,
|
||||
"taskId" TEXT NOT NULL,
|
||||
"filename" TEXT NOT NULL,
|
||||
"originalName" TEXT NOT NULL,
|
||||
"mimetype" TEXT NOT NULL,
|
||||
"size" INTEGER NOT NULL,
|
||||
"path" TEXT NOT NULL,
|
||||
"uploadedBy" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "files_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "audit_logs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"entity" TEXT NOT NULL,
|
||||
"entityId" TEXT,
|
||||
"details" JSONB,
|
||||
"ipAddress" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tasks_quarter_idx" ON "tasks"("quarter");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tasks_status_idx" ON "tasks"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tasks_assigneeId_idx" ON "tasks"("assigneeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "task_details_taskId_idx" ON "task_details"("taskId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "kpi_metrics_quarter_idx" ON "kpi_metrics"("quarter");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "files_taskId_idx" ON "files"("taskId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_userId_idx" ON "audit_logs"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_createdAt_idx" ON "audit_logs"("createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "task_details" ADD CONSTRAINT "task_details_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "task_details" ADD CONSTRAINT "task_details_updatedBy_fkey" FOREIGN KEY ("updatedBy") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "kpi_metrics" ADD CONSTRAINT "kpi_metrics_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "files" ADD CONSTRAINT "files_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "files" ADD CONSTRAINT "files_uploadedBy_fkey" FOREIGN KEY ("uploadedBy") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "tasks" ADD COLUMN "issueNote" TEXT,
|
||||
ADD COLUMN "progress" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "section" TEXT,
|
||||
ADD COLUMN "tag" TEXT,
|
||||
ADD COLUMN "taskType" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tasks_section_idx" ON "tasks"("section");
|
||||
3
backend/prisma/migrations/migration_lock.toml
Normal file
3
backend/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
193
backend/prisma/schema.prisma
Normal file
193
backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,193 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ─── 사용자 ──────────────────────────────────────────────────
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
password String
|
||||
name String
|
||||
role Role @default(MEMBER)
|
||||
department String?
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
createdTasks Task[] @relation("CreatedTasks")
|
||||
assignedTasks Task[] @relation("AssignedTasks")
|
||||
taskDetails TaskDetail[]
|
||||
uploadedFiles File[]
|
||||
auditLogs AuditLog[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
enum Role {
|
||||
ADMIN
|
||||
MANAGER
|
||||
MEMBER
|
||||
}
|
||||
|
||||
// ─── 업무 ────────────────────────────────────────────────────
|
||||
|
||||
model Task {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
description String?
|
||||
status TaskStatus @default(TODO)
|
||||
priority Priority @default(MEDIUM)
|
||||
quarter String // 예: "2026-Q2"
|
||||
category String?
|
||||
section String? // HR | 운영관리
|
||||
tag String? // Growth | Policy | Performance | Culture | Asset | Space | Safety | Environment
|
||||
taskType String? // 상시업무 | 프로젝트
|
||||
progress Int @default(0)
|
||||
issueNote String?
|
||||
startDate DateTime?
|
||||
dueDate DateTime?
|
||||
creatorId String
|
||||
assigneeId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
creator User @relation("CreatedTasks", fields: [creatorId], references: [id])
|
||||
assignee User? @relation("AssignedTasks", fields: [assigneeId], references: [id])
|
||||
details TaskDetail[]
|
||||
kpiMetrics KpiMetric[]
|
||||
files File[]
|
||||
milestones Milestone[]
|
||||
|
||||
@@index([quarter])
|
||||
@@index([status])
|
||||
@@index([assigneeId])
|
||||
@@index([section])
|
||||
@@map("tasks")
|
||||
}
|
||||
|
||||
enum TaskStatus {
|
||||
TODO
|
||||
IN_PROGRESS
|
||||
REVIEW
|
||||
DONE
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum Priority {
|
||||
LOW
|
||||
MEDIUM
|
||||
HIGH
|
||||
URGENT
|
||||
}
|
||||
|
||||
// ─── 업무 상세 / 진행 기록 ────────────────────────────────────
|
||||
|
||||
model TaskDetail {
|
||||
id String @id @default(cuid())
|
||||
taskId String
|
||||
content String
|
||||
updatedBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||
author User @relation(fields: [updatedBy], references: [id])
|
||||
|
||||
@@index([taskId])
|
||||
@@map("task_details")
|
||||
}
|
||||
|
||||
// ─── KPI 지표 ────────────────────────────────────────────────
|
||||
|
||||
model KpiMetric {
|
||||
id String @id @default(cuid())
|
||||
taskId String
|
||||
quarter String
|
||||
target Float
|
||||
actual Float @default(0)
|
||||
unit String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([quarter])
|
||||
@@map("kpi_metrics")
|
||||
}
|
||||
|
||||
// ─── 파일 ────────────────────────────────────────────────────
|
||||
|
||||
model File {
|
||||
id String @id @default(cuid())
|
||||
taskId String
|
||||
filename String // 저장된 파일명 (UUID)
|
||||
originalName String // 원본 파일명
|
||||
mimetype String
|
||||
size Int
|
||||
path String
|
||||
uploadedBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||
uploader User @relation(fields: [uploadedBy], references: [id])
|
||||
|
||||
@@index([taskId])
|
||||
@@map("files")
|
||||
}
|
||||
|
||||
// ─── 마일스톤 (프로세스 단계) ─────────────────────────────────
|
||||
|
||||
model Milestone {
|
||||
id String @id @default(cuid())
|
||||
taskId String
|
||||
title String
|
||||
description String?
|
||||
dueDate DateTime?
|
||||
completedAt DateTime?
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([taskId])
|
||||
@@map("milestones")
|
||||
}
|
||||
|
||||
// ─── 컬럼 설정 ───────────────────────────────────────────────
|
||||
|
||||
model ColumnConfig {
|
||||
key String @id // "HR" | "운영관리"
|
||||
title String
|
||||
titleEn String?
|
||||
subtitle String?
|
||||
cardOrder String? // JSON 배열: task id 순서
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("column_configs")
|
||||
}
|
||||
|
||||
// ─── 감사 로그 ───────────────────────────────────────────────
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
action String // CREATE, UPDATE, DELETE, LOGIN 등
|
||||
entity String // Task, User, File 등
|
||||
entityId String?
|
||||
details Json?
|
||||
ipAddress String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([userId])
|
||||
@@index([createdAt])
|
||||
@@map("audit_logs")
|
||||
}
|
||||
145
backend/prisma/seed.ts
Normal file
145
backend/prisma/seed.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import 'dotenv/config';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
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);
|
||||
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { email: 'admin@eene.com' },
|
||||
update: {},
|
||||
create: { email: 'admin@eene.com', password: adminPw, name: '관리자', role: 'ADMIN', department: 'EENE' },
|
||||
});
|
||||
|
||||
const member = await prisma.user.upsert({
|
||||
where: { email: 'member@eene.com' },
|
||||
update: {},
|
||||
create: { email: 'member@eene.com', password: memberPw, name: '홍길동', role: 'MEMBER', department: 'EENE' },
|
||||
});
|
||||
|
||||
console.log(`✅ Users ready`);
|
||||
|
||||
// ─── 기존 업무 삭제 후 재생성 ─────────────────────────
|
||||
await prisma.kpiMetric.deleteMany({});
|
||||
await prisma.taskDetail.deleteMany({});
|
||||
await prisma.task.deleteMany({});
|
||||
|
||||
// ─── HR 부문 업무 ──────────────────────────────────────
|
||||
const hrTasks = [
|
||||
{
|
||||
title: '사내 핵심역량 교육체계 수립 (HRD)',
|
||||
description: '직급별/직무별 필수 역량 가이드라인 도출\n사내 강사 제도 양성 및 콘텐츠 기획 단계',
|
||||
status: 'IN_PROGRESS' as const,
|
||||
priority: 'HIGH' as const,
|
||||
section: 'HR',
|
||||
tag: 'Growth',
|
||||
taskType: '프로젝트',
|
||||
progress: 20,
|
||||
issueNote: '[5.28] 사내 강사 풀 확보 및 보상안 검토',
|
||||
},
|
||||
{
|
||||
title: '그룹 표준 취업규칙 개정안 (HRM)',
|
||||
description: '유연근무제 및 근태 관리 프로세스 고도화\n노사협의회 안건 조율 및 근로조건 개선안 반영',
|
||||
status: 'IN_PROGRESS' as const,
|
||||
priority: 'HIGH' as const,
|
||||
section: 'HR',
|
||||
tag: 'Policy',
|
||||
taskType: '프로젝트',
|
||||
progress: 40,
|
||||
issueNote: '[5.28] 가족사 간 피드백 이견 조율 진행',
|
||||
},
|
||||
{
|
||||
title: '상반기 평가 지표(KPI) 보완',
|
||||
description: '1분기 피드백 기반 부서별 KPI 정렬\n상반기 업적 평가 시뮬레이션 기획',
|
||||
status: 'IN_PROGRESS' as const,
|
||||
priority: 'MEDIUM' as const,
|
||||
section: 'HR',
|
||||
tag: 'Performance',
|
||||
taskType: '상시업무',
|
||||
progress: 70,
|
||||
issueNote: null,
|
||||
},
|
||||
{
|
||||
title: '가족사 시너지 조직문화 캠페인',
|
||||
description: '임직원 만족도 조사(Engagement Survey) 설계',
|
||||
status: 'TODO' as const,
|
||||
priority: 'LOW' as const,
|
||||
section: 'HR',
|
||||
tag: 'Culture',
|
||||
taskType: '프로젝트',
|
||||
progress: 0,
|
||||
issueNote: null,
|
||||
},
|
||||
];
|
||||
|
||||
// ─── 운영관리 부문 업무 ────────────────────────────────
|
||||
const opsTasks = [
|
||||
{
|
||||
title: '全사 IT자산 수명주기 표준화 구축',
|
||||
description: '자산 전수조사 데이터 기반 불용 기준 정립\n라이선스 최적화를 통한 비용 절감안 도출',
|
||||
status: 'IN_PROGRESS' as const,
|
||||
priority: 'HIGH' as const,
|
||||
section: '운영관리',
|
||||
tag: 'Asset',
|
||||
taskType: '프로젝트',
|
||||
progress: 60,
|
||||
issueNote: null,
|
||||
},
|
||||
{
|
||||
title: '기술센터 2F 세미나룸 브랜딩 기획',
|
||||
description: '다목적 교육 공간 활용을 위한 운영 매뉴얼 수립\n공간 아이덴티티 반영 사인물(Signage) 기획',
|
||||
status: 'REVIEW' as const,
|
||||
priority: 'MEDIUM' as const,
|
||||
section: '운영관리',
|
||||
tag: 'Space',
|
||||
taskType: '프로젝트',
|
||||
progress: 80,
|
||||
issueNote: null,
|
||||
},
|
||||
{
|
||||
title: '중대재해법 대응 안전보건 매뉴얼 정비',
|
||||
description: '현장 점검 기반 소방 및 MSDS 관리 체계 보완\n사내 안전사고 예방 가이드라인 표준화 기획',
|
||||
status: 'IN_PROGRESS' as const,
|
||||
priority: 'HIGH' as const,
|
||||
section: '운영관리',
|
||||
tag: 'Safety',
|
||||
taskType: '상시업무',
|
||||
progress: 30,
|
||||
issueNote: null,
|
||||
},
|
||||
{
|
||||
title: '공용 공간 인프라 개선',
|
||||
description: '기술센터 로비 및 라운지 환경 정비 기획',
|
||||
status: 'TODO' as const,
|
||||
priority: 'LOW' as const,
|
||||
section: '운영관리',
|
||||
tag: 'Environment',
|
||||
taskType: '프로젝트',
|
||||
progress: 0,
|
||||
issueNote: null,
|
||||
},
|
||||
];
|
||||
|
||||
for (const t of [...hrTasks, ...opsTasks]) {
|
||||
await prisma.task.create({
|
||||
data: {
|
||||
...t,
|
||||
quarter: '2026-Q2',
|
||||
category: t.section,
|
||||
creatorId: admin.id,
|
||||
assigneeId: member.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`✅ Tasks created: ${hrTasks.length + opsTasks.length}개`);
|
||||
console.log('🎉 Seeding complete!');
|
||||
}
|
||||
|
||||
main().catch(console.error).finally(() => prisma.$disconnect());
|
||||
48
backend/src/app.ts
Normal file
48
backend/src/app.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import morgan from 'morgan';
|
||||
import path from 'path';
|
||||
import { errorHandler } from './middleware/errorHandler';
|
||||
import routes from './routes';
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(helmet());
|
||||
const allowedOrigins = [
|
||||
'http://localhost:3000',
|
||||
'http://172.16.8.248:3000',
|
||||
process.env.FRONTEND_URL,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
// 같은 서버에서 직접 호출하거나 허용된 origin이면 통과
|
||||
if (!origin || allowedOrigins.includes(origin)) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(new Error(`CORS 차단: ${origin}`));
|
||||
}
|
||||
},
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
app.use(morgan('dev'));
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// 업로드 파일 정적 서빙
|
||||
const uploadDir = path.resolve(process.env.UPLOAD_DIR || '../uploads');
|
||||
app.use('/uploads', express.static(uploadDir));
|
||||
|
||||
// API Routes
|
||||
app.use('/api', routes);
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
export default app;
|
||||
35
backend/src/index.ts
Normal file
35
backend/src/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'dotenv/config';
|
||||
import { createServer } from 'http';
|
||||
import { Server } from 'socket.io';
|
||||
import app from './app';
|
||||
import { setupSocketHandlers } from './socket';
|
||||
import { prisma } from './lib/prisma';
|
||||
|
||||
const PORT = Number(process.env.PORT) || 4000;
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
|
||||
|
||||
const httpServer = createServer(app);
|
||||
|
||||
const io = new Server(httpServer, {
|
||||
cors: {
|
||||
origin: FRONTEND_URL,
|
||||
credentials: true,
|
||||
},
|
||||
});
|
||||
|
||||
setupSocketHandlers(io);
|
||||
|
||||
async function main() {
|
||||
await prisma.$connect();
|
||||
console.log('✅ Database connected');
|
||||
|
||||
httpServer.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`✅ Server running on http://0.0.0.0:${PORT} (팀원: http://<이PC의IP>:${PORT})`);
|
||||
console.log(`✅ Socket.io ready`);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Failed to start server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
14
backend/src/lib/prisma.ts
Normal file
14
backend/src/lib/prisma.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __prisma: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
export const prisma = global.__prisma ?? new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
global.__prisma = prisma;
|
||||
}
|
||||
43
backend/src/middleware/auth.ts
Normal file
43
backend/src/middleware/auth.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
export interface JwtPayload {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: JwtPayload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function authenticate(req: Request, res: Response, next: NextFunction): void {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
res.status(401).json({ message: '인증 토큰이 없습니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
|
||||
req.user = payload;
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ message: '유효하지 않은 토큰입니다.' });
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||||
if (req.user?.role !== 'ADMIN') {
|
||||
res.status(403).json({ message: '관리자 권한이 필요합니다.' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
26
backend/src/middleware/errorHandler.ts
Normal file
26
backend/src/middleware/errorHandler.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
public statusCode: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AppError';
|
||||
}
|
||||
}
|
||||
|
||||
export function errorHandler(
|
||||
err: Error,
|
||||
_req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction,
|
||||
): void {
|
||||
if (err instanceof AppError) {
|
||||
res.status(err.statusCode).json({ message: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('[Error]', err);
|
||||
res.status(500).json({ message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
26
backend/src/middleware/upload.ts
Normal file
26
backend/src/middleware/upload.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import fs from 'fs';
|
||||
|
||||
const MAX_SIZE_MB = Number(process.env.MAX_FILE_SIZE_MB) || 20;
|
||||
const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR || '../uploads');
|
||||
|
||||
if (!fs.existsSync(UPLOAD_DIR)) {
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination(_req, _file, cb) {
|
||||
cb(null, UPLOAD_DIR);
|
||||
},
|
||||
filename(_req, file, cb) {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `${uuidv4()}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: MAX_SIZE_MB * 1024 * 1024 },
|
||||
});
|
||||
70
backend/src/routes/auth.ts
Normal file
70
backend/src/routes/auth.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// POST /api/auth/login
|
||||
router.post('/login', async (req, res, next) => {
|
||||
try {
|
||||
const { email, password } = req.body as { email: string; password: string };
|
||||
|
||||
if (!email || !password) {
|
||||
throw new AppError(400, '이메일과 비밀번호를 입력해주세요.');
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user || !user.isActive) {
|
||||
throw new AppError(401, '이메일 또는 비밀번호가 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
const isMatch = await bcrypt.compare(password, user.password);
|
||||
if (!isMatch) {
|
||||
throw new AppError(401, '이메일 또는 비밀번호가 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id, email: user.email, role: user.role },
|
||||
process.env.JWT_SECRET!,
|
||||
{ expiresIn: process.env.JWT_EXPIRES_IN || '7d' } as jwt.SignOptions,
|
||||
);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
department: user.department,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/auth/me
|
||||
router.get('/me', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.userId },
|
||||
select: { id: true, email: true, name: true, role: true, department: true },
|
||||
});
|
||||
|
||||
if (!user) throw new AppError(404, '사용자를 찾을 수 없습니다.');
|
||||
res.json(user);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/logout (클라이언트 토큰 삭제용 — 서버는 stateless)
|
||||
router.post('/logout', authenticate, (_req, res) => {
|
||||
res.json({ message: '로그아웃 되었습니다.' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
67
backend/src/routes/columns.ts
Normal file
67
backend/src/routes/columns.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Router } from 'express';
|
||||
import { prisma } from '../lib/prisma';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const DEFAULTS: Record<string, { title: string; titleEn: string; subtitle: string }> = {
|
||||
HR: {
|
||||
title: 'HR 부문',
|
||||
titleEn: 'Human Resources',
|
||||
subtitle: '임직원의 몰입(Engagement)과 성장(Education)',
|
||||
},
|
||||
'운영관리': {
|
||||
title: '운영관리 부문',
|
||||
titleEn: 'Operations',
|
||||
subtitle: '인프라 고도화와 자산 라이프사이클 표준화',
|
||||
},
|
||||
};
|
||||
|
||||
// GET /api/columns/:key
|
||||
router.get('/:key', async (req, res, next) => {
|
||||
try {
|
||||
const { key } = req.params;
|
||||
let config = await prisma.columnConfig.findUnique({ where: { key } });
|
||||
|
||||
if (!config) {
|
||||
const def = DEFAULTS[key];
|
||||
config = await prisma.columnConfig.create({
|
||||
data: { key, title: def?.title ?? key, titleEn: def?.titleEn ?? '', subtitle: def?.subtitle ?? '' },
|
||||
});
|
||||
}
|
||||
|
||||
res.json(config);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/columns/:key
|
||||
router.patch('/:key', async (req, res, next) => {
|
||||
try {
|
||||
const { key } = req.params;
|
||||
const { title, titleEn, subtitle, cardOrder } = req.body as Record<string, string>;
|
||||
|
||||
const config = await prisma.columnConfig.upsert({
|
||||
where: { key },
|
||||
create: {
|
||||
key,
|
||||
title: title ?? DEFAULTS[key]?.title ?? key,
|
||||
titleEn: titleEn ?? DEFAULTS[key]?.titleEn ?? '',
|
||||
subtitle: subtitle ?? DEFAULTS[key]?.subtitle ?? '',
|
||||
cardOrder: cardOrder ?? null,
|
||||
},
|
||||
update: {
|
||||
...(title !== undefined && { title }),
|
||||
...(titleEn !== undefined && { titleEn }),
|
||||
...(subtitle !== undefined && { subtitle }),
|
||||
...(cardOrder !== undefined && { cardOrder }),
|
||||
},
|
||||
});
|
||||
|
||||
res.json(config);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
84
backend/src/routes/files.ts
Normal file
84
backend/src/routes/files.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Router } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { upload } from '../middleware/upload';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// POST /api/files/upload/:taskId — 파일 업로드
|
||||
router.post('/upload/:taskId', upload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.file) throw new AppError(400, '파일이 없습니다.');
|
||||
const taskId = String(req.params.taskId);
|
||||
|
||||
const task = await prisma.task.findUnique({ where: { id: taskId } });
|
||||
if (!task) throw new AppError(404, '업무를 찾을 수 없습니다.');
|
||||
|
||||
const fileRecord = await prisma.file.create({
|
||||
data: {
|
||||
taskId,
|
||||
filename: req.file.filename,
|
||||
originalName: req.file.originalname,
|
||||
mimetype: req.file.mimetype,
|
||||
size: req.file.size,
|
||||
path: req.file.path,
|
||||
uploadedBy: (req.body as Record<string, string>).uploadedBy ?? 'system',
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json(fileRecord);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/files/:id/view — 파일 미리보기 (브라우저에서 바로 열기)
|
||||
router.get('/:id/view', async (req, res, next) => {
|
||||
try {
|
||||
const file = await prisma.file.findUnique({ where: { id: req.params.id } });
|
||||
if (!file) throw new AppError(404, '파일을 찾을 수 없습니다.');
|
||||
if (!fs.existsSync(file.path)) throw new AppError(404, '파일이 서버에 없습니다.');
|
||||
|
||||
res.setHeader('Content-Type', file.mimetype);
|
||||
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(file.originalName)}"`);
|
||||
fs.createReadStream(file.path).pipe(res);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/files/:id/download — 파일 다운로드
|
||||
router.get('/:id/download', async (req, res, next) => {
|
||||
try {
|
||||
const file = await prisma.file.findUnique({ where: { id: req.params.id } });
|
||||
if (!file) throw new AppError(404, '파일을 찾을 수 없습니다.');
|
||||
if (!fs.existsSync(file.path)) throw new AppError(404, '파일이 서버에 없습니다.');
|
||||
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(file.originalName)}"`);
|
||||
res.download(file.path, file.originalName);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/files/:id — 파일 삭제
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const file = await prisma.file.findUnique({ where: { id: req.params.id } });
|
||||
if (!file) throw new AppError(404, '파일을 찾을 수 없습니다.');
|
||||
|
||||
// 실제 파일 삭제
|
||||
if (fs.existsSync(file.path)) {
|
||||
fs.unlinkSync(file.path);
|
||||
}
|
||||
|
||||
await prisma.file.delete({ where: { id: req.params.id } });
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
20
backend/src/routes/index.ts
Normal file
20
backend/src/routes/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Router } from 'express';
|
||||
import authRoutes from './auth';
|
||||
import taskRoutes from './tasks';
|
||||
import userRoutes from './users';
|
||||
import fileRoutes from './files';
|
||||
import kpiRoutes from './kpi';
|
||||
import columnRoutes from './columns';
|
||||
import milestoneRoutes from './milestones';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use('/auth', authRoutes);
|
||||
router.use('/tasks', taskRoutes);
|
||||
router.use('/users', userRoutes);
|
||||
router.use('/files', fileRoutes);
|
||||
router.use('/kpi', kpiRoutes);
|
||||
router.use('/columns', columnRoutes);
|
||||
router.use('/milestones', milestoneRoutes);
|
||||
|
||||
export default router;
|
||||
75
backend/src/routes/kpi.ts
Normal file
75
backend/src/routes/kpi.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Router } from 'express';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/kpi?quarter=2026-Q2 — 분기별 KPI 전체 조회
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const { quarter } = req.query as { quarter?: string };
|
||||
|
||||
const metrics = await prisma.kpiMetric.findMany({
|
||||
where: quarter ? { quarter } : undefined,
|
||||
include: {
|
||||
task: { select: { id: true, title: true, status: true, assigneeId: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
res.json(metrics);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/kpi — KPI 등록
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const { taskId, quarter, target, actual, unit } = req.body as {
|
||||
taskId: string;
|
||||
quarter: string;
|
||||
target: number;
|
||||
actual?: number;
|
||||
unit?: string;
|
||||
};
|
||||
|
||||
if (!taskId || !quarter || target === undefined) {
|
||||
throw new AppError(400, 'taskId, quarter, target 은 필수입니다.');
|
||||
}
|
||||
|
||||
const metric = await prisma.kpiMetric.create({
|
||||
data: { taskId, quarter, target: Number(target), actual: Number(actual ?? 0), unit },
|
||||
});
|
||||
|
||||
res.status(201).json(metric);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/kpi/:id — KPI 수정 (실적 업데이트 등)
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { target, actual, unit } = req.body as {
|
||||
target?: number;
|
||||
actual?: number;
|
||||
unit?: string;
|
||||
};
|
||||
|
||||
const metric = await prisma.kpiMetric.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
...(target !== undefined && { target: Number(target) }),
|
||||
...(actual !== undefined && { actual: Number(actual) }),
|
||||
...(unit !== undefined && { unit }),
|
||||
},
|
||||
});
|
||||
|
||||
res.json(metric);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
75
backend/src/routes/milestones.ts
Normal file
75
backend/src/routes/milestones.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Router } from 'express';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/milestones/:taskId — 업무의 마일스톤 목록
|
||||
router.get('/:taskId', async (req, res, next) => {
|
||||
try {
|
||||
const milestones = await prisma.milestone.findMany({
|
||||
where: { taskId: req.params.taskId },
|
||||
orderBy: { order: 'asc' },
|
||||
});
|
||||
res.json(milestones);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/milestones/:taskId — 마일스톤 추가
|
||||
router.post('/:taskId', async (req, res, next) => {
|
||||
try {
|
||||
const { title, description, dueDate } = req.body as Record<string, string>;
|
||||
|
||||
const count = await prisma.milestone.count({ where: { taskId: req.params.taskId } });
|
||||
|
||||
const milestone = await prisma.milestone.create({
|
||||
data: {
|
||||
taskId: req.params.taskId,
|
||||
title,
|
||||
description: description || null,
|
||||
dueDate: dueDate ? new Date(dueDate) : null,
|
||||
order: count,
|
||||
},
|
||||
});
|
||||
res.status(201).json(milestone);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/milestones/item/:id — 마일스톤 수정 (완료 처리 포함)
|
||||
router.patch('/item/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { title, description, dueDate, completed, order } = req.body as Record<string, string | boolean | number>;
|
||||
|
||||
const milestone = await prisma.milestone.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
...(title !== undefined && { title: title as string }),
|
||||
...(description !== undefined && { description: description as string || null }),
|
||||
...(dueDate !== undefined && { dueDate: dueDate ? new Date(dueDate as string) : null }),
|
||||
...(order !== undefined && { order: Number(order) }),
|
||||
...(completed !== undefined && {
|
||||
completedAt: completed ? new Date() : null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
res.json(milestone);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/milestones/item/:id — 마일스톤 삭제
|
||||
router.delete('/item/:id', async (req, res, next) => {
|
||||
try {
|
||||
await prisma.milestone.delete({ where: { id: req.params.id } });
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
141
backend/src/routes/tasks.ts
Normal file
141
backend/src/routes/tasks.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Router } from 'express';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/tasks — 목록 조회 (필터: status, quarter, assigneeId)
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const { status, quarter, assigneeId, category } = req.query as Record<string, string>;
|
||||
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: {
|
||||
...(status && { status: status as any }),
|
||||
...(quarter && { quarter }),
|
||||
...(assigneeId && { assigneeId }),
|
||||
...(category && { category }),
|
||||
},
|
||||
include: {
|
||||
assignee: { select: { id: true, name: true, department: true } },
|
||||
creator: { select: { id: true, name: true } },
|
||||
_count: { select: { files: true, details: true } },
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
|
||||
res.json(tasks);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/tasks/:id — 단건 상세 조회
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const task = await prisma.task.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: {
|
||||
assignee: { select: { id: true, name: true, department: true } },
|
||||
creator: { select: { id: true, name: true } },
|
||||
details: { orderBy: { createdAt: 'desc' } },
|
||||
kpiMetrics: true,
|
||||
files: true,
|
||||
milestones: { orderBy: { order: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!task) throw new AppError(404, '업무를 찾을 수 없습니다.');
|
||||
res.json(task);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/tasks — 업무 등록
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const { title, description, status, priority, quarter, category,
|
||||
section, tag, taskType, progress, issueNote, startDate, dueDate, assigneeId } =
|
||||
req.body as Record<string, string>;
|
||||
|
||||
if (!title || !quarter) {
|
||||
throw new AppError(400, '제목과 분기는 필수입니다.');
|
||||
}
|
||||
|
||||
const task = await prisma.task.create({
|
||||
data: {
|
||||
title,
|
||||
description,
|
||||
status: (status as any) || 'TODO',
|
||||
priority: (priority as any) || 'MEDIUM',
|
||||
quarter,
|
||||
category,
|
||||
section,
|
||||
tag,
|
||||
taskType,
|
||||
progress: progress ? Number(progress) : 0,
|
||||
issueNote: issueNote || null,
|
||||
startDate: startDate ? new Date(startDate) : undefined,
|
||||
dueDate: dueDate ? new Date(dueDate) : undefined,
|
||||
assigneeId: assigneeId || null,
|
||||
creatorId: (req.body as Record<string, string>).creatorId ?? 'system',
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json(task);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/tasks/:id — 업무 수정
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const existing = await prisma.task.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) throw new AppError(404, '업무를 찾을 수 없습니다.');
|
||||
|
||||
const { title, description, status, priority, quarter, category,
|
||||
section, tag, taskType, progress, issueNote, startDate, dueDate, assigneeId } =
|
||||
req.body as Record<string, string>;
|
||||
|
||||
const task = await prisma.task.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
...(title && { title }),
|
||||
...(description !== undefined && { description }),
|
||||
...(status && { status: status as any }),
|
||||
...(priority && { priority: priority as any }),
|
||||
...(quarter && { quarter }),
|
||||
...(category !== undefined && { category }),
|
||||
...(section !== undefined && { section }),
|
||||
...(tag !== undefined && { tag }),
|
||||
...(taskType !== undefined && { taskType }),
|
||||
...(progress !== undefined && { progress: Number(progress) }),
|
||||
...(issueNote !== undefined && { issueNote: issueNote || null }),
|
||||
...(startDate !== undefined && { startDate: startDate ? new Date(startDate) : null }),
|
||||
...(dueDate !== undefined && { dueDate: dueDate ? new Date(dueDate) : null }),
|
||||
...(assigneeId !== undefined && { assigneeId: assigneeId || null }),
|
||||
},
|
||||
});
|
||||
|
||||
res.json(task);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/tasks/:id — 업무 삭제
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const existing = await prisma.task.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) throw new AppError(404, '업무를 찾을 수 없습니다.');
|
||||
|
||||
await prisma.task.delete({ where: { id: req.params.id } });
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
78
backend/src/routes/users.ts
Normal file
78
backend/src/routes/users.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { authenticate, requireAdmin } from '../middleware/auth';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate);
|
||||
|
||||
// GET /api/users — 전체 목록 (관리자)
|
||||
router.get('/', requireAdmin, async (_req, res, next) => {
|
||||
try {
|
||||
const users = await prisma.user.findMany({
|
||||
select: { id: true, email: true, name: true, role: true, department: true, isActive: true, createdAt: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
res.json(users);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/users — 사용자 생성 (관리자)
|
||||
router.post('/', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const { email, password, name, role, department } = req.body as Record<string, string>;
|
||||
|
||||
if (!email || !password || !name) {
|
||||
throw new AppError(400, '이메일, 비밀번호, 이름은 필수입니다.');
|
||||
}
|
||||
|
||||
const exists = await prisma.user.findUnique({ where: { email } });
|
||||
if (exists) throw new AppError(409, '이미 사용 중인 이메일입니다.');
|
||||
|
||||
const hashed = await bcrypt.hash(password, 12);
|
||||
const user = await prisma.user.create({
|
||||
data: { email, password: hashed, name, role: (role as any) || 'MEMBER', department },
|
||||
select: { id: true, email: true, name: true, role: true, department: true },
|
||||
});
|
||||
|
||||
res.status(201).json(user);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/users/:id — 정보 수정
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const isAdmin = req.user!.role === 'ADMIN';
|
||||
const isSelf = req.user!.userId === req.params.id;
|
||||
|
||||
if (!isAdmin && !isSelf) {
|
||||
throw new AppError(403, '권한이 없습니다.');
|
||||
}
|
||||
|
||||
const { name, department, password, role, isActive } = req.body as Record<string, string>;
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (name) data.name = name;
|
||||
if (department !== undefined) data.department = department;
|
||||
if (password) data.password = await bcrypt.hash(password, 12);
|
||||
if (isAdmin && role) data.role = role;
|
||||
if (isAdmin && isActive !== undefined) data.isActive = isActive === 'true';
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data,
|
||||
select: { id: true, email: true, name: true, role: true, department: true, isActive: true },
|
||||
});
|
||||
|
||||
res.json(user);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
29
backend/src/socket.ts
Normal file
29
backend/src/socket.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Server, Socket } from 'socket.io';
|
||||
|
||||
export function setupSocketHandlers(io: Server): void {
|
||||
io.on('connection', (socket: Socket) => {
|
||||
console.log(`[Socket] Connected: ${socket.id}`);
|
||||
|
||||
// 특정 업무 상세 방에 참여 (우측 모니터 패널용)
|
||||
socket.on('join:task', (taskId: string) => {
|
||||
socket.join(`task:${taskId}`);
|
||||
});
|
||||
|
||||
socket.on('leave:task', (taskId: string) => {
|
||||
socket.leave(`task:${taskId}`);
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log(`[Socket] Disconnected: ${socket.id}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 업무 변경 시 해당 방에 브로드캐스트 (라우터에서 호출)
|
||||
export function emitTaskUpdated(io: Server, taskId: string, data: unknown): void {
|
||||
io.to(`task:${taskId}`).emit('task:updated', data);
|
||||
}
|
||||
|
||||
export function emitTaskListRefresh(io: Server): void {
|
||||
io.emit('tasks:refresh');
|
||||
}
|
||||
19
backend/tsconfig.json
Normal file
19
backend/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user