Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions docs/features/banner-curation-v2-links/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Banner Curation V2 Links Design

## UI Summary

기존 배너 등록/수정 화면의 `링크 설정` 카드를 그대로 사용한다. 큐레이션 타입을 선택했을 때만 달라지는 동작은 자동 생성되는 읽기 전용 이동 URL의 형식이며, 운영자는 기존과 동일하게 V2 큐레이션 드롭다운에서 대상만 선택한다. 기존 배너 데이터 전환은 운영 화면에 별도 버튼을 추가하지 않는 일회성 개발 환경 마이그레이션으로 처리한다.

## Navigation

- Route:
- 배너 관리: `/banners`, `/banners/new`, `/banners/:id`
- 배너가 저장하는 Product 이동 경로: `/curation/:id`
- Sidebar/menu placement:
- 기존 `배너 관리` 메뉴를 유지한다.
- Entry actions:
- 배너 등록 또는 수정 화면의 `링크 설정 > 큐레이션 선택`에서 대상 큐레이션을 선택한다.

## List View

- 기존 `BannerListPage`의 목록, 검색, 필터, 정렬순서 UI는 변경하지 않는다.
- 일회성 마이그레이션은 목록에 노출하지 않는다. 데이터 변경 대상과 결과는 개발 작업 로그로 확인한다.

## Detail/Create/Edit View

- Sections/cards:
- 기존 `링크 설정` 카드를 유지한다.
- Fields:
- `배너 타입`이 `큐레이션`이면 `큐레이션 선택` 드롭다운을 표시한다.
- 드롭다운 옵션은 활성 상태인 V2 큐레이션의 `name`만 표시한다.
- 대상 선택 시 `이동 URL`은 `/curation/{id}`로 자동 표시하며 읽기 전용으로 둔다.
- 기존 레거시 URL(`/search?curationId={id}`, `/alcohols/search?curationId={id}`)을 가진 배너를 열어도 해당 ID의 큐레이션이 선택 상태로 표시된다.
- 이미 V2 URL(`/curation/{id}`)인 배너도 해당 ID의 큐레이션이 선택 상태로 표시된다.
- Validation messages:
- 큐레이션 타입에서 대상이 없으면 기존 문구 `큐레이션을 선택해주세요`를 유지한다.
- Primary/secondary actions:
- 기존 `등록`·`저장`·`삭제` 동작과 피드백을 유지한다.

## State and Feedback

- Loading:
- 기존 큐레이션 목록 로딩 상태와 배너 상세 로딩 상태를 유지한다.
- Empty:
- 선택 가능한 V2 큐레이션이 없으면 드롭다운은 빈 상태로 표시한다.
- Error:
- 기존 배너 저장 실패 처리와 토스트를 유지한다.
- 일회성 마이그레이션에서 안전하게 파싱할 수 없는 링크, 상세 조회 실패, PUT 실패는 ID·사유를 실행 결과에 기록한다.
- Success:
- 마이그레이션이 갱신한 배너 ID와 변경 전후 URL을 실행 결과에 기록한다.
- Destructive confirmation:
- 링크 전환은 개발 환경에서 이미 사용자 승인된 일회성 데이터 정정이므로 별도 운영 UI 확인 다이얼로그는 추가하지 않는다.

## Design System Usage

- Components:
- 기존 `Card`, `FormField`, `Select`, `Input`, `Label`, `Checkbox`를 유지한다.
- Tokens/classes:
- 기존 큐레이션 타입의 `bg-muted` 읽기 전용 URL 필드 스타일을 유지한다.
- Existing pages to mirror:
- `src/pages/banners/BannerDetail.tsx`
- `src/pages/banners/components/BannerLinkSettingsCard.tsx`

## Manual UI Review Points

- [ ] 큐레이션 타입에서 V2 큐레이션을 선택하면 이동 URL이 `/curation/{id}`로 표시되는지 확인한다.
- [ ] 비활성 V2 큐레이션은 선택 드롭다운에 표시되지 않는지 확인한다.
- [ ] 레거시 및 V2 링크를 가진 기존 큐레이션 배너를 열었을 때 드롭다운이 올바른 큐레이션을 표시하는지 확인한다.
- [ ] 큐레이션 타입이 아닌 배너의 직접 URL 입력 및 외부 URL 선택 동작이 그대로인지 확인한다.
89 changes: 89 additions & 0 deletions docs/features/banner-curation-v2-links/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Banner Curation V2 Links Implementation Plan

