feat: team org panel, admin CRUD, local deploy tools, bidirectional data sync

Add TeamMember model and APIs, team status UI, /admin page, local server bats,
and scripts to sync data between local PostgreSQL and Render.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
EENE Dashboard
2026-06-06 01:41:00 +09:00
parent d14ff1997c
commit fb2956b0ac
45 changed files with 4104 additions and 376 deletions

View File

@@ -11,6 +11,8 @@ const app = express();
app.use(helmet());
const allowedOrigins = [
'http://localhost:3000',
'http://localhost:5173',
'http://127.0.0.1:3000',
'http://172.16.8.248:3000',
'https://eene-dashboard.vercel.app',
process.env.FRONTEND_URL,
@@ -19,6 +21,11 @@ const allowedOrigins = [
function isAllowedOrigin(origin: string): boolean {
if (allowedOrigins.includes(origin)) return true;
if (/^https:\/\/[\w-]+\.vercel\.app$/.test(origin)) return true;
// 로컬·사설망 프론트 (용량 절약용 로컬 서버)
if (/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) return true;
if (/^http:\/\/172\.(1[6-9]|2\d|3[01])\.\d+\.\d+(:\d+)?$/.test(origin)) return true;
if (/^http:\/\/192\.168\.\d+\.\d+(:\d+)?$/.test(origin)) return true;
if (/^http:\/\/10\.\d+\.\d+\.\d+(:\d+)?$/.test(origin)) return true;
return false;
}

View File

@@ -4,6 +4,7 @@ import { Server } from 'socket.io';
import app from './app';
import { setupSocketHandlers } from './socket';
import { prisma } from './lib/prisma';
import { ensureLocalDirs } from './lib/ensureLocalDirs';
const PORT = Number(process.env.PORT) || 4000;
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
@@ -12,7 +13,7 @@ const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
origin: FRONTEND_URL,
origin: (_origin, callback) => callback(null, true),
credentials: true,
},
});
@@ -20,8 +21,9 @@ const io = new Server(httpServer, {
setupSocketHandlers(io);
async function main() {
ensureLocalDirs();
await prisma.$connect();
console.log('✅ Database connected');
console.log('✅ Database connected (PostgreSQL — 로컬 data/postgres 또는 DATABASE_URL)');
httpServer.listen(PORT, '0.0.0.0', () => {
console.log(`✅ Server running on http://0.0.0.0:${PORT} (팀원: http://<이PC의IP>:${PORT})`);

View File

@@ -0,0 +1,22 @@
import fs from 'fs';
import path from 'path';
/** 로컬 uploads·팀 사진 폴더 생성 (데이터 영구 저장) */
export function ensureLocalDirs() {
const uploadDir = path.resolve(process.env.UPLOAD_DIR || '../uploads');
const teamDir = path.join(uploadDir, 'team');
const dataPostgresHint = path.resolve('../data/postgres');
for (const dir of [uploadDir, teamDir]) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
console.log(`📁 Created: ${dir}`);
}
}
if (!fs.existsSync(dataPostgresHint)) {
console.log(
'💡 PostgreSQL 로컬 저장: 프로젝트 루트에서 docker compose up -d 실행 시 data/postgres 에 DB가 보존됩니다.',
);
}
}

View File

@@ -0,0 +1,76 @@
import { prisma } from './prisma';
export const teamMemberSelect = {
id: true,
name: true,
rank: true,
role: true,
cell: true,
contact: true,
photoUrl: true,
sortOrder: true,
} as const;
export const taskInclude = {
assignee: { select: { id: true, name: true, department: true } },
creator: { select: { id: true, name: true } },
pmMember: { select: teamMemberSelect },
taskAssignees: {
include: { member: { select: teamMemberSelect } },
},
_count: { select: { files: true, details: true } },
} as const;
export const taskDetailInclude = {
assignee: { select: { id: true, name: true, department: true } },
creator: { select: { id: true, name: true } },
pmMember: { select: teamMemberSelect },
taskAssignees: {
include: { member: { select: teamMemberSelect } },
},
details: {
orderBy: { createdAt: 'desc' as const },
include: { author: { select: { id: true, name: true } } },
},
kpiMetrics: true,
files: true,
milestones: { orderBy: { order: 'asc' as const } },
};
export function formatTask<T extends Record<string, unknown>>(task: T) {
const { taskAssignees, ...rest } = task as T & {
taskAssignees?: Array<{ member: unknown }>;
};
const assigneeMembers = (taskAssignees ?? []).map((ta) => ta.member);
return { ...rest, assigneeMembers };
}
export async function syncTaskMembers(
taskId: string,
pmMemberId: string | null | undefined,
assigneeMemberIds: string[] | undefined,
) {
if (pmMemberId !== undefined) {
await prisma.task.update({
where: { id: taskId },
data: { pmMemberId: pmMemberId || null },
});
}
if (assigneeMemberIds !== undefined) {
await prisma.taskAssignee.deleteMany({ where: { taskId } });
const ids = [...new Set(assigneeMemberIds.filter(Boolean))];
if (ids.length > 0) {
await prisma.taskAssignee.createMany({
data: ids.map((memberId) => ({ taskId, memberId })),
});
}
}
}
export function parseMemberIds(body: Record<string, unknown>): string[] | undefined {
if (body.assigneeMemberIds === undefined) return undefined;
const raw = body.assigneeMemberIds;
if (!Array.isArray(raw)) return [];
return raw.map(String).filter(Boolean);
}

View File

@@ -0,0 +1,33 @@
import multer from 'multer';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import fs from 'fs';
const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR || '../uploads');
const TEAM_DIR = path.join(UPLOAD_DIR, 'team');
if (!fs.existsSync(TEAM_DIR)) {
fs.mkdirSync(TEAM_DIR, { recursive: true });
}
const storage = multer.diskStorage({
destination(_req, _file, cb) {
cb(null, TEAM_DIR);
},
filename(_req, file, cb) {
const ext = path.extname(file.originalname).toLowerCase() || '.jpg';
cb(null, `${uuidv4()}${ext}`);
},
});
export const uploadTeamPhoto = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter(_req, file, cb) {
if (/^image\/(jpeg|jpg|png|gif|webp)$/i.test(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('JPEG, PNG, GIF, WebP 이미지만 업로드할 수 있습니다.'));
}
},
});

