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:
@@ -4,7 +4,7 @@
|
||||
"description": "EENE 인재성장팀 대시보드 - Backend API",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"dev": "npm run db:sync && tsx watch src/index.ts",
|
||||
"db:sync": "prisma migrate deploy || prisma db push",
|
||||
"build": "prisma generate && tsc",
|
||||
"start": "npm run db:sync && node dist/index.js",
|
||||
@@ -12,7 +12,9 @@
|
||||
"db:generate": "prisma generate",
|
||||
"db:studio": "prisma studio",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:import-hr": "tsx scripts/import-hr-data.ts"
|
||||
"db:import-hr": "tsx scripts/import-hr-data.ts",
|
||||
"db:sync-remote": "tsx scripts/sync-from-remote.ts",
|
||||
"db:push-remote": "tsx scripts/sync-to-remote.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.0.0",
|
||||
|
||||
@@ -29,6 +29,29 @@ model User {
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
// ─── 팀 인원 (조직도 마스터) ─────────────────────────────────
|
||||
|
||||
model TeamMember {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
rank String? // 직급 (수석연구원, 선임연구원 등)
|
||||
role String? // 직책 (팀장, 셀장, 팀원 등)
|
||||
cell String? // 셀 (HR, 총무, 리더 — 리더/빈값이면 상단 팀장 영역)
|
||||
contact String?
|
||||
photoUrl String?
|
||||
sortOrder Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
pmTasks Task[] @relation("PmTasks")
|
||||
taskAssignees TaskAssignee[]
|
||||
|
||||
@@index([cell])
|
||||
@@index([isActive])
|
||||
@@map("team_members")
|
||||
}
|
||||
|
||||
enum Role {
|
||||
ADMIN
|
||||
MANAGER
|
||||
@@ -60,11 +83,14 @@ model Task {
|
||||
keywords String?
|
||||
creatorId String
|
||||
assigneeId String?
|
||||
pmMemberId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
creator User @relation("CreatedTasks", fields: [creatorId], references: [id])
|
||||
assignee User? @relation("AssignedTasks", fields: [assigneeId], references: [id])
|
||||
pmMember TeamMember? @relation("PmTasks", fields: [pmMemberId], references: [id])
|
||||
taskAssignees TaskAssignee[]
|
||||
details TaskDetail[]
|
||||
kpiMetrics KpiMetric[]
|
||||
files File[]
|
||||
@@ -73,10 +99,23 @@ model Task {
|
||||
@@index([quarter])
|
||||
@@index([status])
|
||||
@@index([assigneeId])
|
||||
@@index([pmMemberId])
|
||||
@@index([section])
|
||||
@@map("tasks")
|
||||
}
|
||||
|
||||
model TaskAssignee {
|
||||
taskId String
|
||||
memberId String
|
||||
|
||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||
member TeamMember @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([taskId, memberId])
|
||||
@@index([memberId])
|
||||
@@map("task_assignees")
|
||||
}
|
||||
|
||||
enum TaskStatus {
|
||||
TODO
|
||||
IN_PROGRESS
|
||||
|
||||
415
backend/scripts/sync-from-remote.ts
Normal file
415
backend/scripts/sync-from-remote.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* 배포 서버(Render) 데이터 → 로컬 PostgreSQL 복사
|
||||
* 사용: npm run db:sync-remote
|
||||
* 환경변수 SOURCE_API_URL 로 원본 변경 가능
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { PrismaClient, type Priority, type TaskStatus } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const SOURCE = (process.env.SOURCE_API_URL || 'https://eene-dashboard-backend.onrender.com').replace(/\/$/, '');
|
||||
const SECTIONS = ['인사관리', '학습성장', '운영지원', '전산관리'];
|
||||
|
||||
type RemoteUser = { id: string; name: string; department?: string | null };
|
||||
type RemoteTask = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
status: TaskStatus;
|
||||
priority: Priority;
|
||||
quarter: string;
|
||||
category?: string | null;
|
||||
section?: string | null;
|
||||
tag?: string | null;
|
||||
taskType?: string | null;
|
||||
progress: number;
|
||||
issueNote?: string | null;
|
||||
startDate?: string | null;
|
||||
dueDate?: string | null;
|
||||
showDate: boolean;
|
||||
showDescription: boolean;
|
||||
showStatus: boolean;
|
||||
showIssue: boolean;
|
||||
showProgress: boolean;
|
||||
keywords?: string | null;
|
||||
creatorId: string;
|
||||
assigneeId?: string | null;
|
||||
pmMemberId?: string | null;
|
||||
creator?: RemoteUser;
|
||||
assignee?: RemoteUser | null;
|
||||
assigneeMembers?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
rank?: string | null;
|
||||
role?: string | null;
|
||||
cell?: string | null;
|
||||
contact?: string | null;
|
||||
photoUrl?: string | null;
|
||||
sortOrder?: number;
|
||||
}>;
|
||||
pmMember?: {
|
||||
id: string;
|
||||
name: string;
|
||||
rank?: string | null;
|
||||
role?: string | null;
|
||||
cell?: string | null;
|
||||
contact?: string | null;
|
||||
photoUrl?: string | null;
|
||||
sortOrder?: number;
|
||||
} | null;
|
||||
milestones?: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
startDate?: string | null;
|
||||
dueDate?: string | null;
|
||||
progress: number;
|
||||
links?: string | null;
|
||||
completedAt?: string | null;
|
||||
order: number;
|
||||
}>;
|
||||
details?: Array<{
|
||||
id: string;
|
||||
content: string;
|
||||
authorName?: string | null;
|
||||
milestoneId?: string | null;
|
||||
updatedBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
author?: RemoteUser;
|
||||
}>;
|
||||
kpiMetrics?: Array<{
|
||||
id: string;
|
||||
quarter: string;
|
||||
target: number;
|
||||
actual: number;
|
||||
unit?: string | null;
|
||||
}>;
|
||||
files?: Array<{
|
||||
id: string;
|
||||
filename: string;
|
||||
originalName: string;
|
||||
displayName?: string | null;
|
||||
sortOrder: number;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
path: string;
|
||||
milestoneId?: string | null;
|
||||
uploadedBy: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
async function fetchJson<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${SOURCE}${path}`);
|
||||
if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function ensureUser(remote?: RemoteUser | null, fallbackEmail?: string): Promise<string> {
|
||||
const pw = await bcrypt.hash('imported!', 10);
|
||||
const email = fallbackEmail || `${(remote?.name || 'user').replace(/\s/g, '')}@import.local`;
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email },
|
||||
update: { name: remote?.name || email },
|
||||
create: {
|
||||
email,
|
||||
password: pw,
|
||||
name: remote?.name || '사용자',
|
||||
role: 'MEMBER',
|
||||
department: remote?.department || 'EENE',
|
||||
},
|
||||
});
|
||||
return user.id;
|
||||
}
|
||||
|
||||
async function ensureTeamMember(data: {
|
||||
name: string;
|
||||
rank?: string | null;
|
||||
role?: string | null;
|
||||
cell?: string | null;
|
||||
contact?: string | null;
|
||||
photoUrl?: string | null;
|
||||
sortOrder?: number;
|
||||
}): Promise<string> {
|
||||
const existing = await prisma.teamMember.findFirst({
|
||||
where: { name: data.name, cell: data.cell ?? null },
|
||||
});
|
||||
if (existing) {
|
||||
return prisma.teamMember
|
||||
.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
rank: data.rank ?? existing.rank,
|
||||
role: data.role ?? existing.role,
|
||||
contact: data.contact ?? existing.contact,
|
||||
photoUrl: data.photoUrl ?? existing.photoUrl,
|
||||
sortOrder: data.sortOrder ?? existing.sortOrder,
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
.then((m) => m.id);
|
||||
}
|
||||
const created = await prisma.teamMember.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
rank: data.rank ?? null,
|
||||
role: data.role ?? null,
|
||||
cell: data.cell ?? null,
|
||||
contact: data.contact ?? null,
|
||||
photoUrl: data.photoUrl ?? null,
|
||||
sortOrder: data.sortOrder ?? 0,
|
||||
},
|
||||
});
|
||||
return created.id;
|
||||
}
|
||||
|
||||
async function clearLocalData() {
|
||||
await prisma.file.deleteMany({});
|
||||
await prisma.taskDetail.deleteMany({});
|
||||
await prisma.taskAssignee.deleteMany({});
|
||||
await prisma.milestone.deleteMany({});
|
||||
await prisma.kpiMetric.deleteMany({});
|
||||
await prisma.task.deleteMany({});
|
||||
}
|
||||
|
||||
async function syncColumnConfigs() {
|
||||
for (const key of SECTIONS) {
|
||||
try {
|
||||
const config = await fetchJson<{
|
||||
key: string;
|
||||
title: string;
|
||||
titleEn?: string | null;
|
||||
subtitle?: string | null;
|
||||
cardOrder?: string | null;
|
||||
}>(`/api/columns/${encodeURIComponent(key)}`);
|
||||
|
||||
await prisma.columnConfig.upsert({
|
||||
where: { key },
|
||||
create: {
|
||||
key,
|
||||
title: config.title,
|
||||
titleEn: config.titleEn ?? '',
|
||||
subtitle: config.subtitle ?? '',
|
||||
cardOrder: config.cardOrder ?? null,
|
||||
},
|
||||
update: {
|
||||
title: config.title,
|
||||
titleEn: config.titleEn ?? '',
|
||||
subtitle: config.subtitle ?? '',
|
||||
cardOrder: config.cardOrder ?? null,
|
||||
},
|
||||
});
|
||||
console.log(` ✓ column: ${key}`);
|
||||
} catch (err) {
|
||||
console.warn(` ⚠ column skip ${key}:`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncTeamMembersFromTasks(tasks: RemoteTask[]) {
|
||||
const seen = new Set<string>();
|
||||
for (const task of tasks) {
|
||||
const members = [
|
||||
...(task.assigneeMembers ?? []),
|
||||
...(task.pmMember ? [task.pmMember] : []),
|
||||
];
|
||||
for (const m of members) {
|
||||
const key = `${m.name}|${m.cell ?? ''}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
await ensureTeamMember(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`📡 Source: ${SOURCE}`);
|
||||
console.log('📥 Fetching remote tasks...');
|
||||
|
||||
const list = await fetchJson<RemoteTask[]>('/api/tasks');
|
||||
console.log(` Found ${list.length} tasks`);
|
||||
|
||||
const fullTasks: RemoteTask[] = [];
|
||||
for (const item of list) {
|
||||
const full = await fetchJson<RemoteTask>(`/api/tasks/${item.id}`);
|
||||
fullTasks.push(full);
|
||||
}
|
||||
|
||||
console.log('🗑️ Clearing local task data...');
|
||||
await clearLocalData();
|
||||
|
||||
const adminId = await ensureUser({ id: '', name: '관리자' }, 'admin@eene.com');
|
||||
const userMap = new Map<string, string>();
|
||||
userMap.set('admin', adminId);
|
||||
|
||||
console.log('👥 Syncing team members...');
|
||||
try {
|
||||
const remoteMembers = await fetchJson<
|
||||
Array<{
|
||||
name: string;
|
||||
rank?: string | null;
|
||||
role?: string | null;
|
||||
cell?: string | null;
|
||||
contact?: string | null;
|
||||
photoUrl?: string | null;
|
||||
sortOrder?: number;
|
||||
}>
|
||||
>('/api/team-members');
|
||||
for (const m of remoteMembers) await ensureTeamMember(m);
|
||||
console.log(` ${remoteMembers.length} team members from API`);
|
||||
} catch {
|
||||
await syncTeamMembersFromTasks(fullTasks);
|
||||
console.log(' team members inferred from tasks');
|
||||
}
|
||||
|
||||
console.log('📋 Importing tasks...');
|
||||
for (const remote of fullTasks) {
|
||||
let creatorId = userMap.get(remote.creatorId);
|
||||
if (!creatorId) {
|
||||
creatorId = await ensureUser(remote.creator, `creator-${remote.creatorId}@import.local`);
|
||||
userMap.set(remote.creatorId, creatorId);
|
||||
}
|
||||
|
||||
let assigneeId: string | null = null;
|
||||
if (remote.assignee) {
|
||||
let mapped = userMap.get(remote.assignee.id);
|
||||
if (!mapped) {
|
||||
mapped = await ensureUser(remote.assignee, `assignee-${remote.assignee.id}@import.local`);
|
||||
userMap.set(remote.assignee.id, mapped);
|
||||
}
|
||||
assigneeId = mapped;
|
||||
}
|
||||
|
||||
let pmMemberId: string | null = null;
|
||||
if (remote.pmMember) {
|
||||
pmMemberId = await ensureTeamMember(remote.pmMember);
|
||||
}
|
||||
|
||||
const task = await prisma.task.create({
|
||||
data: {
|
||||
title: remote.title,
|
||||
description: remote.description ?? null,
|
||||
status: remote.status,
|
||||
priority: remote.priority,
|
||||
quarter: remote.quarter,
|
||||
category: remote.category ?? null,
|
||||
section: remote.section ?? null,
|
||||
tag: remote.tag ?? null,
|
||||
taskType: remote.taskType ?? null,
|
||||
progress: remote.progress ?? 0,
|
||||
issueNote: remote.issueNote ?? null,
|
||||
startDate: remote.startDate ? new Date(remote.startDate) : null,
|
||||
dueDate: remote.dueDate ? new Date(remote.dueDate) : null,
|
||||
showDate: remote.showDate,
|
||||
showDescription: remote.showDescription,
|
||||
showStatus: remote.showStatus,
|
||||
showIssue: remote.showIssue,
|
||||
showProgress: remote.showProgress,
|
||||
keywords: remote.keywords ?? null,
|
||||
creatorId,
|
||||
assigneeId,
|
||||
pmMemberId,
|
||||
},
|
||||
});
|
||||
|
||||
const milestoneIdMap = new Map<string, string>();
|
||||
for (const ms of remote.milestones ?? []) {
|
||||
const created = await prisma.milestone.create({
|
||||
data: {
|
||||
taskId: task.id,
|
||||
title: ms.title,
|
||||
description: ms.description ?? null,
|
||||
startDate: ms.startDate ? new Date(ms.startDate) : null,
|
||||
dueDate: ms.dueDate ? new Date(ms.dueDate) : null,
|
||||
progress: ms.progress ?? 0,
|
||||
links: ms.links ?? null,
|
||||
completedAt: ms.completedAt ? new Date(ms.completedAt) : null,
|
||||
order: ms.order ?? 0,
|
||||
},
|
||||
});
|
||||
milestoneIdMap.set(ms.id, created.id);
|
||||
}
|
||||
|
||||
for (const d of remote.details ?? []) {
|
||||
let authorId = userMap.get(d.updatedBy);
|
||||
if (!authorId) {
|
||||
authorId = await ensureUser(d.author, `author-${d.updatedBy}@import.local`);
|
||||
userMap.set(d.updatedBy, authorId);
|
||||
}
|
||||
await prisma.taskDetail.create({
|
||||
data: {
|
||||
taskId: task.id,
|
||||
milestoneId: d.milestoneId ? milestoneIdMap.get(d.milestoneId) ?? null : null,
|
||||
content: d.content,
|
||||
authorName: d.authorName ?? null,
|
||||
updatedBy: authorId,
|
||||
createdAt: new Date(d.createdAt),
|
||||
updatedAt: new Date(d.updatedAt),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const k of remote.kpiMetrics ?? []) {
|
||||
await prisma.kpiMetric.create({
|
||||
data: {
|
||||
taskId: task.id,
|
||||
quarter: k.quarter,
|
||||
target: k.target,
|
||||
actual: k.actual,
|
||||
unit: k.unit ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const assigneeMemberIds: string[] = [];
|
||||
for (const m of remote.assigneeMembers ?? []) {
|
||||
assigneeMemberIds.push(await ensureTeamMember(m));
|
||||
}
|
||||
if (assigneeMemberIds.length > 0) {
|
||||
await prisma.taskAssignee.createMany({
|
||||
data: [...new Set(assigneeMemberIds)].map((memberId) => ({ taskId: task.id, memberId })),
|
||||
});
|
||||
}
|
||||
|
||||
for (const f of remote.files ?? []) {
|
||||
let uploaderId = userMap.get(f.uploadedBy);
|
||||
if (!uploaderId) {
|
||||
uploaderId = creatorId;
|
||||
userMap.set(f.uploadedBy, uploaderId);
|
||||
}
|
||||
await prisma.file.create({
|
||||
data: {
|
||||
taskId: task.id,
|
||||
milestoneId: f.milestoneId ? milestoneIdMap.get(f.milestoneId) ?? null : null,
|
||||
filename: f.filename,
|
||||
originalName: f.originalName,
|
||||
displayName: f.displayName ?? null,
|
||||
sortOrder: f.sortOrder ?? 0,
|
||||
mimetype: f.mimetype,
|
||||
size: f.size,
|
||||
path: f.path,
|
||||
uploadedBy: uploaderId,
|
||||
createdAt: new Date(f.createdAt),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` ✓ ${remote.title}`);
|
||||
}
|
||||
|
||||
console.log('📐 Syncing column order...');
|
||||
await syncColumnConfigs();
|
||||
|
||||
const count = await prisma.task.count();
|
||||
console.log(`\n✅ Done! Local DB now has ${count} tasks (from ${SOURCE})`);
|
||||
console.log(' Refresh http://localhost:3000');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error('❌ Sync failed:', err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
245
backend/scripts/sync-to-remote.ts
Normal file
245
backend/scripts/sync-to-remote.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* 로컬 PostgreSQL → 배포 서버(Render API) 데이터 업로드
|
||||
* 사용: npm run db:push-remote
|
||||
* 환경변수 TARGET_API_URL 로 대상 변경 가능
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const TARGET = (process.env.TARGET_API_URL || 'https://eene-dashboard-backend.onrender.com').replace(/\/$/, '');
|
||||
const SECTIONS = ['인사관리', '학습성장', '운영지원', '전산관리'];
|
||||
|
||||
async function api<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(`${TARGET}${path}`, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`${method} ${path} → ${res.status} ${text}`);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function memberKey(name: string, cell: string | null) {
|
||||
return `${name}|${cell ?? ''}`;
|
||||
}
|
||||
|
||||
async function ensureRemoteApiReady() {
|
||||
const res = await fetch(`${TARGET}/api/team-members`);
|
||||
if (res.status === 404) {
|
||||
throw new Error(
|
||||
'배포 서버에 team-members API가 없습니다. 코드 배포(Render) 완료 후 다시 실행하세요.',
|
||||
);
|
||||
}
|
||||
if (!res.ok) throw new Error(`team-members check failed: ${res.status}`);
|
||||
}
|
||||
|
||||
async function syncTeamMembers(): Promise<Map<string, string>> {
|
||||
const locals = await prisma.teamMember.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
||||
});
|
||||
|
||||
type RemoteMember = {
|
||||
id: string;
|
||||
name: string;
|
||||
cell: string | null;
|
||||
rank?: string | null;
|
||||
role?: string | null;
|
||||
contact?: string | null;
|
||||
photoUrl?: string | null;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
let remotes: RemoteMember[] = [];
|
||||
try {
|
||||
remotes = await api<RemoteMember[]>('GET', '/api/team-members?all=1');
|
||||
} catch {
|
||||
remotes = await api<RemoteMember[]>('GET', '/api/team-members');
|
||||
}
|
||||
|
||||
const remoteByKey = new Map(remotes.map((m) => [memberKey(m.name, m.cell), m]));
|
||||
const idMap = new Map<string, string>();
|
||||
|
||||
for (const local of locals) {
|
||||
const key = memberKey(local.name, local.cell);
|
||||
const payload = {
|
||||
name: local.name,
|
||||
rank: local.rank,
|
||||
role: local.role,
|
||||
cell: local.cell,
|
||||
contact: local.contact,
|
||||
photoUrl: local.photoUrl,
|
||||
sortOrder: local.sortOrder,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const existing = remoteByKey.get(key);
|
||||
if (existing) {
|
||||
await api('PATCH', `/api/team-members/${existing.id}`, payload);
|
||||
idMap.set(local.id, existing.id);
|
||||
console.log(` ✓ team ${local.name} (updated)`);
|
||||
} else {
|
||||
const created = await api<RemoteMember>('POST', '/api/team-members', payload);
|
||||
idMap.set(local.id, created.id);
|
||||
remoteByKey.set(key, created);
|
||||
console.log(` ✓ team ${local.name} (created)`);
|
||||
}
|
||||
}
|
||||
|
||||
return idMap;
|
||||
}
|
||||
|
||||
async function clearRemoteTasks() {
|
||||
const remoteTasks = await api<Array<{ id: string }>>('GET', '/api/tasks');
|
||||
for (const t of remoteTasks) {
|
||||
await api('DELETE', `/api/tasks/${t.id}`);
|
||||
}
|
||||
console.log(` removed ${remoteTasks.length} remote tasks`);
|
||||
}
|
||||
|
||||
async function syncTasks(memberIdMap: Map<string, string>) {
|
||||
const tasks = await prisma.task.findMany({
|
||||
include: {
|
||||
milestones: { orderBy: { order: 'asc' } },
|
||||
details: { orderBy: { createdAt: 'asc' } },
|
||||
kpiMetrics: true,
|
||||
taskAssignees: true,
|
||||
pmMember: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
for (const task of tasks) {
|
||||
const assigneeMemberIds = task.taskAssignees
|
||||
.map((ta) => memberIdMap.get(ta.memberId))
|
||||
.filter((id): id is string => Boolean(id));
|
||||
|
||||
const pmMemberId = task.pmMemberId ? memberIdMap.get(task.pmMemberId) ?? null : null;
|
||||
|
||||
const created = await api<{ id: string }>('POST', '/api/tasks', {
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
status: task.status,
|
||||
priority: task.priority,
|
||||
quarter: task.quarter,
|
||||
category: task.category,
|
||||
section: task.section,
|
||||
tag: task.tag,
|
||||
taskType: task.taskType,
|
||||
progress: task.progress,
|
||||
issueNote: task.issueNote,
|
||||
startDate: task.startDate?.toISOString() ?? null,
|
||||
dueDate: task.dueDate?.toISOString() ?? null,
|
||||
showDate: task.showDate,
|
||||
showDescription: task.showDescription,
|
||||
showStatus: task.showStatus,
|
||||
showIssue: task.showIssue,
|
||||
showProgress: task.showProgress,
|
||||
keywords: task.keywords,
|
||||
pmMemberId,
|
||||
assigneeMemberIds,
|
||||
});
|
||||
|
||||
const milestoneIdMap = new Map<string, string>();
|
||||
for (const ms of task.milestones) {
|
||||
const remoteMs = await api<{ id: string }>('POST', `/api/milestones/${created.id}`, {
|
||||
title: ms.title,
|
||||
description: ms.description,
|
||||
startDate: ms.startDate?.toISOString() ?? null,
|
||||
dueDate: ms.dueDate?.toISOString() ?? null,
|
||||
progress: ms.progress,
|
||||
links: ms.links,
|
||||
});
|
||||
milestoneIdMap.set(ms.id, remoteMs.id);
|
||||
}
|
||||
|
||||
for (const d of task.details) {
|
||||
await api('POST', `/api/details/${created.id}`, {
|
||||
content: d.content,
|
||||
authorName: d.authorName,
|
||||
milestoneId: d.milestoneId ? milestoneIdMap.get(d.milestoneId) ?? null : null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const k of task.kpiMetrics) {
|
||||
await api('POST', '/api/kpi', {
|
||||
taskId: created.id,
|
||||
quarter: k.quarter,
|
||||
target: k.target,
|
||||
actual: k.actual,
|
||||
unit: k.unit,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` ✓ task ${task.title}`);
|
||||
}
|
||||
|
||||
return tasks.length;
|
||||
}
|
||||
|
||||
async function syncColumnConfigs() {
|
||||
const configs = await prisma.columnConfig.findMany();
|
||||
for (const config of configs) {
|
||||
await api('PATCH', `/api/columns/${encodeURIComponent(config.key)}`, {
|
||||
title: config.title,
|
||||
titleEn: config.titleEn,
|
||||
subtitle: config.subtitle,
|
||||
cardOrder: config.cardOrder,
|
||||
});
|
||||
console.log(` ✓ column ${config.key}`);
|
||||
}
|
||||
|
||||
for (const key of SECTIONS) {
|
||||
if (!configs.some((c) => c.key === key)) {
|
||||
const local = await prisma.columnConfig.findUnique({ where: { key } });
|
||||
if (local) continue;
|
||||
try {
|
||||
await api('GET', `/api/columns/${encodeURIComponent(key)}`);
|
||||
} catch {
|
||||
/* ensure exists on remote */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`📤 Target: ${TARGET}`);
|
||||
console.log('🔍 Checking remote API...');
|
||||
await ensureRemoteApiReady();
|
||||
|
||||
const localTasks = await prisma.task.count();
|
||||
const localMembers = await prisma.teamMember.count({ where: { isActive: true } });
|
||||
console.log(` Local: ${localTasks} tasks, ${localMembers} team members`);
|
||||
|
||||
if (localTasks === 0) {
|
||||
throw new Error('로컬 DB에 업무가 없습니다. 먼저 데이터가져오기.bat 또는 작업을 진행하세요.');
|
||||
}
|
||||
|
||||
console.log('👥 Uploading team members...');
|
||||
const memberIdMap = await syncTeamMembers();
|
||||
|
||||
console.log('🗑️ Clearing remote tasks...');
|
||||
await clearRemoteTasks();
|
||||
|
||||
console.log('📋 Uploading tasks...');
|
||||
const count = await syncTasks(memberIdMap);
|
||||
|
||||
console.log('📐 Uploading column order...');
|
||||
await syncColumnConfigs();
|
||||
|
||||
const remoteTasks = await api<unknown[]>('GET', '/api/tasks');
|
||||
console.log(`\n✅ Done! Remote now has ${remoteTasks.length} tasks`);
|
||||
console.log(` Site: https://eene-dashboard.vercel.app/`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error('❌ Push failed:', err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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})`);
|
||||
|
||||
22
backend/src/lib/ensureLocalDirs.ts
Normal file
22
backend/src/lib/ensureLocalDirs.ts
Normal 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가 보존됩니다.',
|
||||
);
|
||||
}
|
||||
}
|
||||
76
backend/src/lib/taskQuery.ts
Normal file
76
backend/src/lib/taskQuery.ts
Normal 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);
|
||||
}
|
||||
33
backend/src/middleware/uploadTeamPhoto.ts
Normal file
33
backend/src/middleware/uploadTeamPhoto.ts
Normal 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 이미지만 업로드할 수 있습니다.'));
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
126
backend/src/routes/teamMembers.ts
Normal file
126
backend/src/routes/teamMembers.ts
Normal 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;
|
||||
Reference in New Issue
Block a user