댓글이미지 업로드 및 상태값 연동
Deploy EG-BIM QA Gateway / deploy (push) Successful in 36s

This commit is contained in:
root
2026-09-21 20:25:43 +09:00
parent 6639c148eb
commit d197c0cf14
6 changed files with 229 additions and 14 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ python3 -m http.server 4173
- `feedback_comment_attachments`: 답변 댓글 이미지가 생길 때 `comment_id``qa_cdn` 메타데이터
- `support_attachments`: 운영 호환 레이어가 필요할 때 동일 파일의 티켓 첨부 메타데이터
상세 페이지에는 공개 댓글 입력 UI가 있으며, 댓글은 같은 Worker를 통해 ABC의 `feedback_comments`에 저장하고 `is_internal=false`인 공개 댓글만 조회합니다. 댓글 첨부파일은 별도 댓글 첨부 API 계약을 확인한 뒤 추가합니다.
상세 페이지에는 공개 댓글 입력 UI가 있으며, 댓글은 같은 Worker를 통해 ABC의 `feedback_comments`에 저장하고 `is_internal=false`인 공개 댓글만 조회합니다. 댓글 이미지가 있으면 `attachments` multipart 필드로 Worker가 ABC에 전달하며, 응답으로 받은 이미지 URL을 썸네일로 표시합니다. 목록·상세 진입 시에는 ABC의 `feedback_status`를 다시 조회해 관리페이지에서 변경된 상태를 반영합니다.
## 연동 지점
+10
View File
@@ -282,6 +282,16 @@ button { cursor: pointer; }
.comment-empty { padding: 32px; border-bottom: 1px solid var(--line); color: #999; text-align: center; }
.comment-form { padding: 20px 10px 0; }
.comment-form .textarea { min-height: 120px; }
.comment-attachment-input { padding-top: 14px; color: #555; font-size: 13px; }
.comment-attachment-input label { display: block; margin-bottom: 8px; font-weight: 600; }
.comment-attachment-input input { max-width: 100%; }
.comment-image-preview, .comment-attachments-list { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; }
.comment-image-preview-item { width: 104px; overflow: hidden; border: 1px solid #ddd; background: #fafafa; }
.comment-image-preview-item img { display: block; width: 104px; height: 78px; object-fit: cover; }
.comment-image-preview-item span { display: block; overflow: hidden; padding: 5px 6px; color: #666; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.comment-image-link { display: block; width: 124px; }
.comment-image { display: block; width: 124px; height: 94px; border: 1px solid #ddd; object-fit: cover; }
.comment-attachment-name { color: #666; font-size: 13px; }
.comment-form-actions { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding-top: 10px; }
.comment-form-actions .form-error { display: block; flex: 1; margin: 0; }
+10
View File
@@ -70,11 +70,21 @@
## 3차 범위: 공개 댓글
- [x] 상세페이지 댓글 입력 UI 추가
- [x] 댓글 이미지 multipart(`attachments`) 전송
- [x] 댓글 이미지 선택 시 썸네일 미리보기
- [x] Worker 댓글 목록 조회 라우트 추가
- [x] Worker 공개 댓글 생성 라우트 추가
- [x] `is_internal=false` 공개 댓글만 조회
- [ ] 실제 관리자 답변 등록 후 Q&A 상세페이지 표시 테스트
## 4차 범위: 관리자 상태 동기화
- [x] Worker에 `GET /api/feedbacks/{feedbackId}` 상세 조회 프록시 추가
- [x] ABC `feedback_status`를 사용자 화면 상태명으로 변환
- [x] 목록 진입 시 저장된 문의의 최신 상태 재조회
- [x] 상세페이지 진입 시 최신 상태 재조회
- [ ] 실제 관리페이지에서 상태 변경 후 Q&A 화면 반영 테스트
## 보안 요구사항
- [x] API Key를 정적 JavaScript, HTML, `egbim/config.js`에 넣지 않음
+137 -8
View File
@@ -6,6 +6,14 @@
const STORAGE_KEY = 'baron.qa.posts.egbim';
const COMMENTS_KEY = 'baron.qa.comments.egbim';
const CATEGORY_CODES = { '오류문의': 'ERROR_QNA', '개선문의': 'IMPROVEMENT_QNA', '일반문의': 'GENERAL_QNA' };
const STATUS_LABELS = {
INIT: '접수',
ON_REVIEW: '문의검토',
DETAILED_REVIEW: '정밀검토',
IN_PROGRESS: '처리중',
RESOLVED: '답변완료',
PENDING: '보류'
};
let authReady;
function qs(selector, root) { return (root || document).querySelector(selector); }
@@ -30,6 +38,63 @@
localStorage.setItem(STORAGE_KEY, JSON.stringify(posts));
}
function updateStoredPost(post) {
const posts = readJson(STORAGE_KEY, []);
const index = posts.findIndex(function (item) { return item.id === post.id; });
if (index < 0) return;
posts[index] = Object.assign({}, posts[index], post);
localStorage.setItem(STORAGE_KEY, JSON.stringify(posts));
}
function normalizeStatus(value) {
if (value && typeof value === 'object') value = value.code || value.key || value.value || value.name || '';
const raw = String(value || '').trim();
if (!raw) return '';
const upper = raw.toUpperCase();
return STATUS_LABELS[upper] || ({
NEW: '접수', RECEIVED: '접수', 접수: '접수', 문의접수: '접수',
REVIEW: '문의검토', 문의검토: '문의검토',
DEEP: '정밀검토', 정밀검토: '정밀검토',
PATCH: '처리중', 처리중: '처리중',
DONE: '답변완료', COMPLETED: '답변완료', 답변완료: '답변완료',
HOLD: '보류', 보류: '보류'
}[upper] || raw);
}
function extractFeedbackStatus(payload) {
const root = payload && payload.feedback ? payload.feedback : payload;
const candidates = [root, root && root.data, root && root.feedback, root && root.fields];
for (let index = 0; index < candidates.length; index += 1) {
const candidate = candidates[index];
if (!candidate || typeof candidate !== 'object') continue;
const value = candidate.feedback_status || candidate.feedbackStatus || candidate.status_code || candidate.status;
if (value != null && value !== '') return normalizeStatus(value);
}
const fields = root && (Array.isArray(root.fields) ? root.fields : (root.data && Array.isArray(root.data.fields) ? root.data.fields : []));
const statusField = fields.find(function (field) { return field && (field.key === 'feedback_status' || field.fieldKey === 'feedback_status' || field.name === 'feedback_status'); });
return statusField ? normalizeStatus(statusField.value || statusField.val || statusField.data) : '';
}
async function refreshRemoteStatus(post) {
if (!post || !post.id || post.category === '공지사항') return false;
try {
const result = await requestJson('/api/feedbacks/' + encodeURIComponent(post.id), { method: 'GET' });
const status = extractFeedbackStatus(result);
if (!status || status === post.status) return false;
post.status = status;
updateStoredPost(post);
return true;
} catch (error) {
console.warn('[QA] feedback status sync failed:', post.id, error.message);
return false;
}
}
async function refreshRemoteStatuses(posts) {
const changed = await Promise.all((posts || []).map(refreshRemoteStatus));
return changed.some(Boolean);
}
function getComments() { return readJson(COMMENTS_KEY, {}); }
function parseJson(value, fallback) {
@@ -177,7 +242,7 @@
}
async function requestJson(path, options) {
const sameOriginProxy = !config.apiBaseUrl && (path === config.createFeedbackPath || /^\/api\/feedbacks\/[^/]+\/comments$/.test(path));
const sameOriginProxy = !config.apiBaseUrl && (path === config.createFeedbackPath || /^\/api\/feedbacks\/[^/]+(?:\/comments)?$/.test(path));
if (!config.apiBaseUrl && !sameOriginProxy) return { local: true };
const target = config.apiBaseUrl ? config.apiBaseUrl.replace(/\/$/, '') + path : path;
const response = await fetch(target, Object.assign({ credentials: 'include' }, options));
@@ -260,12 +325,13 @@
tableBody.innerHTML = rows.map(function (post, index) {
const isNotice = post.category === '공지사항';
const number = isNotice ? '공지' : filtered.length - ((page - 1) * pageSize + index);
const statusClass = post.status === '답변완료' ? 'done' : (post.status === '문의검토' || post.status === '정밀검토' ? 'review' : '');
const status = normalizeStatus(post.status);
const statusClass = status === '답변완료' ? 'done' : (status === '문의검토' || status === '정밀검토' ? 'review' : '');
return '<tr class="' + (isNotice ? 'notice-row' : '') + '" data-id="' + escapeHtml(post.id) + '">' +
'<td>' + number + '</td><td class="category-cell">' + escapeHtml(post.category.replace('문의', '')) + '</td>' +
'<td>' + escapeHtml(post.company) + '</td><td>' + escapeHtml(post.department) + '</td><td>' + escapeHtml(post.author) + '</td>' +
'<td class="subject"><span class="subject-link">' + (post.secret ? '<span class="lock">🔒</span>' : '') + escapeHtml(post.title) + '</span>' + (!isNotice && post.date === '2026-09-17' ? '<span class="new-mark">N</span>' : '') + '</td>' +
'<td>' + escapeHtml(post.date) + '</td><td>' + (post.status ? '<span class="status ' + statusClass + '">' + escapeHtml(post.status) + '</span>' : '-') + '</td></tr>';
'<td>' + escapeHtml(post.date) + '</td><td>' + (status ? '<span class="status ' + statusClass + '">' + escapeHtml(status) + '</span>' : '-') + '</td></tr>';
}).join('');
qsa('#qaRows tr').forEach(function (row) { row.addEventListener('click', function () { window.location.href = 'detail.html?id=' + encodeURIComponent(row.dataset.id); }); });
pagination.innerHTML = Array.from({ length: totalPages }, function (_, i) { return '<button type="button" class="' + (i + 1 === page ? 'active' : '') + '" data-page="' + (i + 1) + '">' + (i + 1) + '</button>'; }).join('');
@@ -275,6 +341,7 @@
searchForm.addEventListener('submit', function (event) { event.preventDefault(); page = 1; render(); });
qsa('input[name="category"], #onlyMine').forEach(function (input) { input.addEventListener('change', function () { page = 1; render(); }); });
render();
refreshRemoteStatuses(getPosts()).then(function (changed) { if (changed) render(); });
}
function initWrite() {
@@ -334,7 +401,7 @@
}
qs('[data-detail-category]').textContent = post.category;
qs('[data-detail-title]').textContent = post.title;
qs('[data-detail-status]').textContent = post.status || '공지';
qs('[data-detail-status]').textContent = normalizeStatus(post.status) || '공지';
qs('[data-detail-date]').textContent = post.date;
qs('[data-detail-author]').textContent = post.author;
qs('[data-detail-company]').textContent = post.company;
@@ -346,14 +413,48 @@
if (attachmentBox) attachmentBox.innerHTML = attachments.length ? attachments.map(function (attachment) {
return '<div class="attachment-item">📎 ' + escapeHtml(attachment.originalFileName || '첨부파일') + '</div>';
}).join('') : '<span class="help-text">첨부된 파일이 없습니다.</span>';
function safeMediaUrl(value) {
value = String(value || '');
return /^(https?:\/\/|\/|blob:)/i.test(value) ? value : '';
}
function normalizeAttachment(attachment) {
if (typeof attachment === 'string') return { name: '첨부 이미지', url: safeMediaUrl(attachment), thumbnailUrl: safeMediaUrl(attachment) };
attachment = attachment || {};
const url = attachment.url || attachment.download_url || attachment.downloadUrl || attachment.signed_url || attachment.signedUrl || attachment.presigned_url || attachment.presignedUrl || '';
const thumbnailUrl = attachment.thumbnail_url || attachment.thumbnailUrl || attachment.thumb_url || attachment.thumbUrl || url;
return {
name: attachment.original_file_name || attachment.originalFileName || attachment.file_name || attachment.fileName || attachment.name || '첨부 이미지',
url: safeMediaUrl(url),
thumbnailUrl: safeMediaUrl(thumbnailUrl)
};
}
function normalizeComment(comment) {
comment = comment || {};
return { id: comment.id || '', author: comment.author_name || comment.authorName || comment.author || comment.author?.name || '작성자', date: comment.created_at || comment.createdAt || comment.updated_at || '', content: comment.content || comment.body || '' };
const rawAttachments = comment.attachments || comment.images || comment.comment_attachments || comment.commentAttachments || [];
return {
id: comment.id || '',
author: comment.author_name || comment.authorName || comment.author?.name || comment.author || '작성자',
date: comment.created_at || comment.createdAt || comment.updated_at || '',
content: comment.content || comment.body || '',
attachments: (Array.isArray(rawAttachments) ? rawAttachments : [rawAttachments]).map(normalizeAttachment)
};
}
function renderCommentAttachments(attachments) {
return (attachments || []).map(function (attachment) {
const imageUrl = attachment.thumbnailUrl || attachment.url;
if (!imageUrl) return '<div class="comment-attachment-name">📎 ' + escapeHtml(attachment.name) + '</div>';
const linkUrl = attachment.url || imageUrl;
return '<a class="comment-image-link" href="' + escapeHtml(linkUrl) + '" target="_blank" rel="noopener noreferrer"><img class="comment-image" src="' + escapeHtml(imageUrl) + '" alt="' + escapeHtml(attachment.name) + '" loading="lazy"></a>';
}).join('');
}
function renderComments(comments) {
comments = (comments || []).map(normalizeComment).filter(function (comment) { return comment.content; });
qs('#commentCount').textContent = comments.length;
qs('#comments').innerHTML = comments.length ? comments.map(function (comment) { return '<div class="comment"><div class="comment-meta"><strong>' + escapeHtml(comment.author) + '</strong><span>' + escapeHtml(comment.date) + '</span></div><div class="comment-content">' + escapeHtml(comment.content) + '</div></div>'; }).join('') : '<div class="comment-empty">등록된 답변이 없습니다.</div>';
qs('#comments').innerHTML = comments.length ? comments.map(function (comment) { return '<div class="comment"><div class="comment-meta"><strong>' + escapeHtml(comment.author) + '</strong><span>' + escapeHtml(comment.date) + '</span></div><div class="comment-content">' + escapeHtml(comment.content) + '</div>' + (comment.attachments.length ? '<div class="comment-attachments-list">' + renderCommentAttachments(comment.attachments) + '</div>' : '') + '</div>'; }).join('') : '<div class="comment-empty">등록된 답변이 없습니다.</div>';
return comments;
}
let comments = renderComments(getComments()[post.id] || []);
@@ -369,16 +470,32 @@
const contentInput = qs('#commentContent');
const errorBox = qs('#commentError');
const submitButton = qs('#commentSubmit');
const imageInput = qs('#commentImages');
const files = imageInput ? Array.prototype.slice.call(imageInput.files || []) : [];
const content = contentInput.value.trim();
errorBox.hidden = true;
if (!content) { errorBox.textContent = '댓글 내용을 입력해주세요.'; errorBox.hidden = false; return; }
if (files.some(function (file) { return !/^image\//i.test(file.type); })) { errorBox.textContent = '댓글 첨부는 이미지 파일만 등록할 수 있습니다.'; errorBox.hidden = false; return; }
if (files.some(function (file) { return file.size > 30 * 1024 * 1024; })) { errorBox.textContent = '댓글 이미지는 파일당 30MB 이하만 등록할 수 있습니다.'; errorBox.hidden = false; return; }
submitButton.disabled = true;
submitButton.textContent = '등록 중...';
try {
const result = await postToApi(commentsPath, { content: content });
comments.push(normalizeComment(result.comment || { id: result.id, content: content, author: (getAuthUser() || {}).name || '작성자', createdAt: new Date().toISOString() }));
let payload = { content: content };
if (files.length) {
payload = new FormData();
payload.append('content', content);
files.forEach(function (file) { payload.append('attachments', file, file.name); });
}
const result = await postToApi(commentsPath, payload);
const localAttachments = files.map(function (file) { const url = URL.createObjectURL(file); return { name: file.name, url: url, thumbnailUrl: url }; });
const savedComment = normalizeComment(result.comment || { id: result.id, content: content, author: (getAuthUser() || {}).name || '작성자', createdAt: new Date().toISOString(), attachments: localAttachments });
if (!savedComment.attachments.length) savedComment.attachments = localAttachments;
comments.push(savedComment);
renderComments(comments);
contentInput.value = '';
if (imageInput) imageInput.value = '';
const imagePreview = qs('#commentImagePreview');
if (imagePreview) imagePreview.innerHTML = '';
} catch (error) {
errorBox.textContent = error.message || '댓글 등록에 실패했습니다.';
errorBox.hidden = false;
@@ -387,6 +504,18 @@
submitButton.textContent = '댓글 등록';
}
});
const commentImages = qs('#commentImages');
const commentImagePreview = qs('#commentImagePreview');
if (commentImages && commentImagePreview) commentImages.addEventListener('change', function () {
const files = Array.prototype.slice.call(commentImages.files || []);
commentImagePreview.innerHTML = files.filter(function (file) { return /^image\//i.test(file.type); }).map(function (file) {
const url = URL.createObjectURL(file);
return '<div class="comment-image-preview-item"><img src="' + escapeHtml(url) + '" alt="' + escapeHtml(file.name) + '"><span>' + escapeHtml(file.name) + '</span></div>';
}).join('');
});
refreshRemoteStatus(post).then(function (changed) {
if (changed) qs('[data-detail-status]').textContent = normalizeStatus(post.status) || '공지';
});
qs('#backButton').addEventListener('click', function () { window.location.href = 'index.html'; });
}
+1 -1
View File
@@ -3,6 +3,6 @@
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>문의 상세 | EG-BIM Q&amp;A</title><link rel="stylesheet" href="../assets/styles.css?v=20260921-4"></head>
<body>
<main class="egbim-page"><section class="page-hero"><div class="hero-inner"><span class="hero-kicker">EG-BIM SUPPORT</span><h1>Q&amp;A</h1><p>EG-BIM 관련 문의하기</p></div><nav class="sub-nav" aria-label="EG-BIM 지원 메뉴"><div class="sub-nav-inner"><a href="index.html" aria-current="page">문의하기(Q&amp;A)</a><a href="#" aria-disabled="true" class="disabled-tab">원격지원</a></div></nav></section>
<section class="page-shell" id="detail"><div class="section-heading"><div><h2>문의 상세</h2><p>문의 내용과 답변을 확인할 수 있습니다.</p></div></div><article class="detail-card"><div class="detail-head"><div class="detail-title-line"><h2><span class="category" data-detail-category>문의</span> <span data-detail-title>문의 제목</span></h2><span class="status review" data-detail-status>문의접수</span></div><div class="detail-meta"><span data-detail-date>2026-09-17</span><span data-detail-author>작성자</span><span data-detail-company>회사</span><span data-detail-department>부서</span><span data-detail-secret hidden>🔒 비밀글</span></div></div><div class="detail-body" data-detail-content></div><div class="attachments"><h3>첨부파일</h3><div data-detail-attachments><span class="help-text">첨부된 파일이 없습니다.</span></div></div><div class="detail-actions"><a class="button button-outline" href="index.html">목록</a></div></article><section class="comment-box"><h3>답변 <span id="commentCount">0</span></h3><div id="comments"></div><form id="commentForm" class="comment-form"><label class="sr-only" for="commentContent">댓글 내용</label><textarea id="commentContent" class="textarea" maxlength="5000" placeholder="답변 또는 추가 문의를 입력해주세요" required></textarea><div class="comment-form-actions"><span id="commentError" class="form-error" role="alert" hidden></span><button class="button button-primary" id="commentSubmit" type="submit">댓글 등록</button></div></form></section><div class="form-footer"><button class="button button-outline" id="backButton" type="button">목록으로</button></div></section>
<section class="page-shell" id="detail"><div class="section-heading"><div><h2>문의 상세</h2><p>문의 내용과 답변을 확인할 수 있습니다.</p></div></div><article class="detail-card"><div class="detail-head"><div class="detail-title-line"><h2><span class="category" data-detail-category>문의</span> <span data-detail-title>문의 제목</span></h2><span class="status review" data-detail-status>문의접수</span></div><div class="detail-meta"><span data-detail-date>2026-09-17</span><span data-detail-author>작성자</span><span data-detail-company>회사</span><span data-detail-department>부서</span><span data-detail-secret hidden>🔒 비밀글</span></div></div><div class="detail-body" data-detail-content></div><div class="attachments"><h3>첨부파일</h3><div data-detail-attachments><span class="help-text">첨부된 파일이 없습니다.</span></div></div><div class="detail-actions"><a class="button button-outline" href="index.html">목록</a></div></article><section class="comment-box"><h3>답변 <span id="commentCount">0</span></h3><div id="comments"></div><form id="commentForm" class="comment-form"><label class="sr-only" for="commentContent">댓글 내용</label><textarea id="commentContent" class="textarea" maxlength="5000" placeholder="답변 또는 추가 문의를 입력해주세요" required></textarea><div class="comment-attachment-input"><label for="commentImages">이미지 첨부</label><input id="commentImages" type="file" accept="image/*" multiple><div id="commentImagePreview" class="comment-image-preview"></div><span class="help-text">이미지 파일만 등록할 수 있으며, 파일당 최대 30MB입니다.</span></div><div class="comment-form-actions"><span id="commentError" class="form-error" role="alert" hidden></span><button class="button button-primary" id="commentSubmit" type="submit">댓글 등록</button></div></form></section><div class="form-footer"><button class="button button-outline" id="backButton" type="button">목록으로</button></div></section>
</main><script src="config.js?v=20260921-3"></script><script src="app.js?v=20260921-3"></script>
</body></html>
+70 -4
View File
@@ -12,6 +12,7 @@ export default {
if (url.pathname === '/auth/session') return sessionResponse(request, env);
if (url.pathname === '/api/feedbacks') return createFeedback(request, env);
if (/^\/api\/feedbacks\/[^/]+\/comments$/.test(url.pathname)) return feedbackComments(request, env, url);
if (/^\/api\/feedbacks\/[^/]+$/.test(url.pathname)) return feedbackDetail(request, env, url);
if (request.method !== 'GET' && request.method !== 'HEAD') {
return json({ error: 'method_not_allowed' }, 405);
@@ -432,22 +433,40 @@ async function feedbackComments(request, env, url) {
};
let body;
let commentId;
const uploadedAttachments = [];
if (request.method === 'GET') {
const query = new URLSearchParams({ includeInternal: 'false' });
endpoint = endpoint + '?' + query.toString();
} else {
let input;
try { input = await request.json(); } catch (error) { return json({ error: 'invalid_json' }, 400); }
const requestContentType = request.headers.get('content-type') || '';
try {
if (requestContentType.toLowerCase().includes('multipart/form-data')) {
const formData = await request.formData();
input = {};
for (const [key, value] of formData.entries()) {
if (key === 'attachments' && typeof value !== 'string') uploadedAttachments.push(value);
else if (typeof value === 'string' && !(key in input)) input[key] = value;
}
} else {
input = await request.json();
}
} catch (error) {
return json({ error: requestContentType.toLowerCase().includes('multipart/form-data') ? 'invalid_multipart' : 'invalid_json' }, 400);
}
const content = String(input && input.content || '').trim();
if (!content) return json({ error: 'comment_content_required' }, 400);
if (content.length > 5000) return json({ error: 'comment_content_too_long' }, 400);
const invalidAttachment = uploadedAttachments.find((file) => !String(file.type || '').toLowerCase().startsWith('image/'));
if (invalidAttachment) return json({ error: 'comment_image_required', message: '댓글 첨부는 이미지 파일만 등록할 수 있습니다.' }, 415);
const oversizedAttachment = uploadedAttachments.find((file) => Number(file.size || 0) > 30 * 1024 * 1024);
if (oversizedAttachment) return json({ error: 'comment_attachment_too_large', message: '댓글 이미지는 파일당 30MB 이하만 등록할 수 있습니다.' }, 413);
commentId = uuidv7();
headers['content-type'] = 'application/json';
headers['x-source-namespace'] = sourceNamespace;
headers['x-source-record-id'] = commentId;
headers['x-idempotency-consumer'] = sourceNamespace;
headers['idempotency-key'] = commentId;
body = JSON.stringify({
const commentPayload = {
author_id: requesterId,
author_tenant_id: requesterTenantId,
author_name: String(user.name || user.email || ''),
@@ -455,7 +474,16 @@ async function feedbackComments(request, env, url) {
is_internal: false,
actor_type: 'USER',
comment_type: 'COMMENT'
});
};
if (uploadedAttachments.length) {
const multipart = new FormData();
Object.entries(commentPayload).forEach(([key, value]) => multipart.append(key, String(value)));
uploadedAttachments.forEach((file) => multipart.append('attachments', file, file.name || 'comment-image'));
body = multipart;
} else {
headers['content-type'] = 'application/json';
body = JSON.stringify(commentPayload);
}
}
let response;
@@ -477,6 +505,44 @@ async function feedbackComments(request, env, url) {
return json({ id: result.id || result.data?.id || result.comment?.id || commentId, comment: result.comment || result.data || result });
}
async function feedbackDetail(request, env, url) {
if (request.method !== 'GET') return json({ error: 'method_not_allowed' }, 405, { allow: 'GET' });
const user = await getSession(request, env);
if (!user) return json({ error: 'unauthenticated' }, 401);
if (!env.ABC_API_KEY) return json({ error: 'feedback_api_not_configured' }, 503);
if (!env.ABC_PROJECT_ID || !env.ABC_CHANNEL_ID) return json({ error: 'feedback_target_not_configured' }, 503);
const feedbackId = decodeURIComponent(url.pathname.split('/')[3] || '');
if (!isUuidv7(feedbackId)) return json({ error: 'invalid_feedback_id' }, 400);
const requesterId = String(user.requesterId || user.ssoSubject || user.userUuid || '');
const requesterTenantId = String(user.requesterTenantId || user.tenantId || '');
if (!requesterId || !requesterTenantId) return json({ error: 'requester_identity_missing' }, 422);
const apiBaseUrl = String(env.ABC_API_BASE_URL || 'https://feedback.hmac.kr').replace(/\/$/, '');
const endpoint = `${apiBaseUrl}/api/projects/${encodeURIComponent(env.ABC_PROJECT_ID)}/channels/${encodeURIComponent(env.ABC_CHANNEL_ID)}/feedbacks/${encodeURIComponent(feedbackId)}`;
let response;
try {
response = await fetch(endpoint, {
method: 'GET',
headers: {
accept: 'application/json',
'x-api-key': env.ABC_API_KEY,
'x-requester-id': requesterId,
'x-requester-tenant-id': requesterTenantId
}
});
} catch (error) {
console.error('feedback_detail_api_request_failed', error instanceof Error ? error.message : 'unknown');
return json({ error: 'feedback_api_unreachable' }, 502);
}
const result = await response.json().catch(() => ({}));
if (!response.ok) {
const upstreamMessage = String(result.message || result.error || result.code || '').slice(0, 240);
return json({ error: 'feedback_api_rejected', message: upstreamMessage || '문의 상세 조회에 실패했습니다.', status: response.status }, response.status >= 500 ? 502 : response.status);
}
return json({ feedback: result.feedback || result.data || result });
}
function isUuidv7(value) {
return typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
}