## Summary

Change curation banner links generated by the admin from legacy V1 search URLs to Product V2 detail URLs, preserve selection state for legacy and V2 links, and add a guarded one-time development migration script that updates existing legacy CURATION banner records through the published per-banner PUT API.

## Inputs

- Spec: `docs/features/banner-curation-v2-links/spec.md`
- Design: `docs/features/banner-curation-v2-links/design.md`
- API source:
- Published admin API documentation: `https://bottle-note.github.io/bottle-note-api-server/bottle-note/admin-api/admin-api.html`
- Dev API base from `.env.dev`: `https://admin-api.development.bottle-note.com`
- Workspace issue: `https://github.com/bottle-note/workspace/issues/299`

## Data and API Mapping

- `GET /admin/api/v2/curations?isActive=true`
- Existing `useCurationList({ isActive: true })` / `curationService.list` supplies active `CurationV2ListItem[]` to `BannerLinkSettingsCard`.
- No type, service, or hook change is needed; the V2 source conversion already exists.
- `targetUrl`
- `createCurationTargetUrl(curationId)` returns `/curation/{curationId}`.
- `extractCurationIdFromTargetUrl(targetUrl)` returns an ID only for local `/curation/{id}`, `/search?curationId={id}`, or `/alcohols/search?curationId={id}` URLs.
- `BannerLinkSettingsCard` creates the new URL and `useBannerDetailForm` uses the extractor to restore the selected curation.
- `GET /admin/api/v1/banners?bannerType=CURATION&page={n}&size={n}`
- Migration enumerates every CURATION banner ID from all pages. The list response does not contain `targetUrl`.
- `GET /admin/api/v1/banners/{bannerId}`
- Migration reads the current link and full field set for each listed banner.
- `PUT /admin/api/v1/banners/{bannerId}`
- Migration submits the complete existing banner payload, changing only `targetUrl` to `/curation/{id}` and preserving `isExternalUrl: false`.
- `PATCH /admin/api/v1/banners/bulk/reorder` is excluded because it only updates ordering.

## Implementation Steps

1. Add `src/pages/banners/banner-curation-link.ts`.
- Export `createCurationTargetUrl(curationId: number): string`.
- Export `extractCurationIdFromTargetUrl(targetUrl: string | null | undefined): number | null`.
- Accept only the three local URL forms defined above; reject external and malformed URLs.
2. Update `BannerLinkSettingsCard`.
- Replace the inline legacy URL string with `createCurationTargetUrl`.
- Keep the existing V2 list item prop type and read-only field behavior.
3. Update `useBannerDetailForm`.
- Replace its local query-string-only parser with `extractCurationIdFromTargetUrl`.
- Correct its URL documentation comment to describe all accepted formats.
4. Update `BannerDetailPage`.
- Request the V2 curation list with `isActive: true` so inactive curations cannot be selected as new banner targets.
5. Add focused unit tests at `src/pages/banners/__tests__/banner-curation-link.test.ts`.
- Cover V2 URL creation.
- Cover V2, current legacy, and historical legacy extraction.
- Cover malformed IDs, external URLs, unrelated routes, and missing URLs.
6. Add `scripts/migrate-curation-banner-links.mjs`.
- Use Node built-in `fetch`; do not import browser-only API clients.
- Read `.env.dev` without emitting credentials and require `VITE_API_BASE_URL`, `VITE_E2E_TEST_ID`, and `VITE_E2E_TEST_PW`.
- Log in through `POST /admin/api/v1/auth/login`, retain the access token only in memory, and send it as a bearer token for migration requests.
- Default to dry-run. `--apply` is the only mode that sends PUT requests.
- Refuse `--apply` unless the base URL is the development admin API host, preventing accidental production updates.
- Enumerate every `bannerType=CURATION` page, fetch each detail, parse only safe legacy candidates, and print JSON results for `updated`, `skipped`, and `failed` entries without printing credentials or tokens.
- On apply, send each complete PUT payload sequentially and verify the response before recording success. Do not update records already using `/curation/{id}`.
7. Run the script once in dry-run mode against development, inspect the candidate IDs and planned URL changes, then run it with `--apply` under the user-approved development scope.
8. Re-run dry-run after apply and require zero remaining legacy candidates. Record updated, skipped, and failed results in the handoff.

