공개 primary 네비 트리 중복 방지 (v0.0.101)

- 동일 id 중복 제거, 자식으로 붙은 항목은 루트에서 제외
- spec·history·update 반영

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-12 15:45:17 +09:00
parent 003fb86fad
commit 5031b9de22
5 changed files with 53 additions and 8 deletions

View File

@@ -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 문서화

View File

@@ -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`로 트리·동일 부모 내 순서 변경·하위 추가를 제공한다. 하단은 한 단계 목록만 드래그 정렬한다.

View File

@@ -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` 사용) 문구 보강.

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "0.0.100",
"version": "0.0.101",
"private": true,
"type": "module",
"imports": {

View File

@@ -162,11 +162,38 @@ export const renumberSortOrderByTree = (items, location) => {
/**
* 공개 API용 primary 트리(순환 참조 없음).
* 동일 id가 평면 목록에 중복되거나(시드·이전 마이그레이션 등), 한 행은 루트·다른 행은 자식으로만 잡히면
* 기존 한 루프 방식으로는 같은 노드가 roots와 children에 동시에 들어갈 수 있어, 자식으로 연결된 id는 루트에서 제외한다.
* @param {Array<Object>} flatPrimary - location primary인 항목만
* @returns {Array<Object>}
*/
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))
}
}