View File

@@ -7,11 +7,13 @@ import kpiRoutes from './kpi';
import columnRoutes from './columns';
import milestoneRoutes from './milestones';
import detailRoutes from './details';
import teamMemberRoutes from './teamMembers';
const router = Router();
router.use('/auth', authRoutes);
router.use('/tasks', taskRoutes);
router.use('/team-members', teamMemberRoutes);
router.use('/users', userRoutes);
router.use('/files', fileRoutes);
router.use('/kpi', kpiRoutes);

View File

@@ -2,6 +2,13 @@ import { Router } from 'express';
import { prisma } from '../lib/prisma';
import { resolveCreatorId } from '../lib/resolveUser';
import { AppError } from '../middleware/errorHandler';
import {
formatTask,
parseMemberIds,
syncTaskMembers,
taskDetailInclude,
taskInclude,
} from '../lib/taskQuery';
const router = Router();
@@ -17,15 +24,11 @@ router.get('/', async (req, res, next) => {
...(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 } },
},
include: taskInclude,
orderBy: { updatedAt: 'desc' },
});
res.json(tasks);
res.json(tasks.map((t) => formatTask(t)));
} catch (err) {
next(err);
}
@@ -37,21 +40,11 @@ router.get('/:id', async (req, res, next) => {
const taskId = String(req.params.id);
const task = await prisma.task.findUnique({
where: { id: taskId },
include: {
assignee: { select: { id: true, name: true, department: true } },
creator: { select: { id: true, name: true } },
details: {
orderBy: { createdAt: 'desc' },
include: { author: { select: { id: true, name: true } } },
},
kpiMetrics: true,
files: true,
milestones: { orderBy: { order: 'asc' } },
},
include: taskDetailInclude,
});
if (!task) throw new AppError(404, '업무를 찾을 수 없습니다.');
res.json(task);
res.json(formatTask(task));
} catch (err) {
next(err);
}
@@ -60,16 +53,17 @@ router.get('/:id', async (req, res, next) => {
// POST /api/tasks — 업무 등록
router.post('/', async (req, res, next) => {
try {
const body = req.body as Record<string, any>;
const { title, description, status, priority, quarter, category,
section, tag, taskType, progress, issueNote, startDate, dueDate, assigneeId, showDate,
showDescription, showStatus, showIssue, showProgress, keywords } =
req.body as Record<string, any>;
showDescription, showStatus, showIssue, showProgress, keywords, pmMemberId } = body;
if (!title || !quarter) {
throw new AppError(400, '제목과 분기는 필수입니다.');
}
const creatorId = await resolveCreatorId((req.body as Record<string, string>).creatorId);
const creatorId = await resolveCreatorId(body.creatorId);
const assigneeMemberIds = parseMemberIds(body);
const task = await prisma.task.create({
data: {
@@ -93,11 +87,23 @@ router.post('/', async (req, res, next) => {
showProgress: showProgress !== undefined ? showProgress === 'true' || showProgress === true : true,
keywords: keywords || null,
assigneeId: assigneeId || null,
pmMemberId: pmMemberId || null,
creatorId,
},
include: taskInclude,
});
res.status(201).json(task);
if (assigneeMemberIds !== undefined) {
await syncTaskMembers(task.id, undefined, assigneeMemberIds);
const refreshed = await prisma.task.findUnique({
where: { id: task.id },
include: taskInclude,
});
res.status(201).json(formatTask(refreshed!));
return;
}
res.status(201).json(formatTask(task));
} catch (err) {
next(err);
}
@@ -109,12 +115,14 @@ router.patch('/:id', async (req, res, next) => {
const existing = await prisma.task.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, '업무를 찾을 수 없습니다.');
const body = req.body as Record<string, any>;
const { title, description, status, priority, quarter, category,
section, tag, taskType, progress, issueNote, startDate, dueDate, assigneeId, showDate,
showDescription, showStatus, showIssue, showProgress, keywords } =
req.body as Record<string, any>;
showDescription, showStatus, showIssue, showProgress, keywords, pmMemberId } = body;
const task = await prisma.task.update({
const assigneeMemberIds = parseMemberIds(body);
await prisma.task.update({
where: { id: req.params.id },
data: {
...(title && { title }),
@@ -131,6 +139,7 @@ router.patch('/:id', async (req, res, next) => {
...(startDate !== undefined && { startDate: startDate ? new Date(startDate) : null }),
...(dueDate !== undefined && { dueDate: dueDate ? new Date(dueDate) : null }),
...(assigneeId !== undefined && { assigneeId: assigneeId || null }),
...(pmMemberId !== undefined && { pmMemberId: pmMemberId || null }),
...(showDate !== undefined && { showDate: showDate === true || showDate === 'true' }),
...(showDescription !== undefined && { showDescription: showDescription === true || showDescription === 'true' }),
...(showStatus !== undefined && { showStatus: showStatus === true || showStatus === 'true' }),
@@ -140,7 +149,20 @@ router.patch('/:id', async (req, res, next) => {
},
});
res.json(task);
if (pmMemberId !== undefined || assigneeMemberIds !== undefined) {
await syncTaskMembers(
req.params.id,
pmMemberId !== undefined ? (pmMemberId || null) : undefined,
assigneeMemberIds,
);
}
const task = await prisma.task.findUnique({
where: { id: req.params.id },
include: taskInclude,
});
res.json(formatTask(task!));
} catch (err) {
next(err);
}

View File

@@ -0,0 +1,126 @@
import { Router } from 'express';
import { prisma } from '../lib/prisma';
import { AppError } from '../middleware/errorHandler';
import { uploadTeamPhoto } from '../middleware/uploadTeamPhoto';
const router = Router();
const memberSelect = {
id: true,
name: true,
rank: true,
role: true,
cell: true,
contact: true,
photoUrl: true,
sortOrder: true,
isActive: true,
};
// GET /api/team-members (?all=1 이면 비활성 포함 — 관리 화면용)
router.get('/', async (req, res, next) => {
try {
const includeAll = req.query.all === '1' || req.query.all === 'true';
const members = await prisma.teamMember.findMany({
where: includeAll ? undefined : { isActive: true },
select: memberSelect,
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
});
res.json(members);
} catch (err) {
next(err);
}
});
// POST /api/team-members/photo — 팀원 사진 (로컬 uploads/team/ 저장)
router.post('/photo', uploadTeamPhoto.single('photo'), async (req, res, next) => {
try {
if (!req.file) {
throw new AppError(400, '이미지 파일을 선택해 주세요.');
}
res.status(201).json({
url: `/uploads/team/${req.file.filename}`,
filename: req.file.filename,
});
} catch (err) {
next(err);
}
});
// POST /api/team-members — 인원 등록
router.post('/', async (req, res, next) => {
try {
const { name, rank, role, cell, contact, photoUrl, sortOrder } =
req.body as Record<string, unknown>;
if (!name || typeof name !== 'string' || !name.trim()) {
throw new AppError(400, '이름은 필수입니다.');
}
const member = await prisma.teamMember.create({
data: {
name: name.trim(),
rank: typeof rank === 'string' ? rank : null,
role: typeof role === 'string' ? role : null,
cell: typeof cell === 'string' ? cell : null,
contact: typeof contact === 'string' ? contact : null,
photoUrl: typeof photoUrl === 'string' ? photoUrl : null,
sortOrder: typeof sortOrder === 'number' ? sortOrder : Number(sortOrder) || 0,
},
select: memberSelect,
});
res.status(201).json(member);
} catch (err) {
next(err);
}
});
// PATCH /api/team-members/:id
router.patch('/:id', async (req, res, next) => {
try {
const existing = await prisma.teamMember.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, '팀원을 찾을 수 없습니다.');
const { name, rank, role, cell, contact, photoUrl, sortOrder, isActive } =
req.body as Record<string, unknown>;
const member = await prisma.teamMember.update({
where: { id: req.params.id },
data: {
...(name !== undefined && { name: String(name).trim() }),
...(rank !== undefined && { rank: rank ? String(rank) : null }),
...(role !== undefined && { role: role ? String(role) : null }),
...(cell !== undefined && { cell: cell ? String(cell) : null }),
...(contact !== undefined && { contact: contact ? String(contact) : null }),
...(photoUrl !== undefined && { photoUrl: photoUrl ? String(photoUrl) : null }),
...(sortOrder !== undefined && { sortOrder: Number(sortOrder) || 0 }),
...(isActive !== undefined && { isActive: isActive === true || isActive === 'true' }),
},
select: memberSelect,
});
res.json(member);
} catch (err) {
next(err);
}
});
// DELETE /api/team-members/:id — soft delete
router.delete('/:id', async (req, res, next) => {
try {
const existing = await prisma.teamMember.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, '팀원을 찾을 수 없습니다.');
await prisma.teamMember.update({
where: { id: req.params.id },
data: { isActive: false },
});
res.status(204).send();
} catch (err) {
next(err);
}
});
export default router;