Initial commit - EENE Dashboard
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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');
|
||||
}
|
||||
Reference in New Issue
Block a user