EENE Dashboard upload to Gitea
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,8 +17,11 @@ app.use(
|
||||
const allowedOrigins = [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:5173',
|
||||
'https://localhost:3000',
|
||||
'http://127.0.0.1:3000',
|
||||
'https://127.0.0.1:3000',
|
||||
'http://172.16.8.248:3000',
|
||||
'https://172.16.8.248:3000',
|
||||
'https://eene-dashboard.vercel.app',
|
||||
process.env.FRONTEND_URL,
|
||||
].filter(Boolean) as string[];
|
||||
@@ -26,11 +29,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;
|
||||
// 로컬·사설망 프론트 (http/https, LAN IP)
|
||||
if (/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin)) return true;
|
||||
if (/^https?:\/\/172\.(1[6-9]|2\d|3[01])\.\d+\.\d+(:\d+)?$/.test(origin)) return true;
|
||||
if (/^https?:\/\/192\.168\.\d+\.\d+(:\d+)?$/.test(origin)) return true;
|
||||
if (/^https?:\/\/10\.\d+\.\d+\.\d+(:\d+)?$/.test(origin)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import app from './app';
|
||||
import { setupSocketHandlers } from './socket';
|
||||
import { prisma } from './lib/prisma';
|
||||
import { ensureLocalDirs } from './lib/ensureLocalDirs';
|
||||
import { getProjectRoot, getHrSeedPath, getUploadDir } from './lib/projectPaths';
|
||||
|
||||
const PORT = Number(process.env.PORT) || 4000;
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
|
||||
@@ -19,11 +20,16 @@ const io = new Server(httpServer, {
|
||||
});
|
||||
|
||||
setupSocketHandlers(io);
|
||||
app.set('io', io);
|
||||
|
||||
async function main() {
|
||||
ensureLocalDirs();
|
||||
await prisma.$connect();
|
||||
console.log('✅ Database connected (PostgreSQL — 로컬 data/postgres 또는 DATABASE_URL)');
|
||||
console.log('✅ Database connected — local PostgreSQL (data/postgres)');
|
||||
|
||||
console.log(`📂 Project root: ${getProjectRoot()}`);
|
||||
console.log(`📂 HR seed: ${getHrSeedPath()}`);
|
||||
console.log(`📂 Uploads: ${getUploadDir()}`);
|
||||
|
||||
httpServer.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`✅ Server running on http://0.0.0.0:${PORT} (팀원: http://<이PC의IP>:${PORT})`);
|
||||
|
||||
@@ -1,22 +1,6 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { ensureProjectDataDirs } from './projectPaths';
|
||||
|
||||
/** 로컬 uploads·팀 사진 폴더 생성 (데이터 영구 저장) */
|
||||
/** 로컬 data·uploads 폴더 생성 (DB·파일 영구 저장) */
|
||||
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가 보존됩니다.',
|
||||
);
|
||||
}
|
||||
ensureProjectDataDirs();
|
||||
}
|
||||
|
||||
60
backend/src/lib/fileMime.ts
Normal file
60
backend/src/lib/fileMime.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
const EXT_MIME: Record<string, string> = {
|
||||
pdf: 'application/pdf',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
bmp: 'image/bmp',
|
||||
svg: 'image/svg+xml',
|
||||
mp4: 'video/mp4',
|
||||
mov: 'video/quicktime',
|
||||
avi: 'video/x-msvideo',
|
||||
webm: 'video/webm',
|
||||
mkv: 'video/x-matroska',
|
||||
m4v: 'video/x-m4v',
|
||||
wmv: 'video/x-ms-wmv',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
csv: 'text/csv',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
doc: 'application/msword',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
hwp: 'application/x-hwp',
|
||||
hwpx: 'application/hwp+zip',
|
||||
txt: 'text/plain',
|
||||
};
|
||||
|
||||
/** multer가 application/octet-stream 으로 저장할 때 확장자로 보정 */
|
||||
export function resolveFileMime(originalName: string, stored?: string | null): string {
|
||||
const ext = originalName.split('.').pop()?.toLowerCase() ?? '';
|
||||
const fromExt = ext ? EXT_MIME[ext] : undefined;
|
||||
|
||||
if (!stored || stored === 'application/octet-stream' || stored === 'application/x-msdownload') {
|
||||
return fromExt ?? stored ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
if (fromExt && stored.startsWith('application/') && stored.includes('octet')) {
|
||||
return fromExt;
|
||||
}
|
||||
|
||||
return stored;
|
||||
}
|
||||
|
||||
export function isInlinePreviewMime(mime: string): boolean {
|
||||
return (
|
||||
mime.startsWith('image/') ||
|
||||
mime.startsWith('video/') ||
|
||||
mime.startsWith('text/') ||
|
||||
mime === 'application/pdf' ||
|
||||
mime.includes('spreadsheet') ||
|
||||
mime.includes('excel') ||
|
||||
mime.includes('wordprocessingml') ||
|
||||
mime.includes('presentationml') ||
|
||||
mime === 'application/msword' ||
|
||||
mime === 'application/vnd.ms-powerpoint' ||
|
||||
mime === 'application/x-hwp' ||
|
||||
mime === 'application/hwp+zip'
|
||||
);
|
||||
}
|
||||
140
backend/src/lib/milestonePeriods.ts
Normal file
140
backend/src/lib/milestonePeriods.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
export interface MilestonePeriodEntry {
|
||||
id: string;
|
||||
startDate?: string | null;
|
||||
dueDate?: string | null;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
function parseDay(iso: string): Date {
|
||||
const d = new Date(iso);
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
}
|
||||
|
||||
export function normalizePeriodEntries(raw: unknown): MilestonePeriodEntry[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const entries: MilestonePeriodEntry[] = [];
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const row = item as Record<string, unknown>;
|
||||
const startDate = typeof row.startDate === 'string' ? row.startDate.trim() || null : null;
|
||||
const dueDate = typeof row.dueDate === 'string' ? row.dueDate.trim() || null : null;
|
||||
const note = typeof row.note === 'string' ? row.note.trim() || null : null;
|
||||
if (!startDate && !dueDate && !note) continue;
|
||||
entries.push({
|
||||
id: typeof row.id === 'string' && row.id ? row.id : `period-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
startDate,
|
||||
dueDate,
|
||||
note,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function deriveMilestoneDatesFromPeriods(entries: MilestonePeriodEntry[]): {
|
||||
startDate: Date | null;
|
||||
dueDate: Date | null;
|
||||
} {
|
||||
const times: number[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.startDate) times.push(parseDay(entry.startDate).getTime());
|
||||
if (entry.dueDate) times.push(parseDay(entry.dueDate).getTime());
|
||||
}
|
||||
if (times.length === 0) return { startDate: null, dueDate: null };
|
||||
return {
|
||||
startDate: new Date(Math.min(...times)),
|
||||
dueDate: new Date(Math.max(...times)),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMilestonePeriodPayload(body: Record<string, unknown>): {
|
||||
periodEntries: MilestonePeriodEntry[] | undefined;
|
||||
startDate: Date | null | undefined;
|
||||
dueDate: Date | null | undefined;
|
||||
} {
|
||||
if (body.periodEntries !== undefined) {
|
||||
const periodEntries = normalizePeriodEntries(body.periodEntries);
|
||||
const { startDate, dueDate } = deriveMilestoneDatesFromPeriods(periodEntries);
|
||||
return {
|
||||
periodEntries,
|
||||
startDate,
|
||||
dueDate,
|
||||
};
|
||||
}
|
||||
|
||||
const hasLegacyStart = body.startDate !== undefined;
|
||||
const hasLegacyDue = body.dueDate !== undefined;
|
||||
if (!hasLegacyStart && !hasLegacyDue) {
|
||||
return { periodEntries: undefined, startDate: undefined, dueDate: undefined };
|
||||
}
|
||||
|
||||
const startDate = body.startDate ? new Date(String(body.startDate)) : null;
|
||||
const dueDate = body.dueDate ? new Date(String(body.dueDate)) : null;
|
||||
const periodEntries =
|
||||
startDate || dueDate
|
||||
? normalizePeriodEntries([
|
||||
{
|
||||
id: `period-legacy-${Date.now()}`,
|
||||
startDate: startDate ? startDate.toISOString().slice(0, 10) : null,
|
||||
dueDate: dueDate ? dueDate.toISOString().slice(0, 10) : null,
|
||||
note: null,
|
||||
},
|
||||
])
|
||||
: [];
|
||||
|
||||
return { periodEntries, startDate, dueDate };
|
||||
}
|
||||
|
||||
function toDateInput(d: Date | null | undefined): string | null {
|
||||
return d ? d.toISOString().slice(0, 10) : null;
|
||||
}
|
||||
|
||||
/** 레거시 description(@overview: 포함) → 기간1 note 본문 */
|
||||
export function legacyDescriptionNote(description: string | null | undefined): string {
|
||||
if (!description?.trim()) return '';
|
||||
if (description.startsWith('@overview:')) {
|
||||
const rest = description.slice('@overview:'.length);
|
||||
const nl = rest.indexOf('\n');
|
||||
if (nl === -1) return rest.trim();
|
||||
const body = rest.slice(nl + 1).trim();
|
||||
if (body) return body;
|
||||
return rest.slice(0, nl).trim();
|
||||
}
|
||||
return description.trim();
|
||||
}
|
||||
|
||||
/** 공통업무내용(description)을 periodEntries[0].note로 이관 */
|
||||
export function migrateDescriptionToPeriodEntries(input: {
|
||||
id?: string;
|
||||
periodEntries: unknown;
|
||||
startDate: Date | null;
|
||||
dueDate: Date | null;
|
||||
description: string | null;
|
||||
}): { periodEntries: MilestonePeriodEntry[] | null; migrated: boolean } {
|
||||
const legacyNote = legacyDescriptionNote(input.description);
|
||||
if (!legacyNote) {
|
||||
return { periodEntries: normalizePeriodEntries(input.periodEntries), migrated: false };
|
||||
}
|
||||
|
||||
const existing = normalizePeriodEntries(input.periodEntries);
|
||||
if (existing.length > 0) {
|
||||
if (existing.some((e) => e.note?.trim())) {
|
||||
return { periodEntries: existing, migrated: false };
|
||||
}
|
||||
const updated = [...existing];
|
||||
updated[0] = { ...updated[0], note: legacyNote };
|
||||
return { periodEntries: updated, migrated: true };
|
||||
}
|
||||
|
||||
const id = input.id ? `period-legacy-${input.id}` : `period-legacy-${Date.now()}`;
|
||||
return {
|
||||
periodEntries: [
|
||||
{
|
||||
id,
|
||||
startDate: toDateInput(input.startDate),
|
||||
dueDate: toDateInput(input.dueDate),
|
||||
note: legacyNote,
|
||||
},
|
||||
],
|
||||
migrated: true,
|
||||
};
|
||||
}
|
||||
65
backend/src/lib/projectPaths.ts
Normal file
65
backend/src/lib/projectPaths.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/** backend/ 디렉터리 (package.json 기준) */
|
||||
export function getBackendRoot(): string {
|
||||
return path.resolve(__dirname, '../..');
|
||||
}
|
||||
|
||||
/** EENE_Dashboard_0608 루트 */
|
||||
export function getProjectRoot(): string {
|
||||
return path.resolve(getBackendRoot(), '..');
|
||||
}
|
||||
|
||||
export function getDataDir(): string {
|
||||
return path.join(getProjectRoot(), 'data');
|
||||
}
|
||||
|
||||
export function getPostgresDataDir(): string {
|
||||
return path.join(getDataDir(), 'postgres');
|
||||
}
|
||||
|
||||
export function getSeedDir(): string {
|
||||
return path.join(getDataDir(), 'seed');
|
||||
}
|
||||
|
||||
/** HR 원본 JSON — seed·import 공통 (프로젝트 내부 data/seed/) */
|
||||
export function getHrSeedPath(): string {
|
||||
const configured = process.env.HR_DATA_PATH?.trim();
|
||||
if (configured) {
|
||||
return path.isAbsolute(configured)
|
||||
? configured
|
||||
: path.resolve(getBackendRoot(), configured);
|
||||
}
|
||||
return path.join(getSeedDir(), 'hr-data.json');
|
||||
}
|
||||
|
||||
export function getUploadDir(): string {
|
||||
const configured = process.env.UPLOAD_DIR?.trim();
|
||||
if (configured) {
|
||||
return path.isAbsolute(configured)
|
||||
? configured
|
||||
: path.resolve(getBackendRoot(), configured);
|
||||
}
|
||||
return path.join(getProjectRoot(), 'uploads');
|
||||
}
|
||||
|
||||
export function getTeamUploadDir(): string {
|
||||
return path.join(getUploadDir(), 'team');
|
||||
}
|
||||
|
||||
/** 서버 기동 시 data·uploads 등 로컬 영구 저장 폴더 생성 */
|
||||
export function ensureProjectDataDirs(): void {
|
||||
for (const dir of [
|
||||
getDataDir(),
|
||||
getPostgresDataDir(),
|
||||
getSeedDir(),
|
||||
getUploadDir(),
|
||||
getTeamUploadDir(),
|
||||
]) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
console.log(`📁 Created: ${dir}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
47
backend/src/lib/taskIssues.ts
Normal file
47
backend/src/lib/taskIssues.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export interface TaskIssueEntry {
|
||||
id: string;
|
||||
text: string;
|
||||
showOnCard: boolean;
|
||||
}
|
||||
|
||||
function newIssueId() {
|
||||
return `issue-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
export function normalizeIssueEntries(raw: unknown): TaskIssueEntry[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const entries: TaskIssueEntry[] = [];
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const row = item as Record<string, unknown>;
|
||||
const text = typeof row.text === 'string' ? row.text.trim() : '';
|
||||
if (!text) continue;
|
||||
entries.push({
|
||||
id: typeof row.id === 'string' && row.id ? row.id : newIssueId(),
|
||||
text,
|
||||
showOnCard: row.showOnCard !== false,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function parseIssueEntriesFromTask(task: {
|
||||
issueEntries?: unknown;
|
||||
issueNote?: string | null;
|
||||
showIssue?: boolean;
|
||||
}): TaskIssueEntry[] {
|
||||
const fromJson = normalizeIssueEntries(task.issueEntries);
|
||||
if (fromJson.length > 0) return fromJson;
|
||||
const legacy = task.issueNote?.trim();
|
||||
if (!legacy) return [];
|
||||
return [{ id: 'legacy', text: legacy, showOnCard: task.showIssue !== false }];
|
||||
}
|
||||
|
||||
export function deriveIssueFields(entries: TaskIssueEntry[]) {
|
||||
const visible = entries.filter((entry) => entry.showOnCard && entry.text.trim());
|
||||
return {
|
||||
issueEntries: entries,
|
||||
issueNote: visible.length > 0 ? visible[visible.length - 1].text : null,
|
||||
showIssue: visible.length > 0,
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,13 @@ export const teamMemberSelect = {
|
||||
sortOrder: true,
|
||||
} as const;
|
||||
|
||||
export const milestoneInclude = {
|
||||
pmMember: { select: teamMemberSelect },
|
||||
milestoneAssignees: {
|
||||
include: { member: { select: teamMemberSelect } },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const taskInclude = {
|
||||
assignee: { select: { id: true, name: true, department: true } },
|
||||
creator: { select: { id: true, name: true } },
|
||||
@@ -34,15 +41,32 @@ export const taskDetailInclude = {
|
||||
},
|
||||
kpiMetrics: true,
|
||||
files: true,
|
||||
milestones: { orderBy: { order: 'asc' as const } },
|
||||
milestones: {
|
||||
orderBy: { order: 'asc' as const },
|
||||
include: milestoneInclude,
|
||||
},
|
||||
};
|
||||
|
||||
export function formatMilestone<T extends Record<string, unknown>>(milestone: T) {
|
||||
const { milestoneAssignees, ...rest } = milestone as T & {
|
||||
milestoneAssignees?: Array<{ member: unknown }>;
|
||||
};
|
||||
const assigneeMembers = (milestoneAssignees ?? []).map((ma) => ma.member);
|
||||
return { ...rest, assigneeMembers };
|
||||
}
|
||||
|
||||
export function formatTask<T extends Record<string, unknown>>(task: T) {
|
||||
const { taskAssignees, ...rest } = task as T & {
|
||||
const { taskAssignees, milestones, ...rest } = task as T & {
|
||||
taskAssignees?: Array<{ member: unknown }>;
|
||||
milestones?: Array<Record<string, unknown>>;
|
||||
};
|
||||
const assigneeMembers = (taskAssignees ?? []).map((ta) => ta.member);
|
||||
return { ...rest, assigneeMembers };
|
||||
const formattedMilestones = milestones?.map((m) => formatMilestone(m));
|
||||
return {
|
||||
...rest,
|
||||
assigneeMembers,
|
||||
...(formattedMilestones !== undefined ? { milestones: formattedMilestones } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function syncTaskMembers(
|
||||
|
||||
23
backend/src/lib/uploadErrors.ts
Normal file
23
backend/src/lib/uploadErrors.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import fs from 'fs';
|
||||
import type { Express } from 'express';
|
||||
|
||||
export const DISK_FULL_MESSAGE =
|
||||
'저장 공간이 부족하여 파일을 업로드하지 못했습니다. 불필요한 파일을 삭제한 후 다시 시도해 주세요.';
|
||||
|
||||
export function isDiskFullError(err: unknown): boolean {
|
||||
if (!err || typeof err !== 'object') return false;
|
||||
const e = err as NodeJS.ErrnoException & { cause?: unknown };
|
||||
if (e.code === 'ENOSPC' || e.code === 'EDQUOT') return true;
|
||||
if (e.cause !== undefined) return isDiskFullError(e.cause);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** multer 업로드 실패 시 디스크에 남은 임시 파일 제거 */
|
||||
export function cleanupUploadedFile(file?: Express.Multer.File | null) {
|
||||
if (!file?.path) return;
|
||||
try {
|
||||
if (fs.existsSync(file.path)) fs.unlinkSync(file.path);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,49 @@
|
||||
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);
|
||||
|
||||
const prismaCode = (err as { code?: string }).code;
|
||||
if (prismaCode === 'P2022') {
|
||||
res.status(500).json({ message: 'DB 스키마가 최신이 아닙니다. 배포 후 다시 시도해 주세요.' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({ message: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
import { DISK_FULL_MESSAGE, isDiskFullError } from '../lib/uploadErrors';
|
||||
|
||||
|
||||
|
||||
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;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (isDiskFullError(err)) {
|
||||
|
||||
@@ -3,7 +3,6 @@ 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)) {
|
||||
@@ -20,7 +19,5 @@ const storage = multer.diskStorage({
|
||||
},
|
||||
});
|
||||
|
||||
export const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: MAX_SIZE_MB * 1024 * 1024 },
|
||||
});
|
||||
/** 파일 크기 상한 없음 — 디스크 여유만큼 저장 (부족 시 uploadErrors) */
|
||||
export const upload = multer({ storage });
|
||||
|
||||
@@ -22,7 +22,6 @@ const storage = multer.diskStorage({
|
||||
|
||||
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);
|
||||
|
||||
@@ -10,8 +10,8 @@ const DEFAULTS: Record<string, { title: string; titleEn: string; subtitle: strin
|
||||
subtitle: '임직원의 몰입(Engagement)과 성장(Education)',
|
||||
},
|
||||
'운영관리': {
|
||||
title: '운영관리 부문',
|
||||
titleEn: 'Operations',
|
||||
title: '총무관리',
|
||||
titleEn: 'GA',
|
||||
subtitle: '인프라 고도화와 자산 라이프사이클 표준화',
|
||||
},
|
||||
};
|
||||
@@ -27,6 +27,18 @@ router.get('/:key', async (req, res, next) => {
|
||||
config = await prisma.columnConfig.create({
|
||||
data: { key, title: def?.title ?? key, titleEn: def?.titleEn ?? '', subtitle: def?.subtitle ?? '' },
|
||||
});
|
||||
} else if (key === '운영관리') {
|
||||
const legacyTitles = ['운영관리', '운영관리 부문'];
|
||||
const legacyTitleEn = ['Operations'];
|
||||
if (legacyTitles.includes(config.title) || legacyTitleEn.includes(config.titleEn)) {
|
||||
config = await prisma.columnConfig.update({
|
||||
where: { key },
|
||||
data: {
|
||||
title: DEFAULTS[key]?.title ?? config.title,
|
||||
titleEn: DEFAULTS[key]?.titleEn ?? config.titleEn,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json(config);
|
||||
|
||||
@@ -1,20 +1,43 @@
|
||||
import { Router, type Response } from 'express';
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { resolveTaskActorId } from '../lib/resolveUser';
|
||||
import { upload } from '../middleware/upload';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
import { cleanupUploadedFile, DISK_FULL_MESSAGE, isDiskFullError } from '../lib/uploadErrors';
|
||||
import { toHtml } from '@ohah/hwpjs';
|
||||
import { resolveFileMime } from '../lib/fileMime';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/** Vercel 상세 창에서 PDF 등 iframe 미리보기 허용 */
|
||||
function allowCrossOriginPreview(res: Response) {
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
res.setHeader(
|
||||
'Content-Security-Policy',
|
||||
"frame-ancestors 'self' https://eene-dashboard.vercel.app https://*.vercel.app http://localhost:3000",
|
||||
const PREVIEW_FRAME_ANCESTORS = [
|
||||
"'self'",
|
||||
'https://eene-dashboard.vercel.app',
|
||||
'http://localhost:3000',
|
||||
'https://localhost:3000',
|
||||
'http://127.0.0.1:3000',
|
||||
'https://127.0.0.1:3000',
|
||||
];
|
||||
|
||||
function isPrivateDevOrigin(origin: string): boolean {
|
||||
return (
|
||||
/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin) ||
|
||||
/^https?:\/\/172\.(1[6-9]|2\d|3[01])\.\d+\.\d+(:\d+)?$/.test(origin) ||
|
||||
/^https?:\/\/192\.168\.\d+\.\d+(:\d+)?$/.test(origin) ||
|
||||
/^https?:\/\/10\.\d+\.\d+\.\d+(:\d+)?$/.test(origin)
|
||||
);
|
||||
}
|
||||
|
||||
/** PDF 등 iframe 미리보기 — localhost·LAN https 포함 */
|
||||
function allowCrossOriginPreview(req: Request, res: Response) {
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
const ancestors = [...PREVIEW_FRAME_ANCESTORS];
|
||||
const origin = req.headers.origin;
|
||||
if (origin && isPrivateDevOrigin(origin) && !ancestors.includes(origin)) {
|
||||
ancestors.push(origin);
|
||||
}
|
||||
res.setHeader('Content-Security-Policy', `frame-ancestors ${ancestors.join(' ')}`);
|
||||
res.removeHeader('X-Frame-Options');
|
||||
}
|
||||
|
||||
@@ -59,7 +82,7 @@ router.post('/upload/:taskId', upload.single('file'), async (req, res, next) =>
|
||||
originalName: fixOriginalName(req.file.originalname),
|
||||
displayName,
|
||||
sortOrder: Number.isNaN(sortOrder) ? 0 : sortOrder,
|
||||
mimetype: req.file.mimetype,
|
||||
mimetype: resolveFileMime(fixOriginalName(req.file.originalname), req.file.mimetype),
|
||||
size: req.file.size,
|
||||
path: req.file.path,
|
||||
uploadedBy,
|
||||
@@ -68,10 +91,47 @@ router.post('/upload/:taskId', upload.single('file'), async (req, res, next) =>
|
||||
|
||||
res.status(201).json(fileRecord);
|
||||
} catch (err) {
|
||||
if (isDiskFullError(err)) {
|
||||
cleanupUploadedFile(req.file);
|
||||
next(new AppError(507, DISK_FULL_MESSAGE));
|
||||
return;
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
function isHwpOriginalName(originalName: string): boolean {
|
||||
const ext = originalName.split('.').pop()?.toLowerCase() ?? '';
|
||||
return ext === 'hwp' || ext === 'hwpx';
|
||||
}
|
||||
|
||||
// GET /api/files/:id/hwp-preview — 한글(.hwp/.hwpx) HTML 미리보기
|
||||
router.get('/:id/hwp-preview', async (req, res, next) => {
|
||||
try {
|
||||
const fileId = String(req.params.id);
|
||||
const file = await prisma.file.findUnique({ where: { id: fileId } });
|
||||
if (!file) throw new AppError(404, '파일을 찾을 수 없습니다.');
|
||||
if (!fs.existsSync(file.path)) throw new AppError(404, '파일이 서버에 없습니다.');
|
||||
if (!isHwpOriginalName(file.originalName)) {
|
||||
throw new AppError(400, '한글 파일만 미리보기할 수 있습니다.');
|
||||
}
|
||||
|
||||
const data = fs.readFileSync(file.path);
|
||||
const html = toHtml(data, {
|
||||
includeVersion: false,
|
||||
includePageInfo: false,
|
||||
});
|
||||
|
||||
res.json({ html });
|
||||
} catch (err) {
|
||||
if (err instanceof AppError) {
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
next(new AppError(422, '한글 파일 미리보기 변환에 실패했습니다.'));
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/files/:id/view — 파일 미리보기 (브라우저에서 바로 열기)
|
||||
router.get('/:id/view', async (req, res, next) => {
|
||||
try {
|
||||
@@ -80,8 +140,9 @@ router.get('/:id/view', async (req, res, next) => {
|
||||
if (!file) throw new AppError(404, '파일을 찾을 수 없습니다.');
|
||||
if (!fs.existsSync(file.path)) throw new AppError(404, '파일이 서버에 없습니다.');
|
||||
|
||||
allowCrossOriginPreview(res);
|
||||
res.setHeader('Content-Type', file.mimetype);
|
||||
allowCrossOriginPreview(req, res);
|
||||
const mime = resolveFileMime(file.originalName, file.mimetype);
|
||||
res.setHeader('Content-Type', mime);
|
||||
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(file.originalName)}"`);
|
||||
fs.createReadStream(file.path).pipe(res);
|
||||
} catch (err) {
|
||||
@@ -122,7 +183,7 @@ router.post('/:id/replace', upload.single('file'), async (req, res, next) => {
|
||||
data: {
|
||||
filename: req.file.filename,
|
||||
originalName: fixOriginalName(req.file.originalname),
|
||||
mimetype: req.file.mimetype,
|
||||
mimetype: resolveFileMime(fixOriginalName(req.file.originalname), req.file.mimetype),
|
||||
size: req.file.size,
|
||||
path: req.file.path,
|
||||
},
|
||||
@@ -130,6 +191,11 @@ router.post('/:id/replace', upload.single('file'), async (req, res, next) => {
|
||||
|
||||
res.json(file);
|
||||
} catch (err) {
|
||||
if (isDiskFullError(err)) {
|
||||
cleanupUploadedFile(req.file);
|
||||
next(new AppError(507, DISK_FULL_MESSAGE));
|
||||
return;
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
74
backend/src/routes/hubConfig.ts
Normal file
74
backend/src/routes/hubConfig.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Router } from 'express';
|
||||
import { prisma } from '../lib/prisma';
|
||||
|
||||
const router = Router();
|
||||
const HUB_ID = 'default';
|
||||
|
||||
export const DEFAULT_HUB_CONFIG = {
|
||||
sloganTitle: '분기 중점 과제',
|
||||
sloganLines: ['인사 · 육성 · 문화 · 총무', '개선과제', '정상 추진'],
|
||||
scheduleTitle: '분기 주요 일정',
|
||||
scheduleItems: [
|
||||
{ id: '1', date: '2026-04-01', text: '상반기 채용·온보딩' },
|
||||
{ id: '2', date: '2026-05-15', text: '조직문화 진단·리더십 교육' },
|
||||
{ id: '3', date: '2026-06-20', text: '분기 성과 점검·평가' },
|
||||
],
|
||||
routineLabels: ['채용 운영', '학습 지원', '직원 소통', '자산·시설', '문서·행정'],
|
||||
};
|
||||
|
||||
function normalizeConfig(raw: Record<string, unknown>) {
|
||||
const sloganTitle = (raw.sloganTitle as string) ?? DEFAULT_HUB_CONFIG.sloganTitle;
|
||||
return {
|
||||
sloganTitle: sloganTitle === '분기 슬로건' ? '분기 중점 과제' : sloganTitle,
|
||||
sloganLines: Array.isArray(raw.sloganLines)
|
||||
? (raw.sloganLines as string[])
|
||||
: DEFAULT_HUB_CONFIG.sloganLines,
|
||||
scheduleTitle: (raw.scheduleTitle as string) ?? DEFAULT_HUB_CONFIG.scheduleTitle,
|
||||
scheduleItems: Array.isArray(raw.scheduleItems)
|
||||
? (raw.scheduleItems as typeof DEFAULT_HUB_CONFIG.scheduleItems)
|
||||
: DEFAULT_HUB_CONFIG.scheduleItems,
|
||||
routineLabels: Array.isArray(raw.routineLabels)
|
||||
? (raw.routineLabels as string[])
|
||||
: DEFAULT_HUB_CONFIG.routineLabels,
|
||||
};
|
||||
}
|
||||
|
||||
async function getOrCreateHubConfig() {
|
||||
let row = await prisma.hubConfig.findUnique({ where: { id: HUB_ID } });
|
||||
if (!row) {
|
||||
row = await prisma.hubConfig.create({
|
||||
data: { id: HUB_ID, config: DEFAULT_HUB_CONFIG },
|
||||
});
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
// GET /api/hub-config
|
||||
router.get('/', async (_req, res, next) => {
|
||||
try {
|
||||
const row = await getOrCreateHubConfig();
|
||||
res.json(normalizeConfig(row.config as Record<string, unknown>));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/hub-config
|
||||
router.patch('/', async (req, res, next) => {
|
||||
try {
|
||||
const row = await getOrCreateHubConfig();
|
||||
const merged = normalizeConfig({
|
||||
...(row.config as Record<string, unknown>),
|
||||
...(req.body as Record<string, unknown>),
|
||||
});
|
||||
const updated = await prisma.hubConfig.update({
|
||||
where: { id: HUB_ID },
|
||||
data: { config: merged },
|
||||
});
|
||||
res.json(normalizeConfig(updated.config as Record<string, unknown>));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -8,6 +8,7 @@ import columnRoutes from './columns';
|
||||
import milestoneRoutes from './milestones';
|
||||
import detailRoutes from './details';
|
||||
import teamMemberRoutes from './teamMembers';
|
||||
import hubConfigRoutes from './hubConfig';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -18,6 +19,7 @@ router.use('/users', userRoutes);
|
||||
router.use('/files', fileRoutes);
|
||||
router.use('/kpi', kpiRoutes);
|
||||
router.use('/columns', columnRoutes);
|
||||
router.use('/hub-config', hubConfigRoutes);
|
||||
router.use('/milestones', milestoneRoutes);
|
||||
router.use('/details', detailRoutes);
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { resolveTaskActorId } from '../lib/resolveUser';
|
||||
import { formatMilestone, milestoneInclude, parseMemberIds } from '../lib/taskQuery';
|
||||
import { resolveMilestonePeriodPayload } from '../lib/milestonePeriods';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
|
||||
const router = Router();
|
||||
@@ -28,14 +31,47 @@ function clampProgress(value: unknown): number {
|
||||
return Math.min(100, Math.max(0, Math.round(n)));
|
||||
}
|
||||
|
||||
async function syncMilestoneMembers(
|
||||
milestoneId: string,
|
||||
pmMemberId: string | null | undefined,
|
||||
assigneeMemberIds: string[] | undefined,
|
||||
) {
|
||||
if (pmMemberId !== undefined) {
|
||||
await prisma.milestone.update({
|
||||
where: { id: milestoneId },
|
||||
data: { pmMemberId: pmMemberId || null },
|
||||
});
|
||||
}
|
||||
|
||||
if (assigneeMemberIds !== undefined) {
|
||||
await prisma.milestoneAssignee.deleteMany({ where: { milestoneId } });
|
||||
const ids = [...new Set(assigneeMemberIds.filter(Boolean))];
|
||||
if (ids.length > 0) {
|
||||
await prisma.milestoneAssignee.createMany({
|
||||
data: ids.map((memberId) => ({ milestoneId, memberId })),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMilestone(id: string) {
|
||||
const milestone = await prisma.milestone.findUnique({
|
||||
where: { id },
|
||||
include: milestoneInclude,
|
||||
});
|
||||
if (!milestone) throw new AppError(404, '단계를 찾을 수 없습니다.');
|
||||
return formatMilestone(milestone);
|
||||
}
|
||||
|
||||
// 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' },
|
||||
include: milestoneInclude,
|
||||
});
|
||||
res.json(milestones);
|
||||
res.json(milestones.map((m) => formatMilestone(m)));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -45,26 +81,48 @@ router.get('/:taskId', async (req, res, next) => {
|
||||
router.post('/:taskId', async (req, res, next) => {
|
||||
try {
|
||||
const taskId = req.params.taskId;
|
||||
const { title, description, startDate, dueDate, feedback, links, progress } =
|
||||
req.body as Record<string, string | number>;
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const { title, subtitle, description, startDate, dueDate, feedback, links, progress } = body;
|
||||
const assigneeMemberIds = parseMemberIds(body);
|
||||
const pmMemberId =
|
||||
body.pmMemberId !== undefined ? String(body.pmMemberId || '') || null : undefined;
|
||||
|
||||
if (!title?.toString().trim()) throw new AppError(400, '단계 제목은 필수입니다.');
|
||||
|
||||
const count = await prisma.milestone.count({ where: { taskId } });
|
||||
const periodPayload = resolveMilestonePeriodPayload(body);
|
||||
|
||||
const milestone = await prisma.milestone.create({
|
||||
data: {
|
||||
taskId,
|
||||
title: String(title).trim(),
|
||||
subtitle: subtitle !== undefined ? String(subtitle || '').trim() || null : null,
|
||||
description: description?.toString().trim() || null,
|
||||
startDate: startDate ? new Date(String(startDate)) : null,
|
||||
dueDate: dueDate ? new Date(String(dueDate)) : null,
|
||||
startDate:
|
||||
periodPayload.startDate !== undefined
|
||||
? periodPayload.startDate
|
||||
: startDate
|
||||
? new Date(String(startDate))
|
||||
: null,
|
||||
dueDate:
|
||||
periodPayload.dueDate !== undefined
|
||||
? periodPayload.dueDate
|
||||
: dueDate
|
||||
? new Date(String(dueDate))
|
||||
: null,
|
||||
periodEntries:
|
||||
periodPayload.periodEntries !== undefined
|
||||
? (periodPayload.periodEntries as Prisma.InputJsonValue)
|
||||
: undefined,
|
||||
progress: progress !== undefined ? clampProgress(progress) : 0,
|
||||
links: normalizeLinks(links),
|
||||
order: count,
|
||||
...(pmMemberId !== undefined ? { pmMemberId } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
await syncMilestoneMembers(milestone.id, pmMemberId, assigneeMemberIds);
|
||||
|
||||
if (feedback?.toString().trim()) {
|
||||
const updatedBy = await resolveTaskActorId(taskId);
|
||||
await prisma.taskDetail.create({
|
||||
@@ -77,7 +135,7 @@ router.post('/:taskId', async (req, res, next) => {
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json(milestone);
|
||||
res.status(201).json(await loadMilestone(milestone.id));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -86,19 +144,33 @@ router.post('/:taskId', async (req, res, next) => {
|
||||
// PATCH /api/milestones/item/:id
|
||||
router.patch('/item/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { title, description, startDate, dueDate, feedback, links, progress, completed, order } =
|
||||
req.body as Record<string, string | boolean | number>;
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const { title, subtitle, description, startDate, dueDate, feedback, links, progress, completed, order } =
|
||||
body;
|
||||
const assigneeMemberIds = parseMemberIds(body);
|
||||
const pmMemberId =
|
||||
body.pmMemberId !== undefined ? String(body.pmMemberId || '') || null : undefined;
|
||||
|
||||
const existing = await prisma.milestone.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) throw new AppError(404, '단계를 찾을 수 없습니다.');
|
||||
|
||||
const periodPayload = resolveMilestonePeriodPayload(body);
|
||||
|
||||
const milestone = await prisma.milestone.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
...(title !== undefined && { title: String(title).trim() }),
|
||||
...(subtitle !== undefined && { subtitle: String(subtitle || '').trim() || null }),
|
||||
...(description !== undefined && { description: description ? String(description).trim() : null }),
|
||||
...(startDate !== undefined && { startDate: startDate ? new Date(String(startDate)) : null }),
|
||||
...(dueDate !== undefined && { dueDate: dueDate ? new Date(String(dueDate)) : null }),
|
||||
...(periodPayload.startDate !== undefined && { startDate: periodPayload.startDate }),
|
||||
...(periodPayload.dueDate !== undefined && { dueDate: periodPayload.dueDate }),
|
||||
...(periodPayload.periodEntries !== undefined && {
|
||||
periodEntries: periodPayload.periodEntries as Prisma.InputJsonValue,
|
||||
}),
|
||||
...(periodPayload.startDate === undefined &&
|
||||
startDate !== undefined && { startDate: startDate ? new Date(String(startDate)) : null }),
|
||||
...(periodPayload.dueDate === undefined &&
|
||||
dueDate !== undefined && { dueDate: dueDate ? new Date(String(dueDate)) : null }),
|
||||
...(progress !== undefined && { progress: clampProgress(progress) }),
|
||||
...(links !== undefined && { links: normalizeLinks(links) }),
|
||||
...(order !== undefined && { order: Number(order) }),
|
||||
@@ -106,9 +178,12 @@ router.patch('/item/:id', async (req, res, next) => {
|
||||
completedAt: completed ? new Date() : null,
|
||||
...(completed && { progress: 100 }),
|
||||
}),
|
||||
...(pmMemberId !== undefined ? { pmMemberId } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
await syncMilestoneMembers(milestone.id, pmMemberId, assigneeMemberIds);
|
||||
|
||||
if (typeof feedback === 'string' && feedback.trim()) {
|
||||
const updatedBy = await resolveTaskActorId(existing.taskId);
|
||||
await prisma.taskDetail.create({
|
||||
@@ -121,7 +196,7 @@ router.patch('/item/:id', async (req, res, next) => {
|
||||
});
|
||||
}
|
||||
|
||||
res.json(milestone);
|
||||
res.json(await loadMilestone(milestone.id));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
import type { Server } from 'socket.io';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { resolveCreatorId } from '../lib/resolveUser';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
import { emitTaskListRefresh, emitTaskUpdated } from '../socket';
|
||||
import {
|
||||
formatTask,
|
||||
parseMemberIds,
|
||||
@@ -9,9 +11,25 @@ import {
|
||||
taskDetailInclude,
|
||||
taskInclude,
|
||||
} from '../lib/taskQuery';
|
||||
import { deriveIssueFields, normalizeIssueEntries } from '../lib/taskIssues';
|
||||
|
||||
const router = Router();
|
||||
|
||||
function resolveIssuePayload(body: Record<string, any>) {
|
||||
if (body.issueEntries !== undefined) {
|
||||
const entries = normalizeIssueEntries(body.issueEntries);
|
||||
return deriveIssueFields(entries);
|
||||
}
|
||||
if (body.issueNote !== undefined) {
|
||||
const text = typeof body.issueNote === 'string' ? body.issueNote.trim() : '';
|
||||
if (!text) {
|
||||
return { issueEntries: [], issueNote: null, showIssue: false };
|
||||
}
|
||||
return deriveIssueFields([{ id: 'legacy', text, showOnCard: body.showIssue !== false }]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// GET /api/tasks — 목록 조회 (필터: status, quarter, assigneeId)
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
@@ -64,6 +82,7 @@ router.post('/', async (req, res, next) => {
|
||||
|
||||
const creatorId = await resolveCreatorId(body.creatorId);
|
||||
const assigneeMemberIds = parseMemberIds(body);
|
||||
const issuePayload = resolveIssuePayload(body);
|
||||
|
||||
const task = await prisma.task.create({
|
||||
data: {
|
||||
@@ -77,13 +96,14 @@ router.post('/', async (req, res, next) => {
|
||||
tag,
|
||||
taskType,
|
||||
progress: progress ? Number(progress) : 0,
|
||||
issueNote: issueNote || null,
|
||||
issueNote: issuePayload?.issueNote ?? (issueNote || null),
|
||||
issueEntries: issuePayload?.issueEntries as any ?? undefined,
|
||||
startDate: startDate ? new Date(startDate) : undefined,
|
||||
dueDate: dueDate ? new Date(dueDate) : undefined,
|
||||
showDate: showDate !== undefined ? showDate === 'true' || showDate === true : true,
|
||||
showDescription: showDescription !== undefined ? showDescription === 'true' || showDescription === true : true,
|
||||
showStatus: showStatus !== undefined ? showStatus === 'true' || showStatus === true : true,
|
||||
showIssue: showIssue !== undefined ? showIssue === 'true' || showIssue === true : true,
|
||||
showIssue: issuePayload?.showIssue ?? (showIssue !== undefined ? showIssue === 'true' || showIssue === true : true),
|
||||
showProgress: showProgress !== undefined ? showProgress === 'true' || showProgress === true : true,
|
||||
assigneeId: assigneeId || null,
|
||||
pmMemberId: pmMemberId || null,
|
||||
@@ -120,6 +140,7 @@ router.patch('/:id', async (req, res, next) => {
|
||||
showDescription, showStatus, showIssue, showProgress, pmMemberId } = body;
|
||||
|
||||
const assigneeMemberIds = parseMemberIds(body);
|
||||
const issuePayload = resolveIssuePayload(body);
|
||||
|
||||
await prisma.task.update({
|
||||
where: { id: req.params.id },
|
||||
@@ -134,7 +155,20 @@ router.patch('/:id', async (req, res, next) => {
|
||||
...(tag !== undefined && { tag }),
|
||||
...(taskType !== undefined && { taskType }),
|
||||
...(progress !== undefined && { progress: Number(progress) }),
|
||||
...(issueNote !== undefined && { issueNote: issueNote || null }),
|
||||
...(issuePayload
|
||||
? {
|
||||
issueEntries: issuePayload.issueEntries as any,
|
||||
issueNote: issuePayload.issueNote,
|
||||
showIssue: issuePayload.showIssue,
|
||||
}
|
||||
: issueNote !== undefined
|
||||
? {
|
||||
issueNote: issueNote || null,
|
||||
...(issueNote
|
||||
? {}
|
||||
: { issueEntries: [] as any, showIssue: false }),
|
||||
}
|
||||
: {}),
|
||||
...(startDate !== undefined && { startDate: startDate ? new Date(startDate) : null }),
|
||||
...(dueDate !== undefined && { dueDate: dueDate ? new Date(dueDate) : null }),
|
||||
...(assigneeId !== undefined && { assigneeId: assigneeId || null }),
|
||||
@@ -160,7 +194,14 @@ router.patch('/:id', async (req, res, next) => {
|
||||
include: taskInclude,
|
||||
});
|
||||
|
||||
res.json(formatTask(task!));
|
||||
const formatted = formatTask(task!);
|
||||
const io = req.app.get('io') as Server | undefined;
|
||||
if (io) {
|
||||
emitTaskUpdated(io, req.params.id, formatted);
|
||||
emitTaskListRefresh(io);
|
||||
}
|
||||
|
||||
res.json(formatted);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
import { uploadTeamPhoto } from '../middleware/uploadTeamPhoto';
|
||||
import { cleanupUploadedFile, DISK_FULL_MESSAGE, isDiskFullError } from '../lib/uploadErrors';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -43,6 +44,11 @@ router.post('/photo', uploadTeamPhoto.single('photo'), async (req, res, next) =>
|
||||
filename: req.file.filename,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isDiskFullError(err)) {
|
||||
cleanupUploadedFile(req.file);
|
||||
next(new AppError(507, DISK_FULL_MESSAGE));
|
||||
return;
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user