Compare commits

..
2 Commits
Author SHA1 Message Date
root dbf5dc3611 fix: show post and comment actions only to the verified author
Deploy EG-BIM QA Gateway / deploy (push) Successful in 40s
2026-10-30 15:58:41 +09:00
root 688d16555e feat: add typo-tolerant and Korean initial search to Q&A list 2026-10-30 13:19:36 +09:00
9 changed files with 411 additions and 45 deletions

No files matched your search

+6
View File
@@ -33,6 +33,12 @@ python3 -m http.server 4173
상세 페이지에는 공개 댓글 입력 UI가 있으며, 댓글은 같은 Worker를 통해 ABC의 `feedback_comments`에 저장하고 `is_internal=false`인 공개 댓글만 조회합니다. 댓글 이미지가 있으면 `attachments` multipart 필드로 Worker가 ABC에 전달하며, 응답으로 받은 이미지 URL을 썸네일로 표시합니다. 목록·상세 진입 시에는 ABC의 `feedback_status`를 다시 조회해 관리페이지에서 변경된 상태를 반영합니다.
게시글과 댓글의 수정·삭제 버튼은 로그인 사용자의 기본 requester UUID와 작성자 ID가 일치할 때만 표시합니다. 이름·이메일이 같다는 이유로 표시하지 않으며, 작성자 ID가 없거나 로그인 확인 중·만료·조회 실패 상태이면 숨깁니다. 다른 사람의 게시글에서도 본인이 작성한 댓글의 버튼은 표시합니다. 공통 버튼 CSS는 `hidden` 속성을 덮어쓰지 않습니다.
목록 검색은 제목·내용·회사·부서·작성자를 대상으로 오타, 띄어쓰기·대소문자 차이, 두 글자 이상의 한글 초성을 처리합니다. 예를 들어 `로그임`, `ㄹㄱㅇ`으로 `로그인`을, `파일첨부`로 `파일 첨부`를 찾을 수 있습니다. 검색 버튼이나 Enter로 실행하며, 여러 단어를 입력하면 각 단어가 모두 일치해야 합니다. 정확한 제목 일치를 우선하고 같은 점수는 기존 목록 순서를 유지합니다. 분류와 `내가 작성한 글` 조건도 함께 적용합니다.
너무 짧은 검색어의 과도한 일치를 줄이기 위해 1~2글자는 정확한 포함 검색을 사용합니다. 오타는 3~5글자에 1회, 6~32글자에 최대 2회의 삽입·삭제·치환·인접 문자 교환을 허용합니다. 긴 단어는 정확한 포함 검색을 사용합니다. 목록은 API가 반환하는 페이지를 순서대로 수집하므로 첫 100건 이후의 글도 검색합니다. 로딩 중과 일부 페이지 조회 실패는 화면에 표시하며, 조회하지 못한 글까지 검색했다고 안내하지 않습니다.
## 연동 지점
- SSO: Q&A는 홈페이지 로그인 정보를 전달받지 않고, `https://sso.hmac.kr/oidc`에 등록된 독립적인 PKCE Public Client로 직접 인증합니다. callback은 `https://qa-test.baroncs.co.kr/auth/callback`이며, Worker가 검증한 claim을 버전이 지정된 HttpOnly 세션 쿠키에 저장합니다. 브라우저의 `GET /auth/session`은 Q&A 세션에 저장된 작성자 정보를 반환합니다. feedback 서버는 반드시 SSO 세션 또는 토큰을 서버 측에서 검증해야 합니다.
+3 -1
View File
@@ -210,6 +210,7 @@ button { cursor: pointer; }
.search-form input { width: 100%; min-width: 0; height: 40px; padding: 0 13px; border: 1px solid #d2d2d2; background: #fff; outline: 0; }
.search-form input:focus { border-color: var(--navy); }
.button { display: inline-flex; align-items: center; justify-content: center; height: 42px; padding: 0 21px; border: 1px solid transparent; border-radius: 3px; font-weight: 500; white-space: nowrap; }
.button[hidden] { display: none !important; }
.button-primary { background: var(--navy); color: #fff; }
.button-primary:hover { background: #0e315f; }
.button-outline { border-color: #bfc5cb; background: #fff; color: #555; }
@@ -238,10 +239,11 @@ button { cursor: pointer; }
.table-actions { display: flex; align-items: center; justify-content: space-between; padding-top: 23px; }
.result-count { color: var(--muted); font-size: 13px; }
.pagination { display: flex; justify-content: center; gap: 5px; margin: 38px 0 0; }
.pagination { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 5px; margin: 38px 0 0; }
.pagination button { width: 32px; height: 32px; border: 0; background: none; color: #555; }
.pagination button:hover { color: var(--navy); }
.pagination button.active { border-radius: 50%; background: var(--navy); color: #fff; }
.pagination button:disabled { color: #bbb; cursor: default; }
.form-card, .detail-card { border-top: 2px solid #242424; }
.form-row { display: grid; grid-template-columns: 145px 1fr; min-height: 68px; border-bottom: 1px solid var(--line); }
+151 -33
View File
@@ -137,10 +137,114 @@
};
}
async function loadRemotePosts() {
const result = await requestJson("/api/feedbacks?limit=100&page=1", { method: "GET" });
const items = Array.isArray(result) ? result : (result.items || result.data && result.data.items || []);
return { posts: (Array.isArray(items) ? items : []).map(normalizeRemotePost).filter(function (post) { return post.id; }), meta: result.meta || result.data && result.data.meta || {} };
async function loadRemotePosts(onPage) {
const posts = [];
const seen = new Set();
let meta = {};
for (let page = 1; ; page += 1) {
const result = await requestJson('/api/feedbacks?limit=100&page=' + page, { method: 'GET' });
const items = Array.isArray(result) ? result : (result.items || result.data && result.data.items || []);
if (!Array.isArray(items)) throw new Error('문의 목록 응답을 확인할 수 없습니다.');
const previousCount = posts.length;
items.map(normalizeRemotePost).forEach(function (post) {
if (post.id && !seen.has(post.id)) { seen.add(post.id); posts.push(post); }
});
if (page > 1 && items.length && posts.length === previousCount) throw new Error('문의 목록의 다음 페이지를 불러오지 못했습니다.');
meta = result.meta || result.data && result.data.meta || {};
const totalPages = Number(meta.totalPages ?? meta.total_pages);
const total = Number(meta.total ?? meta.totalCount ?? meta.total_count);
if (!items.length && (Number.isFinite(total) && posts.length < total || Number.isFinite(totalPages) && page < totalPages)) {
throw new Error('문의 목록을 끝까지 불러오지 못했습니다.');
}
const hasMore = items.length > 0 && (Number.isFinite(totalPages) ? page < totalPages : Number.isFinite(total) ? posts.length < total : items.length === 100);
if (onPage) onPage({ posts: posts.slice(), meta: meta, complete: !hasMore });
if (!hasMore) return { posts: posts, meta: meta };
}
}
const HANGUL_INITIALS = 'ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ';
const searchIndex = new WeakMap();
function normalizeSearchText(value) {
return String(value || '').normalize('NFKC').toLowerCase()
.replace(/[\u1100-\u1112]/g, function (char) { return HANGUL_INITIALS[char.charCodeAt(0) - 0x1100]; })
.replace(/[\p{P}\p{S}]+/gu, ' ').replace(/\s+/g, ' ').trim();
}
function hangulInitials(value) {
return Array.from(value).map(function (char) {
const code = char.charCodeAt(0) - 0xac00;
return code >= 0 && code <= 11171 ? HANGUL_INITIALS[Math.floor(code / 588)] : char;
}).join('');
}
// Find a near substring, allowing insertions, deletions, substitutions and adjacent swaps.
function fuzzySubstringDistance(query, text, limit) {
const cap = limit + 1;
const characters = Array.from({ length: query.length }, function (_, index) { return query.charCodeAt(index); });
let previous = new Uint8Array(query.length + 1);
let beforePrevious = new Uint8Array(query.length + 1);
let current = new Uint8Array(query.length + 1);
for (let j = 0; j <= query.length; j += 1) previous[j] = Math.min(j, cap);
let best = limit + 1;
for (let i = 0; i < text.length; i += 1) {
const character = text.charCodeAt(i);
const preceding = text.charCodeAt(i - 1);
current[0] = 0;
for (let j = 1; j <= query.length; j += 1) {
let distance = previous[j - 1] + (character === characters[j - 1] ? 0 : 1);
if (previous[j] + 1 < distance) distance = previous[j] + 1;
if (current[j - 1] + 1 < distance) distance = current[j - 1] + 1;
if (i > 0 && j > 1 && character === characters[j - 2] && preceding === characters[j - 1] && beforePrevious[j - 2] + 1 < distance) {
distance = beforePrevious[j - 2] + 1;
}
current[j] = distance > cap ? cap : distance;
}
if (current[query.length] < best) best = current[query.length];
// Exact substrings are checked by the caller; one edit is the best remaining match.
if (best <= 1) return best;
const reusable = beforePrevious;
beforePrevious = previous;
previous = current;
current = reusable;
}
return best;
}
function searchPosts(posts, query) {
const terms = normalizeSearchText(query).split(' ').filter(Boolean);
if (!terms.length) return posts.slice();
return posts.map(function (post, index) {
let fields = searchIndex.get(post);
if (!fields) {
fields = [post.title, post.content, getPostCompanyLabel(post), post.department, post.author].map(function (value, field) {
const text = normalizeSearchText(value).replace(/ /g, '');
return { text: text, initials: hangulInitials(text), characters: new Set(text), weight: field === 0 ? 0 : field === 1 ? 3 : 2 };
});
searchIndex.set(post, fields);
}
let score = 0;
for (const term of terms) {
let best = Infinity;
const initialsOnly = /^[ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ]{2,}$/.test(term);
const limit = term.length >= 6 ? 2 : term.length >= 3 ? 1 : 0;
for (const field of fields) {
if (field.text.includes(term)) best = Math.min(best, field.weight);
else if (initialsOnly && field.initials.includes(term)) best = Math.min(best, 1 + field.weight);
else if (!initialsOnly && limit && term.length <= 32 && field.text) {
if (Array.from(term).filter(function (char) { return !field.characters.has(char); }).length > limit) continue;
const distance = fuzzySubstringDistance(term, field.text, limit);
if (distance <= limit) best = Math.min(best, 10 * distance + field.weight);
}
if (best === 0) break;
}
if (!Number.isFinite(best)) return { post: post, index: index, score: Infinity };
score += best;
}
return { post: post, index: index, score: score };
}).filter(function (match) { return Number.isFinite(match.score); })
.sort(function (a, b) { return a.score - b.score || a.index - b.index; })
.map(function (match) { return match.post; });
}
function savePost(post) {
@@ -162,13 +266,16 @@
localStorage.setItem(STORAGE_KEY, JSON.stringify(posts));
}
function isPostOwner(post, user) {
if (!post || !user) return false;
function matchesAuthorId(authorId, user) {
if (!user) return false;
const normalizeId = function (id) { return String(id || '').trim().toLowerCase(); };
const postIds = [post.requesterId, post.requesterUuid].map(normalizeId).filter(Boolean);
const userIds = [user.requesterId, user.ssoSubject, user.userUuid].map(normalizeId).filter(Boolean);
if (postIds.length && userIds.length) return postIds.some(function (id) { return userIds.indexOf(id) > -1; });
return Boolean(post.author && [user.name, user.loginId, user.email].filter(Boolean).indexOf(post.author) > -1);
const ownerId = normalizeId(authorId);
const userId = normalizeId(user.requesterId || user.ssoSubject || user.userUuid);
return Boolean(ownerId && userId && ownerId === userId);
}
function isPostOwner(post, user) {
return Boolean(post) && matchesAuthorId(post.requesterId || post.requesterUuid, user);
}
function normalizeStatus(value) {
@@ -301,15 +408,15 @@
if (tenantId && tenantIds.indexOf(tenantId) === -1) tenantIds.unshift(tenantId);
const primaryTenant = tenantList.find(function (tenant) { return typeof tenant !== 'string' && (tenant.id || tenant.tenantId) === tenantId; }) || tenantList[0] || {};
const tenantName = raw.tenantName || raw.tenant_name || custom.tenantName || custom.tenant_name || claims.tenantName || claims.tenant_name || getTenantName(primaryTenant);
const ssoSubject = raw.ssoSubject || raw.oauthSubject || raw.subject || claims.sub || claims.subject || raw.userId || sessionStorage.getItem('descopeUserId') || '';
const userUuid = raw.userUuid || raw.userId || claims.userId || claims.user_id || sessionStorage.getItem('descopeUserId') || '';
const ssoSubject = raw.ssoSubject || raw.oauthSubject || raw.subject || claims.sub || claims.subject || raw.requesterId || raw.userUuid || raw.userId || sessionStorage.getItem('descopeUserId') || '';
const userUuid = raw.userUuid || raw.userId || claims.userId || claims.user_id || ssoSubject || sessionStorage.getItem('descopeUserId') || '';
const loginId = raw.loginId || (raw.loginIds || [])[0] || raw.email || claims.email || claimProfile.email || sessionStorage.getItem('loginId') || readCookie('descope_login_id') || '';
const email = raw.email || claims.email || claimProfile.email || sessionStorage.getItem('loginId') || '';
const company = raw.company || custom.company || claims.company || sessionStorage.getItem('company') || getCompanyNameFromEmail(email) || '';
return {
userUuid: userUuid,
ssoSubject: ssoSubject,
requesterId: ssoSubject,
requesterId: raw.requesterId || ssoSubject,
tenantId: tenantId,
requesterTenantId: tenantId,
tenantIds: tenantIds,
@@ -359,7 +466,12 @@
const response = await fetch(config.ssoSessionEndpoint, { credentials: 'include', headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error('SSO 세션 확인에 실패했습니다.');
const body = await response.json();
const remoteUser = normalizeAuthUser(body.user || body, body.claims || body.scope || {});
const rawUser = body.user || body;
if (body.authenticated === false || !rawUser || !(rawUser.requesterId || rawUser.ssoSubject || rawUser.userUuid || rawUser.userId || rawUser.subject)) {
resolvedAuthUser = null;
return null;
}
const remoteUser = normalizeAuthUser(rawUser, body.claims || body.scope || {});
if (remoteUser.loginId || remoteUser.ssoSubject) {
resolvedAuthUser = remoteUser;
return remoteUser;
@@ -367,8 +479,8 @@
} catch (error) {
console.warn('[QA] SSO session bridge unavailable:', error.message);
}
resolvedAuthUser = localUser;
return localUser;
resolvedAuthUser = null;
return null;
}
function renderAuth(user) {
@@ -475,6 +587,9 @@
let page = 1;
let posts = getPosts();
let remoteLoaded = false;
let loadFailed = false;
let receivedRemotePage = false;
const searchStatus = qs('#searchStatus');
function getPostCompany(post, user) { return getPostCompanyLabel(post, user); }
@@ -484,19 +599,20 @@
const categoryFilter = selectedCategory ? selectedCategory.value : "전체";
const onlyMine = qs("#onlyMine").checked;
const user = getAuthUser();
const filtered = posts.filter(function (post) {
const filtered = searchPosts(posts.filter(function (post) {
const matchesCategory = categoryFilter === "전체" || categoryFilter === post.category;
const company = getPostCompany(post, user);
const haystack = [post.title, post.content, company, post.department, post.author].join(" ").toLowerCase();
const matchesQuery = !query || haystack.indexOf(query) > -1;
const matchesMine = !onlyMine || isPostOwner(post, user);
return matchesCategory && matchesQuery && matchesMine;
});
return matchesCategory && matchesMine;
}), query);
const pageSize = config.pageSize || 10;
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
page = Math.min(page, totalPages);
const rows = filtered.slice((page - 1) * pageSize, page * pageSize);
if (resultCount) resultCount.textContent = "총 " + filtered.length + "건";
if (searchStatus) {
searchStatus.hidden = remoteLoaded && !loadFailed && !query;
searchStatus.textContent = loadFailed ? '목록을 모두 불러오지 못했습니다. 현재 불러온 글에서 검색합니다. 새로고침해 주세요.' : !remoteLoaded ? '문의 목록을 불러오는 중입니다. 검색 결과가 추가될 수 있습니다.' : '검색 결과 ' + filtered.length + '건';
}
if (empty) {
empty.textContent = remoteLoaded || posts.length ? "검색 조건에 맞는 문의가 없습니다." : "문의 목록을 불러오는 중입니다.";
empty.style.display = rows.length ? "none" : "block";
@@ -514,7 +630,11 @@
"<td>" + escapeHtml(post.dateTime || 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("");
const visiblePages = Array.from(new Set([1, totalPages, page - 2, page - 1, page, page + 1, page + 2])).filter(function (value) { return value >= 1 && value <= totalPages; }).sort(function (a, b) { return a - b; });
pagination.innerHTML = '<button type="button" data-page="' + Math.max(1, page - 1) + '" aria-label="이전 페이지"' + (page === 1 ? ' disabled' : '') + '>‹</button>' + visiblePages.map(function (value, index) {
const gap = index > 0 && value - visiblePages[index - 1] > 1 ? '<span aria-hidden="true">…</span>' : '';
return gap + '<button type="button" class="' + (value === page ? 'active' : '') + '" data-page="' + value + '"' + (value === page ? ' aria-current="page"' : '') + '>' + value + '</button>';
}).join('') + '<button type="button" data-page="' + Math.min(totalPages, page + 1) + '" aria-label="다음 페이지"' + (page === totalPages ? ' disabled' : '') + '>›</button>';
qsa("#pagination button").forEach(function (button) { button.addEventListener("click", function () { page = Number(button.dataset.page); render(); window.scrollTo({ top: 360, behavior: "smooth" }); }); });
}
@@ -522,15 +642,16 @@
qsa("input[name=category], #onlyMine").forEach(function (input) { input.addEventListener("change", function () { page = 1; render(); }); });
render();
if (authReady) authReady.then(render);
loadRemotePosts().then(function (result) {
loadRemotePosts(function (result) { receivedRemotePage = true; posts = result.posts; render(); }).then(function (result) {
posts = result.posts;
remoteLoaded = true;
render();
}).catch(function (error) {
console.warn("[QA] feedback list load failed:", error.message);
remoteLoaded = true;
loadFailed = true;
render();
refreshRemoteStatuses(posts).then(function (changed) { if (changed) render(); });
if (!receivedRemotePage) refreshRemoteStatuses(posts).then(function (changed) { if (changed) render(); });
});
}
@@ -614,7 +735,7 @@
renderDetailStatus();
qs('[data-detail-date]').textContent = post.dateTime || post.date;
qs('[data-detail-author]').textContent = post.author;
let detailUser = getAuthUser();
const detailUser = await (authReady || Promise.resolve(getAuthUser()));
qs('[data-detail-company]').textContent = getPostCompanyLabel(post, detailUser);
qs('[data-detail-department]').textContent = post.department;
qs('[data-detail-content]').textContent = post.content;
@@ -634,20 +755,19 @@
const completionDoneNotice = qs("#completionDoneNotice");
const completionButton = qs("#completionButton");
const renderCompletionNotice = function () {
const owner = isPostOwner(post, detailUser || getAuthUser());
const owner = isPostOwner(post, detailUser);
const completed = normalizeStatus(post.status) === "완료" || post.completionConfirmed;
const visible = owner && !completed && post.completionRequested;
if (completionNotice) completionNotice.hidden = !visible;
if (completionDoneNotice) completionDoneNotice.hidden = !(owner && completed);
};
const ownerActions = function () {
const owner = isPostOwner(post, detailUser || getAuthUser());
const owner = isPostOwner(post, detailUser);
if (editButton) editButton.hidden = !owner;
if (deleteButton) deleteButton.hidden = !owner;
renderCompletionNotice();
};
ownerActions();
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;
@@ -777,15 +897,13 @@
let editingCommentId = null;
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);
return matchesAuthorId(comment.authorId, user);
}
function renderComments(comments) {
comments = (comments || []).map(normalizeComment).filter(function (comment) { return comment.content; });
qs('#commentCount').textContent = comments.length;
const user = detailUser || getAuthUser();
const user = detailUser;
qs('#comments').innerHTML = comments.length ? comments.map(function (comment) {
const owner = Boolean(comment.id) && isCommentOwner(comment, user);
const editing = Boolean(editingCommentId) && editingCommentId === comment.id && owner;
+2 -2
View File
@@ -1,8 +1,8 @@
<!doctype html>
<html lang="ko">
<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=20260929-3"></head>
<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=20261030-owner-1"></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 id="completionNotice" class="completion-notice" hidden><div><strong>처리 완료 안내</strong><p>담당자가 처리를 완료했습니다. 내용을 확인하셨다면 완료 확인을 눌러주세요.</p></div><button class="button button-primary" id="completionButton" type="button">완료 확인</button></div><div id="completionDoneNotice" class="completion-done-notice" hidden>완료 처리 된 요청 건입니다.</div><div class="detail-actions"><div class="owner-actions"><button class="button button-outline" id="editButton" type="button" hidden>수정</button><button class="button button-danger" id="deleteButton" type="button" hidden>삭제</button></div><a class="button button-outline" href="index.html">목록</a></div></article><form id="editForm" class="edit-form" hidden><h3>문의 수정</h3><div class="form-row"><label class="form-label" for="editCategory">구분</label><div class="form-control"><select id="editCategory" class="select"><option value="오류문의">오류문의</option><option value="개선문의">개선문의</option><option value="일반문의">일반문의</option></select></div></div><div class="form-row"><label class="form-label" for="editTitle">제목</label><div class="form-control"><input id="editTitle" class="input" maxlength="200" required></div></div><div class="form-row"><label class="form-label" for="editContent">내용</label><div class="form-control column"><textarea id="editContent" class="textarea" maxlength="10000" required></textarea></div></div><div class="form-row"><div class="form-label">설정</div><div class="form-control"><label class="check"><input id="editSecret" type="checkbox"> 비밀글</label></div></div><div class="form-footer"><button class="button button-outline" id="editCancel" type="button">취소</button><button class="button button-primary" type="submit">수정 저장</button></div><div id="editError" class="form-error" role="alert" hidden></div></form><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 class="button button-primary comment-image-button" for="commentImages">이미지 첨부</label><input id="commentImages" type="file" accept="image/*" multiple><div id="commentImagePreview" class="comment-image-preview"></div></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=20261027-org-1"></script>
</main><script src="config.js?v=20260921-3"></script><script src="app.js?v=20261030-owner-1"></script>
</body></html>
+4 -3
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="EG-BIM Q&A 문의 게시판">
<title>EG-BIM Q&A | 바론컨설턴트</title>
<link rel="stylesheet" href="../assets/styles.css?v=20260929-3">
<link rel="stylesheet" href="../assets/styles.css?v=20261030-owner-1">
</head>
<body>
<main class="qa-list-page">
@@ -16,13 +16,14 @@
<section class="page-shell">
<div class="qa-list-heading"><h1>문의하기(Q&amp;A)</h1></div>
<form class="filter-panel" id="searchForm">
<div class="filter-row"><div class="chip-group"><label class="chip"><input type="radio" name="category" value="전체" checked>전체</label><label class="chip"><input type="radio" name="category" value="일반문의">일반</label><label class="chip"><input type="radio" name="category" value="개선문의">개선</label><label class="chip"><input type="radio" name="category" value="오류문의">오류</label></div><label class="check"><input type="checkbox" id="onlyMine">내가 작성한 글</label><span class="filter-spacer"></span><div class="search-form"><input id="query" type="search" placeholder="제목+내용 검색어를 입력하세요" aria-label="게시글 검색"><button class="button search-button" type="submit"><span aria-hidden="true">⌕</span> 검색</button></div></div>
<div class="filter-row"><div class="chip-group"><label class="chip"><input type="radio" name="category" value="전체" checked>전체</label><label class="chip"><input type="radio" name="category" value="일반문의">일반</label><label class="chip"><input type="radio" name="category" value="개선문의">개선</label><label class="chip"><input type="radio" name="category" value="오류문의">오류</label></div><label class="check"><input type="checkbox" id="onlyMine">내가 작성한 글</label><span class="filter-spacer"></span><div class="search-form"><input id="query" type="search" maxlength="120" placeholder="검색어 입력 (오타·초성 지원)" aria-label="제목·내용·회사·부서·작성자 검색 (오타·초성 지원)"><button class="button search-button" type="submit"><span aria-hidden="true">⌕</span> 검색</button></div></div>
</form>
<p id="searchStatus" class="help-text" role="status" aria-live="polite" hidden></p>
<div class="table-wrap"><table class="qa-table"><caption class="sr-only">EG-BIM 문의 목록</caption><colgroup><col style="width:6%"><col style="width:8%"><col style="width:13%"><col style="width:11%"><col style="width:7%"><col style="width:38%"><col style="width:11%"><col style="width:6%"></colgroup><thead><tr><th>번호</th><th>구분</th><th>회사</th><th>부서</th><th>작성자</th><th>제목</th><th>등록일</th><th>상태</th></tr></thead><tbody id="qaRows"></tbody></table><div class="table-empty" id="tableEmpty">검색 조건에 맞는 문의가 없습니다.</div></div>
<div class="list-footer"><div class="pagination" id="pagination" aria-label="페이지 이동"></div><a class="button button-primary" href="write.html">문의등록</a></div>
</section>
</main>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="config.js?v=20260921-3"></script><script src="app.js?v=20261027-org-1"></script>
<script src="config.js?v=20260921-3"></script><script src="app.js?v=20261030-owner-1"></script>
</body>
</html>
+2 -2
View File
@@ -1,8 +1,8 @@
<!doctype html>
<html lang="ko">
<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=20260929-3"></head>
<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=20261030-owner-1"></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"><div class="section-heading"><div><h2>문의 등록</h2><p>로그인한 사용자 정보로 문의가 등록됩니다.</p></div></div><div class="form-notice" data-auth-state>로그인 상태를 확인하고 있습니다.</div><div class="form-error" id="formError" role="alert"></div><form id="qaForm" novalidate><div class="form-card" id="writeFields"><div class="form-row"><label class="form-label" for="category">구분 <span class="required">*</span></label><div class="form-control"><select class="select" id="category" required><option value="">선택해주세요</option><option>오류문의</option><option>개선문의</option><option>일반문의</option></select></div></div><div class="form-row"><label class="form-label" for="title">제목 <span class="required">*</span></label><div class="form-control"><input class="input" id="title" type="text" maxlength="100" placeholder="문의 제목을 입력해주세요" required></div></div><div class="form-row"><span class="form-label">공개 설정</span><div class="form-control"><label class="check"><input id="secret" type="checkbox"> 비밀글로 등록</label></div></div><div class="form-row"><label class="form-label" for="content">내용 <span class="required">*</span></label><div class="form-control column"><textarea class="textarea" id="content" placeholder="문의 내용을 작성해주세요" required></textarea><span class="help-text">개인정보, 비밀번호, 인증번호 등 민감한 정보는 입력하지 마세요.</span></div></div><div class="form-row"><label class="form-label" for="attachment">첨부파일</label><div class="form-control column"><input class="file-input" id="attachment" type="file" multiple><span class="help-text">파일은 문의와 함께 안전하게 업로드됩니다. 파일당 최대 30MB</span></div></div></div><div class="form-footer"><a class="button button-outline" href="index.html">취소</a><button class="button button-primary" id="submitButton" type="submit">문의 등록</button></div></form></section>
</main><div id="toast" class="toast" role="status" aria-live="polite"></div><script src="config.js?v=20260921-3"></script><script src="app.js?v=20261027-org-1"></script>
</main><div id="toast" class="toast" role="status" aria-live="polite"></div><script src="config.js?v=20260921-3"></script><script src="app.js?v=20261030-owner-1"></script>
</body></html>
+34 -4
View File
@@ -21,13 +21,13 @@ function element() {
};
}
async function detailPage({ comments = [], cachedComments = [], createResponse = {}, patchResponse = {}, authRefresh = false } = {}) {
async function detailPage({ comments = [], cachedComments = [], createResponse = {}, patchResponse = {}, authRefresh = false, user = USER, postOwner = USER.requesterId } = {}) {
const elements = new Map([
'#detail', '[data-detail-category]', '[data-detail-title]', '[data-detail-status]',
'[data-detail-date]', '[data-detail-author]', '[data-detail-company]',
'[data-detail-department]', '[data-detail-content]', '[data-detail-secret]',
'#comments', '#commentCount', '#commentForm', '#commentContent',
'#commentError', '#commentSubmit', '[data-comment-edit-input]'
'#commentError', '#commentSubmit', '[data-comment-edit-input]', '#editButton', '#deleteButton'
].map((selector) => [selector, element()]));
const storage = new Map([[COMMENTS_KEY, JSON.stringify({ [POST]: cachedComments })]]);
const requests = [];
@@ -45,7 +45,7 @@ async function detailPage({ comments = [], cachedComments = [], createResponse =
else if (method === 'PATCH') body = patchResponse;
else if (method === 'DELETE') body = { ok: true };
else if (path.endsWith('/comments')) body = { comments };
else body = { id: POST, title: '문의 제목', contents: '문의 내용', requester_id: USER.requesterId };
else body = { id: POST, title: '문의 제목', contents: '문의 내용', requester_id: postOwner, requester_name: USER.name };
return new Response(JSON.stringify(body));
}
});
@@ -54,7 +54,7 @@ async function detailPage({ comments = [], cachedComments = [], createResponse =
resolvedAuthUser = user;
if (refresh) authReady = Promise.resolve(user);
} }; })();`), context);
context.testPage.setUser(USER, authRefresh);
context.testPage.setUser(user, authRefresh);
await context.testPage.initDetail();
await new Promise((resolve) => setImmediate(resolve));
return {
@@ -141,3 +141,33 @@ test('a cached comment without an ID remains read-only when loading the page', a
assert.doesNotMatch(page.html(), /data-comment-edit|data-comment-delete|data-comment-save/);
assert.equal(page.requests.some((request) => ['PATCH', 'DELETE'].includes(request.method)), false);
});
test('another author post hides both post buttons while the viewer own comment keeps its actions', async () => {
const page = await detailPage({ postOwner: 'another-user', comments: [comment(), comment({ id: SECOND, author_id: 'another-user' })] });
assert.equal(page.elements.get('#editButton').hidden, true);
assert.equal(page.elements.get('#deleteButton').hidden, true);
assert.match(page.html(), new RegExp('data-comment-edit="' + FIRST + '"'));
assert.doesNotMatch(page.html(), new RegExp('data-comment-(edit|delete)="' + SECOND + '"'));
});
test('a matching display name never shows post or comment buttons when author IDs are missing', async () => {
const page = await detailPage({ postOwner: '', comments: [comment({ author_id: '' })] });
assert.equal(page.elements.get('#editButton').hidden, true);
assert.equal(page.elements.get('#deleteButton').hidden, true);
assert.doesNotMatch(page.html(), /data-comment-edit|data-comment-delete/);
});
test('post and comment actions follow only the current primary identity and disappear without a session', async () => {
const page = await detailPage({
user: { ...USER, ssoSubject: 'stale-owner', userUuid: 'stale-owner' },
comments: [comment(), comment({ id: SECOND, author_id: 'stale-owner' })]
});
assert.equal(page.elements.get('#editButton').hidden, false);
assert.equal(page.elements.get('#deleteButton').hidden, false);
assert.match(page.html(), new RegExp('data-comment-edit="' + FIRST + '"'));
assert.doesNotMatch(page.html(), new RegExp('data-comment-(edit|delete)="' + SECOND + '"'));
const anonymous = await detailPage({ user: null, authRefresh: true, comments: [comment()] });
assert.equal(anonymous.elements.get('#editButton').hidden, true);
assert.equal(anonymous.elements.get('#deleteButton').hidden, true);
assert.doesNotMatch(anonymous.html(), /data-comment-edit|data-comment-delete/);
});
+21
View File
@@ -42,3 +42,24 @@ test('own-post filtering follows canonical identity even after company and depar
assert.equal(page.isPostOwner({ requesterId: 'fixture-owner', company: '이전 회사' }, user), true);
assert.equal(page.isPostOwner({ requesterId: 'another-owner', author: '동명이인', company: user.company }, user), false);
});
test('a matching display name, login or email never grants post ownership without an author ID', () => {
const user = { requesterId: 'fixture-owner', name: '동명이인', loginId: 'same@example.test', email: 'same@example.test' };
for (const author of [user.name, user.loginId, user.email]) {
assert.equal(page.isPostOwner({ author }, user), false);
}
assert.equal(page.isPostOwner({ requesterId: 'fixture-owner' }, null), false);
});
test('the signed primary identity takes precedence over stale alternate browser IDs', () => {
const user = { requesterId: 'current-owner', ssoSubject: 'old-owner', userUuid: 'old-owner' };
assert.equal(page.isPostOwner({ requesterId: 'old-owner' }, user), false);
assert.equal(page.isPostOwner({ requesterId: 'current-owner' }, user), true);
});
test('server requester and user UUID fields remain the author identity when normalizing a session', () => {
const user = page.normalizeAuthUser({ requesterId: 'current-owner', userUuid: 'current-owner', email: 'fixture@example.test' }, {});
assert.equal(user.requesterId, 'current-owner');
assert.equal(user.ssoSubject, 'current-owner');
assert.equal(user.userUuid, 'current-owner');
});
+188
View File
@@ -0,0 +1,188 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const source = readFileSync(new URL('../egbim/app.js', import.meta.url), 'utf8');
const flush = () => new Promise((resolve) => setImmediate(resolve));
const post = (id, title, extra = {}) => ({ id, title, content: '', company: '회사', department: '', author: '', ...extra });
const record = (id, title, extra = {}) => ({ id, title, contents: '문의 내용', Category: 'GENERAL_QNA', requester_id: 'owner', ...extra });
const ids = (posts) => Array.from(posts, (item) => item.id);
function element() {
return { value: '', textContent: '', innerHTML: '', hidden: false, checked: false, style: {}, listeners: {},
addEventListener(name, callback) { this.listeners[name] = callback; } };
}
function pageContext(fetchPage = async () => ({ items: [] })) {
const elements = new Map(['#qaRows', '#searchForm', '#query', '#onlyMine', '#tableEmpty', '#pagination', '#searchStatus', '#resultCount'].map((selector) => [selector, element()]));
const category = element();
category.value = '전체';
let buttons = [];
const calls = [];
const warnings = [];
const context = vm.createContext({
window: { QA_CONFIG: { pageSize: 10 }, location: { origin: 'https://qa.example.test' }, scrollTo() {} },
document: {
cookie: '', addEventListener() {},
querySelector: (selector) => selector === 'input[name=category]:checked' ? category : elements.get(selector) || null,
querySelectorAll(selector) {
if (selector === 'input[name=category], #onlyMine') return [category, elements.get('#onlyMine')];
if (selector === '#pagination button') {
buttons = Array.from(elements.get('#pagination').innerHTML.matchAll(/<button([^>]*)>/g), ([, attributes]) => {
const button = element();
button.dataset = { page: /data-page="(\d+)"/.exec(attributes)[1] };
button.disabled = attributes.includes(' disabled');
return button;
});
return buttons;
}
return [];
}
},
localStorage: { getItem: () => null, setItem() {} }, sessionStorage: { getItem: () => null },
URL, URLSearchParams,
console: { warn: (...args) => warnings.push(args) },
async fetch(path, options) {
assert.equal(options.credentials, 'include');
const page = Number(new URL(path, 'https://qa.example.test').searchParams.get('page'));
calls.push(page);
return Response.json(await fetchPage(page));
}
});
vm.runInContext(source.replace(/\}\)\(\);\s*$/, 'globalThis.pageSearch = { searchPosts, loadRemotePosts, initList, setUser(user) { resolvedAuthUser = user; } }; })();'), context);
return {
search: context.pageSearch.searchPosts, load: context.pageSearch.loadRemotePosts, elements, calls, warnings, category,
html: () => elements.get('#qaRows').innerHTML,
async init() { context.pageSearch.setUser({ requesterId: 'owner', ssoSubject: 'owner' }); context.pageSearch.initList(); await flush(); },
submit(query) { elements.get('#query').value = query; elements.get('#searchForm').listeners.submit({ preventDefault() {} }); },
clickPage(page) { const button = buttons.find((item) => item.dataset.page === String(page) && !item.disabled); assert.ok(button); button.listeners.click(); },
buttonCount: () => buttons.length
};
}
test('search ranks exact title matches ahead of content matches and typos, preserving ties and input order', () => {
const page = pageContext();
const posts = [post('typo', '로그임 오류'), post('body', '사용 문의', { content: '로그인 문제' }), post('title', '로그인 오류'), post('title2', '로그인 방법')];
assert.deepEqual(ids(page.search(posts, '로그인')), ['title', 'title2', 'body', 'typo']);
assert.deepEqual(ids(page.search(posts, '')), ids(posts));
assert.deepEqual(ids(posts), ['typo', 'body', 'title', 'title2']);
});
test('search tolerates Korean and English typos, missing or extra letters and adjacent transposition', () => {
const page = pageContext();
const posts = [post('korean', '로그인 오류'), post('english', 'Login error'), post('attachment', '파일 첨부 기능'), post('unrelated', '결제 승인 요청')];
for (const [query, expected] of [['로그임', 'korean'], ['logni', 'english'], ['loggin', 'english'], ['파일첨뷰', 'attachment']]) {
assert.ok(ids(page.search(posts, query)).includes(expected), query);
}
assert.deepEqual(ids(page.search(posts, 'qzxv')), []);
});
test('search supports spacing, case, Unicode normalization and Korean initials', () => {
const page = pageContext();
const posts = [post('login', '로그인 오류'), post('bim', 'EG-BIM 파일 첨부')];
for (const query of ['로그인오류', 'ㄹㄱㅇ', '로그인']) assert.equal(page.search(posts, query)[0].id, 'login', query);
for (const query of ['파일첨부', 'eg bim', 'EG-BIM']) assert.equal(page.search(posts, query)[0].id, 'bim', query);
});
test('all query words are required and may match title, body, company, department or author', () => {
const page = pageContext();
const posts = [post('match', '로그인 오류', { company: '바론 컨설턴트', department: '설계부', author: '김작성', content: '첨부 문의' }), post('other', '로그인 오류', { department: '관리부' })];
for (const query of ['로그임 설계부', '바론컨설턴트 김작성', '첨부 ㅅㄱㅂ']) assert.deepEqual(ids(page.search(posts, query)), ['match'], query);
assert.deepEqual(ids(page.search(posts, '로그인 없는검색어')), []);
});
test('one and two character queries require a literal match to limit unrelated results', () => {
const page = pageContext();
const posts = [post('dept', '문의', { department: 'IS3' })];
assert.deepEqual(ids(page.search(posts, 'is')), ['dept']);
assert.deepEqual(ids(page.search(posts, 'it')), []);
assert.deepEqual(ids(page.search(posts, '오류')), []);
});
test('all remote pages are loaded and duplicate IDs do not duplicate search results', async () => {
const first = Array.from({ length: 100 }, (_, index) => record(String(index + 1), '일반 문의 ' + index));
const page = pageContext(async (number) => ({ items: number === 1 ? first : [first[99], record('older', '로그인 오류')], meta: { total: 101 } }));
const updates = [];
const result = await page.load((update) => updates.push(update));
assert.equal(result.posts.length, 101);
assert.deepEqual(page.calls, [1, 2]);
assert.equal(updates[0].complete, false);
assert.equal(updates[1].complete, true);
assert.deepEqual(ids(page.search(result.posts, '로그임')), ['older']);
});
test('page metadata and full pages without metadata both terminate correctly', async () => {
const withPages = pageContext(async (number) => ({ items: [record('page-' + number, '문의')], meta: { totalPages: 2 } }));
assert.equal((await withPages.load()).posts.length, 2);
assert.deepEqual(withPages.calls, [1, 2]);
const withoutMeta = pageContext(async (number) => ({ items: number === 1 ? Array.from({ length: 100 }, (_, index) => record(String(index), '문의')) : [] }));
assert.equal((await withoutMeta.load()).posts.length, 100);
assert.deepEqual(withoutMeta.calls, [1, 2]);
const empty = pageContext(async () => ({ items: [], meta: { total: 0, totalPages: 1 } }));
assert.equal((await empty.load()).posts.length, 0);
assert.deepEqual(empty.calls, [1]);
});
test('repeated or missing remote pages fail instead of looping or claiming all results were loaded', async () => {
const records = Array.from({ length: 100 }, (_, index) => record(String(index), '문의'));
const repeated = pageContext(async () => ({ items: records }));
await assert.rejects(repeated.load(), /다음 페이지/);
assert.deepEqual(repeated.calls, [1, 2]);
const missing = pageContext(async (number) => ({ items: number === 1 ? records : [], meta: { total: 101 } }));
await assert.rejects(missing.load(), /끝까지/);
});
test('list form uses fuzzy results while category and ownership filters continue to apply', async () => {
const page = pageContext(async () => ({ items: [
record('mine', '로그인 오류'), record('other', '로그인 오류', { requester_id: 'someone-else' }),
record('improvement', '로그인 개선', { Category: 'IMPROVEMENT_QNA' }), record('unrelated', '결제 승인')
] }));
await page.init();
page.submit('로그임');
assert.match(page.html(), /data-id="mine"/);
assert.match(page.html(), /data-id="other"/);
assert.doesNotMatch(page.html(), /data-id="unrelated"/);
page.category.value = '일반문의';
page.category.listeners.change();
assert.doesNotMatch(page.html(), /data-id="improvement"/);
const mine = page.elements.get('#onlyMine');
mine.checked = true;
mine.listeners.change();
assert.match(page.html(), /data-id="mine"/);
assert.doesNotMatch(page.html(), /data-id="other"/);
page.submit('qzxv');
assert.equal(page.html(), '');
assert.equal(page.elements.get('#tableEmpty').style.display, 'block');
assert.equal(page.elements.get('#searchStatus').textContent, '검색 결과 0건');
});
test('older pages remain reachable and a new search resets pagination before showing a matching old post', async () => {
const records = Array.from({ length: 201 }, (_, index) => record(String(index + 1), index === 200 ? '고유마지막글' : '일반 문의 ' + index));
const page = pageContext(async (number) => ({ items: records.slice((number - 1) * 100, number * 100), meta: { total: 201 } }));
await page.init();
assert.deepEqual(page.calls, [1, 2, 3]);
assert.ok(page.buttonCount() <= 9);
page.clickPage(21);
assert.match(page.html(), /data-id="201"/);
page.submit('고유마지막글');
assert.match(page.html(), /data-id="201"/);
assert.match(page.elements.get('#pagination').innerHTML, /class="active" data-page="1"/);
page.submit('');
assert.match(page.html(), /data-id="1"/);
assert.doesNotMatch(page.html(), /data-id="201"/);
});
test('partial loading remains searchable and visibly reports a page fetch failure', async () => {
const page = pageContext(async (number) => {
if (number === 2) throw new Error('fixture connection failure');
return { items: [record('loaded', '로그인 오류')], meta: { totalPages: 2 } };
});
await page.init();
page.submit('로그임');
assert.match(page.html(), /data-id="loaded"/);
assert.match(page.elements.get('#searchStatus').textContent, /목록을 모두 불러오지 못했습니다/);
assert.equal(page.elements.get('#searchStatus').hidden, false);
assert.equal(page.warnings.length, 1);
assert.deepEqual(page.calls, [1, 2]);
});