## Edge Cases

- A CURATION banner with `isExternalUrl=true` is skipped even if its URL contains `curationId`.
- A URL with a missing, duplicate, non-integer, zero, negative, or unrelated `curationId` is skipped and reported.
- Absolute URLs and non-CURATION banners are never rewritten.
- V2 links are not rewritten; they are reported as already migrated.
- If the list pagination changes during migration, the script processes the pages returned at execution time and reports individual failures rather than retrying blind writes.
- A failed PUT does not stop the remaining candidate processing; its ID and error are recorded for follow-up.
- Product-route verification is out of this repository's control and remains a manual check.

## Verification Checklist

- [ ] `pnpm test:run src/pages/banners/__tests__/banner-curation-link.test.ts src/pages/banners/__tests__/banner.schema.test.ts`
- [ ] `pnpm test:run src/hooks/__tests__/useCurations.test.ts`
- [ ] `pnpm build`
- [ ] `pnpm exec eslint src/pages/banners/banner-curation-link.ts src/pages/banners/components/BannerLinkSettingsCard.tsx src/pages/banners/useBannerDetailForm.ts scripts/migrate-curation-banner-links.mjs`
- [ ] `node scripts/migrate-curation-banner-links.mjs` reports the expected development candidates without mutation.
- [ ] `node scripts/migrate-curation-banner-links.mjs --apply` completes with no unexpected failures.
- [ ] A final dry-run reports zero legacy migration candidates.
- [ ] Manual UI check: selecting a V2 curation on a banner writes `/curation/{id}` and legacy/V2 banner details restore the correct dropdown selection.

## Implementation Notes

- Do not add an admin UI action for this one-time migration.
- Do not change the Product route to `/dashboard/curations/:id`; #299 specifies `/curation/{id}`.
- Do not store or log test credentials, access tokens, refresh tokens, or full authorization headers.
- Do not modify non-CURATION banners or production data.
- Existing inactive curation banner links are preserved when an old banner is opened, but inactive options are not offered for a new selection.
77 changes: 77 additions & 0 deletions docs/features/banner-curation-v2-links/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Banner Curation V2 Links Spec

## Summary

큐레이션 타입 배너가 V1 검색 결과 페이지가 아닌 Product의 큐레이션 V2 상세 화면으로 이동하도록 링크 구조를 전환한다. 새로 생성하거나 수정하는 배너는 V2 큐레이션을 선택해 `/curation/{id}` 링크를 저장한다. 이미 저장된 레거시 큐레이션 배너도 일회성 API 마이그레이션으로 같은 형식으로 갱신한다.

## Source Inputs

- API docs/response shape:
- Published admin API documentation: `https://bottle-note.github.io/bottle-note-api-server/bottle-note/admin-api/admin-api.html`
- `GET /admin/api/v1/banners` supports `bannerType`, pagination, and returns banner IDs but not `targetUrl`.
- `GET /admin/api/v1/banners/{bannerId}` returns `targetUrl` and every field required by `PUT /admin/api/v1/banners/{bannerId}`.
- No bulk banner-update endpoint is published. `PATCH /admin/api/v1/banners/bulk/reorder` is ordering-only and must not be used for this migration.
- `GET /admin/api/v2/curations` provides the V2 curation list used by the banner selector.
- Existing UI/code references:
- `src/pages/banners/BannerDetail.tsx`: already loads V2 curations through `useCurationList` from `useCurations`.
- `src/pages/banners/components/BannerLinkSettingsCard.tsx`: currently generates the legacy `/search?curationId={id}` URL.
- `src/pages/banners/useBannerDetailForm.ts`: extracts `curationId` from legacy query-string URLs when editing existing banners.
- User request:
- GitHub workspace issue #299: `https://github.com/bottle-note/workspace/issues/299`
- Existing legacy links should be updated in bulk through the admin API.

