diff --git a/assets/styles.css b/assets/styles.css index b6c7f94..4a2377b 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -294,6 +294,9 @@ button { cursor: pointer; } .comment { padding: 19px 10px; border-bottom: 1px solid var(--line); } .comment-meta { display: flex; justify-content: space-between; margin-bottom: 7px; color: #777; font-size: 13px; } .comment-content { white-space: pre-wrap; } +.comment-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; } +.comment-actions .button { min-height: 32px; padding: 0 12px; font-size: 12px; } +.comment-edit-input { display: block; width: 100%; min-height: 96px; margin-top: 8px; } .comment-empty { padding: 32px; border-bottom: 1px solid var(--line); color: #999; text-align: center; } .comment-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: 16px; padding: 20px 10px 0; } .comment-form .textarea { min-height: 120px; } diff --git a/egbim/app.js b/egbim/app.js index facd64e..918d312 100644 --- a/egbim/app.js +++ b/egbim/app.js @@ -417,7 +417,7 @@ async function requestJson(path, options) { const requestPath = String(path).split("?")[0]; - const sameOriginProxy = !config.apiBaseUrl && (requestPath === config.createFeedbackPath || requestPath === "/api/feedbacks" || /^\/api\/feedbacks\/[^\/]+(?:\/comments|\/completion-confirmation)?$/.test(requestPath)); + const sameOriginProxy = !config.apiBaseUrl && (requestPath === config.createFeedbackPath || requestPath === "/api/feedbacks" || /^\/api\/feedbacks\/[^\/]+(?:\/comments(?:\/[^\/]+)?|\/completion-confirmation)?$/.test(requestPath)); 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)); @@ -651,7 +651,7 @@ renderCompletionNotice(); }; ownerActions(); - if (authReady) authReady.then(function (user) { detailUser = user || detailUser; ownerActions(); renderCompletionNotice(); }); + if (authReady) authReady.then(function (user) { detailUser = user || detailUser; ownerActions(); renderCompletionNotice(); comments = renderComments(comments); }); if (completionButton) completionButton.addEventListener("click", async function () { if (!post.completionRequested || post.completionConfirmed) return; completionButton.disabled = true; @@ -762,6 +762,7 @@ const rawAttachments = comment.attachments || comment.images || comment.comment_attachments || comment.commentAttachments || []; return { id: comment.id || '', + authorId: comment.author_id || comment.authorId || comment.requester_id || comment.requesterId || '', author: comment.author_name || comment.authorName || comment.author?.name || comment.author || '작성자', date: formatKoreanDateTime(comment.created_at || comment.createdAt || comment.updated_at || '', true), content: comment.content || comment.body || '', @@ -778,10 +779,27 @@ }).join(''); } + let editingCommentId = ''; + function isCommentOwner(comment, user) { + const authorId = String(comment.authorId || '').trim().toLowerCase(); + const userIds = [user && user.requesterId, user && user.ssoSubject, user && user.userUuid].map(function (id) { return String(id || '').trim().toLowerCase(); }).filter(Boolean); + return Boolean(authorId && userIds.indexOf(authorId) !== -1); + } + 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 '
' + escapeHtml(comment.author) + '' + escapeHtml(comment.date) + '
' + escapeHtml(comment.content) + '
' + (comment.attachments.length ? '
' + renderCommentAttachments(comment.attachments) + '
' : '') + '
'; }).join('') : '
등록된 답변이 없습니다.
'; + const user = detailUser || getAuthUser(); + qs('#comments').innerHTML = comments.length ? comments.map(function (comment) { + const owner = isCommentOwner(comment, user); + const editing = editingCommentId === comment.id && owner; + const actions = owner ? '
' : ''; + const content = editing ? '
' : '
' + escapeHtml(comment.content) + '
'; + return '
' + escapeHtml(comment.author) + '' + escapeHtml(comment.date) + '
' + content + (comment.attachments.length ? '
' + renderCommentAttachments(comment.attachments) + '
' : '') + (editing ? '' : actions) + '
'; + }).join('') : '
등록된 답변이 없습니다.
'; + const storedComments = getComments(); + storedComments[post.id] = comments; + localStorage.setItem(COMMENTS_KEY, JSON.stringify(storedComments)); return comments; } let comments = renderComments(getComments()[post.id] || []); @@ -791,6 +809,48 @@ }).catch(function (error) { console.warn('[QA] comments load failed:', error.message); }); + qs('#comments').addEventListener('click', async function (event) { + const editButton = event.target.closest('[data-comment-edit]'); + const cancelButton = event.target.closest('[data-comment-cancel]'); + const saveButton = event.target.closest('[data-comment-save]'); + const deleteButton = event.target.closest('[data-comment-delete]'); + if (editButton) { + editingCommentId = editButton.getAttribute('data-comment-edit'); + comments = renderComments(comments); + const input = qs('[data-comment-edit-input]'); + if (input) { input.focus(); input.setSelectionRange(input.value.length, input.value.length); } + return; + } + if (cancelButton) { editingCommentId = ''; comments = renderComments(comments); return; } + if (saveButton) { + const commentId = saveButton.getAttribute('data-comment-save'); + const comment = comments.find(function (item) { return item.id === commentId; }); + const input = qs('[data-comment-edit-input]'); + const content = input ? input.value.trim() : ''; + if (!content) { showToast('댓글 내용을 입력해주세요.'); return; } + saveButton.disabled = true; + try { + const result = await requestJson(commentsPath + '/' + encodeURIComponent(commentId), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: content }) }); + const updatedComment = normalizeComment(Object.assign({}, comment, result.comment || result, { content: content })); + comments = comments.map(function (item) { return item.id === commentId ? updatedComment : item; }); + editingCommentId = ''; + renderComments(comments); + showToast('댓글이 수정되었습니다.'); + } catch (error) { showToast(error.message || '댓글 수정에 실패했습니다.'); saveButton.disabled = false; } + return; + } + if (deleteButton) { + const commentId = deleteButton.getAttribute('data-comment-delete'); + if (!window.confirm('이 댓글을 삭제하시겠습니까?')) return; + deleteButton.disabled = true; + try { + await requestJson(commentsPath + '/' + encodeURIComponent(commentId), { method: 'DELETE' }); + comments = comments.filter(function (item) { return item.id !== commentId; }); + renderComments(comments); + showToast('댓글이 삭제되었습니다.'); + } catch (error) { showToast(error.message || '댓글 삭제에 실패했습니다.'); deleteButton.disabled = false; } + } + }); const commentForm = qs('#commentForm'); if (commentForm) commentForm.addEventListener('submit', async function (event) { event.preventDefault(); @@ -816,6 +876,7 @@ 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.authorId) savedComment.authorId = (getAuthUser() || {}).requesterId || ''; if (!savedComment.attachments.length) savedComment.attachments = localAttachments; comments.push(savedComment); renderComments(comments); diff --git a/src/index.js b/src/index.js index 3a2d702..e936e49 100644 --- a/src/index.js +++ b/src/index.js @@ -15,6 +15,7 @@ export default { return createFeedback(request, env); } if (/^\/api\/feedbacks\/[^/]+\/completion-confirmation$/.test(url.pathname)) return completionConfirmation(request, env, url); + if (/^\/api\/feedbacks\/[^/]+\/comments\/[^/]+$/.test(url.pathname)) return feedbackCommentItem(request, env, url); if (/^\/api\/feedbacks\/[^/]+\/comments$/.test(url.pathname)) return feedbackComments(request, env, url); if (/^\/api\/feedbacks\/[^/]+$/.test(url.pathname)) return feedbackDetail(request, env, url); @@ -626,6 +627,62 @@ 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 feedbackCommentItem(request, env, url) { + if (request.method !== 'PATCH' && request.method !== 'DELETE') return json({ error: 'method_not_allowed' }, 405, { allow: 'PATCH, DELETE' }); + 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 pathParts = url.pathname.split('/'); + const feedbackId = decodeURIComponent(pathParts[3] || ''); + const commentId = decodeURIComponent(pathParts[5] || ''); + if (!isUuidv7(feedbackId)) return json({ error: 'invalid_feedback_id' }, 400); + if (!isUuidv7(commentId)) return json({ error: 'invalid_comment_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)}/comments/${encodeURIComponent(commentId)}`; + const headers = { + accept: 'application/json', + 'x-api-key': env.ABC_API_KEY, + 'x-requester-id': requesterId, + 'x-requester-tenant-id': requesterTenantId + }; + let body; + if (request.method === 'PATCH') { + let input; + try { + input = await request.json(); + } catch (error) { + return json({ error: '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); + headers['content-type'] = 'application/json'; + body = JSON.stringify({ content }); + } + + let response; + try { + response = await fetch(endpoint, { method: request.method, headers, body }); + } catch (error) { + console.error('feedback_comment_item_api_request_failed', error instanceof Error ? error.message : 'unknown'); + return json({ error: 'feedback_comments_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_comments_api_rejected', message: upstreamMessage || '댓글 수정 또는 삭제에 실패했습니다.', status: response.status }, response.status >= 500 ? 502 : response.status); + } + if (request.method === 'DELETE') return json({ ok: true, id: commentId }); + return json({ comment: result.comment || result.data || result }); +} + async function feedbackDetail(request, env, url) { if (!['GET', 'PATCH', 'DELETE'].includes(request.method)) return json({ error: 'method_not_allowed' }, 405, { allow: 'GET, PATCH, DELETE' }); const user = await getSession(request, env);