From 5031b9de2288f029c414cafc589dd60b16c57d9a Mon Sep 17 00:00:00 2001 From: zenn Date: Tue, 12 May 2026 15:45:17 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B3=B5=EA=B0=9C=20primary=20=EB=84=A4?= =?UTF-8?q?=EB=B9=84=20=ED=8A=B8=EB=A6=AC=20=EC=A4=91=EB=B3=B5=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80=20(v0.0.101)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 동일 id 중복 제거, 자식으로 붙은 항목은 루트에서 제외 - spec·history·update 반영 Co-authored-by: Cursor --- docs/history.md | 6 +++++ docs/spec.md | 2 +- docs/update.md | 4 +++ package.json | 2 +- server/utils/navigation-tree.js | 47 ++++++++++++++++++++++++++++----- 5 files changed, 53 insertions(+), 8 deletions(-) diff --git a/docs/history.md b/docs/history.md index 65df680..459ebc6 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,11 @@ # 의사결정 이력 +## 2026-05-12 v0.0.101 + +### 공개 primary 네비 트리 중복 방지 + +`parent_id`가 있는 행과 루트로 들어온 동일 id가 공존하거나, 평면 목록에 id가 중복되면 한 루프로는 같은 노드가 `roots`와 부모 `children`에 동시에 들어갈 수 있다. 서버에서 id 단위로 한 번만 쓰고, 자식으로 연결된 id는 루트 후보에서 빼서 UI 중복을 제거한다. + ## 2026-05-12 v0.0.100 ### EMAIL_OTP_PEPPER 문서화 diff --git a/docs/spec.md b/docs/spec.md index d4d5be4..59eac5f 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -520,7 +520,7 @@ components/content/ - 네비게이션은 `navigation_items` 테이블로 관리한다. - 컬럼: `parent_id`(nullable, self FK, `ON DELETE CASCADE`), `is_folder`(boolean, **자식이 있는 상단 항목이면 저장 시 서버가 true로 설정**), `location`(`primary`|`footer`), `sort_order`, `label`, `url`, `is_visible`(저장 시 항상 true로 정리). `(location, label, url)` 유니크 제약은 제거되었다. -- `GET /api/navigation` 응답: `primary`는 **트리**(각 노드에 `children` 배열이 있을 수 있음, 리프는 `children` 없음). 노드 필드: `id`, `label`, `url`, `isFolder`, `isVisible`. `footer`는 **평면** 배열(`parent_id` 없음)이며 `id`, `label`, `url`, `isVisible`만 내려간다. +- `GET /api/navigation` 응답: `primary`는 **트리**(각 노드에 `children` 배열이 있을 수 있음, 리프는 `children` 없음). 노드 필드: `id`, `label`, `url`, `isFolder`, `isVisible`. 트리 조립 시 **동일 `id`는 한 번만 반영**하고, DB 평면 행에 `parent_id`가 있어 자식으로 붙은 항목은 **루트에 두지 않는다**(레거시·중복 행으로 인한 사이드바 이중 표시 방지). `footer`는 **평면** 배열(`parent_id` 없음)이며 `id`, `label`, `url`, `isVisible`만 내려간다. - `PUT /admin/api/navigation` 요청 본문의 각 항목은 반드시 `id`(UUID)를 포함한다. 저장 시 `sort_order`는 서버가 위치별 트리 DFS 순으로 다시 부여한다. `parent_id`는 `primary`에서만 허용되며 `footer` 항목은 항상 루트다. `is_visible`·`is_folder`는 요청값과 무관하게 서버에서 **항상 표시·자식 유무 기준 폴더**로 덮어쓴다. - URL은 `/`로 시작하는 내부 경로, `http(s)://` 외부 URL, 폴더 전용 자리 표시 `#`를 허용한다. - 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션** 탭으로 구분한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·번호 열에서 시작한다. 상단은 `AdminNavPrimaryBranch`로 트리·동일 부모 내 순서 변경·하위 추가를 제공한다. 하단은 한 단계 목록만 드래그 정렬한다. diff --git a/docs/update.md b/docs/update.md index 650e13e..9be6d8a 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,9 @@ # 업데이트 이력 +## v0.0.101 + +- `server/utils/navigation-tree.js` `buildPublicPrimaryTree`: 평면 `primary`에서 **동일 id 중복 제거**(정렬 후 첫 행 유지), **유효 부모 아래에 붙은 항목은 루트에서 제외**해 사이드바에 항목이 두 번 보이던 현상 방지. + ## v0.0.100 - `.env.example`·`docs/deploy.md`·`docs/spec.md`: **`EMAIL_OTP_PEPPER` 의미**(OTP 해시용 서버 비밀, 긴 난문자열 권장·미설정 시 `MEMBER_SESSION_SECRET` 사용) 문구 보강. diff --git a/package.json b/package.json index c138bff..fc9982e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "0.0.100", + "version": "0.0.101", "private": true, "type": "module", "imports": { diff --git a/server/utils/navigation-tree.js b/server/utils/navigation-tree.js index bea76f4..a135638 100644 --- a/server/utils/navigation-tree.js +++ b/server/utils/navigation-tree.js @@ -162,11 +162,38 @@ export const renumberSortOrderByTree = (items, location) => { /** * 공개 API용 primary 트리(순환 참조 없음). + * 동일 id가 평면 목록에 중복되거나(시드·이전 마이그레이션 등), 한 행은 루트·다른 행은 자식으로만 잡히면 + * 기존 한 루프 방식으로는 같은 노드가 roots와 children에 동시에 들어갈 수 있어, 자식으로 연결된 id는 루트에서 제외한다. * @param {Array} flatPrimary - location primary인 항목만 * @returns {Array} */ export const buildPublicPrimaryTree = (flatPrimary) => { - const list = flatPrimary.map((row) => ({ + const sorted = [...(flatPrimary || [])].sort((a, b) => { + const sa = a.sortOrder || 0 + const sb = b.sortOrder || 0 + if (sa !== sb) { + return sa - sb + } + const la = String(a.label || '') + const lb = String(b.label || '') + if (la !== lb) { + return la.localeCompare(lb) + } + return String(a.id).localeCompare(String(b.id)) + }) + + const idSeen = new Set() + const deduped = [] + for (const row of sorted) { + const id = String(row.id) + if (idSeen.has(id)) { + continue + } + idSeen.add(id) + deduped.push(row) + } + + const list = deduped.map((row) => ({ id: row.id, label: row.label, url: row.url, @@ -177,16 +204,24 @@ export const buildPublicPrimaryTree = (flatPrimary) => { })) const byId = new Map(list.map((i) => [String(i.id), { ...i, children: [] }])) - const roots = [] + const attachedAsChild = new Set() for (const row of list) { const id = String(row.id) const node = byId.get(id) const p = row.parentId - if (p && byId.has(String(p))) { - byId.get(String(p)).children.push(node) - } else { - roots.push(node) + const pid = p != null && String(p).trim() !== '' ? String(p).trim() : '' + if (pid && pid !== id && byId.has(pid)) { + byId.get(pid).children.push(node) + attachedAsChild.add(id) + } + } + + const roots = [] + for (const row of list) { + const id = String(row.id) + if (!attachedAsChild.has(id)) { + roots.push(byId.get(id)) } }