## Admin Workflow

- 관리자는 배너 등록 또는 수정 화면에서 배너 타입을 `큐레이션`으로 선택한다.
- 관리자는 활성 상태인 V2 큐레이션 목록에서 링크 대상을 선택한다.
- 선택하면 이동 URL은 `/curation/{id}`로 자동 설정되고, 읽기 전용으로 표시된다.
- 기존 큐레이션 타입 배너를 수정 화면에서 열면 레거시 링크와 V2 링크 모두에서 큐레이션 ID를 인식해 선택 상태를 표시한다.
- 일회성 마이그레이션은 큐레이션 타입의 내부 링크 배너만 대상으로 하며, 레거시 링크를 `/curation/{id}`로 갱신한다.

## Data Requirements

- New and edited curation-banner link:
- `targetUrl`: `/curation/{curationId}`
- `isExternalUrl`: `false`
- `bannerType`: `CURATION`
- Banner curation selector query:
- `GET /admin/api/v2/curations?isActive=true`
- 비활성 큐레이션은 새 배너의 링크 대상으로 표시하지 않는다.
- Migration candidate requirements:
- `bannerType === 'CURATION'`
- `isExternalUrl === false`
- `targetUrl` is a recognized legacy curation URL with a numeric `curationId`, including `/search?curationId={id}` and the historical `/alcohols/search?curationId={id}` form.
- Migration request:
- Fetch every CURATION banner page through `GET /admin/api/v1/banners`.
- Fetch each candidate detail through `GET /admin/api/v1/banners/{bannerId}`.
- Send `PUT /admin/api/v1/banners/{bannerId}` with the existing required detail fields unchanged except `targetUrl` set to `/curation/{curationId}` and `isExternalUrl` set to `false`.
- Skip and report records whose URL cannot be safely parsed or whose detailed record no longer meets the candidate requirements.

## Acceptance Criteria

- [ ] 새 큐레이션 타입 배너를 저장하면 `targetUrl`이 `/curation/{id}` 형식이다.
- [ ] 큐레이션 선택 목록에는 활성 상태인 V2 큐레이션만 표시된다.
- [ ] 수정 화면에서 레거시 및 V2 큐레이션 링크 모두 해당 큐레이션을 선택 상태로 표시한다.
- [ ] 일회성 마이그레이션은 모든 페이지의 후보 배너를 조회하고, 안전하게 파싱 가능한 내부 큐레이션 링크만 갱신한다.
- [ ] 마이그레이션은 배너의 링크 외 필드와 정렬 순서를 변경하지 않는다.
- [ ] 마이그레이션 결과에는 갱신·건너뜀·실패 배너 ID와 사유가 남는다.
- [ ] 배너 클릭을 처리하는 Product 앱에서 `/curation/{id}`가 V2 큐레이션 상세로 연결된다.

## In Scope

- 배너 폼의 V2 링크 생성 및 기존 링크 파싱.
- V2 큐레이션 선택 타입을 유지하는 API/타입 연결.
- 개발 환경의 기존 레거시 큐레이션 배너 일회성 API 마이그레이션과 결과 보고.
- 단위 테스트 및 마이그레이션 dry-run 확인.

## Out of Scope

- Product 앱의 `/curation/{id}` 라우트 구현 또는 수정.
- API 서버에 전용 bulk banner-update endpoint 추가.
- CURATION 이외 배너 타입의 링크 정책 변경.
- 운영 환경 데이터 변경. 운영 전환은 별도 승인과 대상 검토 후 수행한다.

## Open Questions

- Product 앱의 `/curation/{id}` 라우트가 배포 환경에서 이미 V2 상세 화면으로 동작하는지 수동 확인이 필요하다.
- 레거시 링크를 수정할 때만 갱신하는 대신, 이번 요청에 따라 개발 환경에서는 일회성 마이그레이션으로 일괄 갱신한다.
Loading