diff --git a/apps/app/components/ShareBottomSheet.tsx b/apps/app/components/ShareBottomSheet.tsx index bc3ccc26..2320d289 100644 --- a/apps/app/components/ShareBottomSheet.tsx +++ b/apps/app/components/ShareBottomSheet.tsx @@ -116,50 +116,47 @@ function ShareBottomSheetContent({ url, text }: ShareExtensionProps) { return; } - const { wish, item, accessToken } = result; + const { wish, accessToken } = result; /** 응답 body 를 못 읽으면 파싱 안내 없이 기존 성공 화면 */ - if (!wish || !item) { + if (!wish) { setSheetState({ status: 'success' }); return; } - /** reused 등으로 이미 종결된 아이템 — SSE 생략 */ - if (applyParsedItem(item, wish.id)) return; - - /** PENDING·PROCESSING — 로딩을 유지한 채 파싱 결과를 기다린다 */ - const sseResult = await subscribeItemParsingFromShare({ - itemId: item.id, + /** PARSING — 로딩을 유지한 채 파싱 결과를 기다린다 */ + const { status: sseStatus, wishId: resultWishId } = await subscribeItemParsingFromShare({ + wishId: wish.id, accessToken, signal: abortController.signal, }); if (!isMounted) return; - if (sseResult === 'READY') { + if (sseStatus === 'READY' || sseStatus === 'MERGED') { /** SSE payload 에는 상품명·가격이 없어 상세를 재조회해 카드를 채운다 */ - const detailItem = await getWishFromShare(wish.id, accessToken); + const detailItem = await getWishFromShare(resultWishId, accessToken); if (!isMounted) return; - if (detailItem && applyParsedItem(detailItem, wish.id)) return; + if (detailItem && applyParsedItem(detailItem, resultWishId)) return; /** 재조회 실패 — 저장은 확정이므로 기존 성공 화면으로 폴백 */ - setSheetState({ status: 'success', wishId: wish.id }); + setSheetState({ status: 'success', wishId: resultWishId }); return; } - if (sseResult === 'INCOMPLETE') { - setSheetState({ status: 'incomplete', wishId: wish.id }); + if (sseStatus === 'INCOMPLETE') { + setSheetState({ status: 'incomplete', wishId: resultWishId }); return; } - if (sseResult === 'FAILED') { - setSheetState({ status: 'failed', wishId: wish.id }); + if (sseStatus === 'FAILED') { + setSheetState({ status: 'failed', wishId: resultWishId }); return; } /** 타임아웃·연결 실패 — 기존 성공 화면 폴백 */ - setSheetState({ status: 'success', wishId: wish.id }); + setSheetState({ status: 'success', wishId: resultWishId }); }; void registerWish(); diff --git a/apps/app/utils/postWishLinkFromShare.ts b/apps/app/utils/postWishLinkFromShare.ts index ecee113e..2776642d 100644 --- a/apps/app/utils/postWishLinkFromShare.ts +++ b/apps/app/utils/postWishLinkFromShare.ts @@ -11,7 +11,7 @@ import { TokenStorage } from './tokenStorage'; */ export type ShareFailureReasonT = 'unauthenticated' | 'sessionExpired' | 'network' | 'server'; -const SHARE_ITEM_STATUSES = ['PENDING', 'PROCESSING', 'READY', 'INCOMPLETE', 'FAILED'] as const; +const SHARE_ITEM_STATUSES = ['PARSING', 'READY', 'INCOMPLETE', 'FAILED'] as const; export type ShareItemStatusT = (typeof SHARE_ITEM_STATUSES)[number]; @@ -24,7 +24,7 @@ export type ShareWishT = { }; export type ShareItemT = { - id: number; + id: number | null; status: ShareItemStatusT; name: string | null; price: number | null; @@ -37,7 +37,6 @@ export type PostWishLinkFromShareResultT = ok: true; /** 응답 body 를 못 읽으면 null — 저장은 성공이므로 시트는 기존 성공 화면으로 폴백 */ wish: ShareWishT | null; - item: ShareItemT | null; /** refresh 까지 끝난 시점의 토큰 — SSE 구독·위시 재조회에 재사용 */ accessToken: string; } @@ -76,24 +75,15 @@ const readErrorCode = async (response: Response): Promise => { }; /** - * 성공 응답에서 wish·item 을 뽑는다. 모양이 어긋나면 null — + * 성공 응답에서 wish 를 뽑는다. 모양이 어긋나면 null — * 저장 자체는 성공이므로 호출부는 파싱 안내 없이 기존 성공 화면으로 폴백한다. */ -const readSuccessBody = async ( - response: Response -): Promise<{ wish: ShareWishT; item: ShareItemT } | null> => { +const readSuccessBody = async (response: Response): Promise => { try { - const body = (await response.json()) as { - data?: { wish?: ShareWishT | null; item?: ShareItemT | null } | null; - }; + const body = (await response.json()) as { data?: { wish?: ShareWishT | null } | null }; const wish = body.data?.wish; - const item = body.data?.item; - if (typeof wish?.id !== 'number' || typeof item?.id !== 'number') return null; - /** 모르는 status 는 종결 여부를 판단할 수 없다 — SSE 로 넘기지 않고 성공 폴백 */ - if (!isShareItemStatus(item.status)) return null; - - return { wish, item }; + return typeof wish?.id === 'number' ? wish : null; } catch { return null; } @@ -146,14 +136,9 @@ export const postWishLinkFromShare = async ( return { ok: false, reason, retryable: reason === 'server', ...(code ? { code } : {}) }; } - const successBody = await readSuccessBody(postWishResponse); + const wish = await readSuccessBody(postWishResponse); - return { - ok: true, - wish: successBody?.wish ?? null, - item: successBody?.item ?? null, - accessToken: activeToken, - }; + return { ok: true, wish, accessToken: activeToken }; } catch { return { ok: false, reason: 'network', retryable: true }; } diff --git a/apps/app/utils/subscribeItemParsingFromShare.ts b/apps/app/utils/subscribeItemParsingFromShare.ts index 7f4ca4f3..270298a0 100644 --- a/apps/app/utils/subscribeItemParsingFromShare.ts +++ b/apps/app/utils/subscribeItemParsingFromShare.ts @@ -6,22 +6,32 @@ import type { ShareItemStatusT } from './postWishLinkFromShare'; * SSE 구독 결과. `FALLBACK` 은 타임아웃·연결 실패·abort 등 — * 저장은 이미 성공한 상태라 호출부는 기존 성공 화면으로 조용히 폴백한다. */ -export type ItemParsingSseResultT = +type ItemParsingSseStatusT = | Extract + | 'MERGED' | 'FALLBACK'; +type ItemParsingSseResultT = { + status: ItemParsingSseStatusT; + /** 결과 화면에 보여 줄 위시. MERGED인 경우, 살아남은 기존 wishId */ + wishId: number; +}; + /** 시트가 로딩에 고착되지 않도록 이벤트가 없으면 성공 화면으로 폴백 */ const SSE_TIMEOUT_MS = 10_000; -const PARSING_EVENT_TO_RESULT: Record = { +const PARSING_EVENT_TO_STATUS: Record = { ITEM_PARSING_COMPLETED: 'READY', ITEM_PARSING_INCOMPLETE: 'INCOMPLETE', ITEM_PARSING_FAILED: 'FAILED', + ITEM_PARSING_MERGED: 'MERGED', }; type NotificationSsePayloadT = { type?: string; kind?: string; + wishId?: number | null; + /** MERGED 에서는 합쳐져 사라진 요청 위시 id */ refId?: number; }; @@ -106,8 +116,8 @@ const concatBytes = (left: Uint8Array, right: Uint8Array): Uint8Array => { }; type SubscribeItemParsingParamsT = { - /** 이 아이템의 ITEM_PARSING_* 알림만 소비한다 (refId === itemId) */ - itemId: number; + /** 이 위시의 ITEM_PARSING_* 알림만 소비한다 */ + wishId: number; /** postWishLinkFromShare 가 refresh 까지 끝낸 시점의 토큰 */ accessToken: string; /** 시트 close·호스트 앱 이동 시 연결을 즉시 끊기 위한 신호 */ @@ -120,11 +130,13 @@ type SubscribeItemParsingParamsT = { * SSE 는 보조 채널 — 어떤 오류(연결 실패·401·타임아웃·abort)든 throw 없이 FALLBACK 을 반환한다. */ export const subscribeItemParsingFromShare = async ({ - itemId, + wishId, accessToken, signal, timeoutMs = SSE_TIMEOUT_MS, }: SubscribeItemParsingParamsT): Promise => { + const fallback: ItemParsingSseResultT = { status: 'FALLBACK', wishId }; + const controller = new AbortController(); const abortConnection = () => controller.abort(); @@ -144,14 +156,14 @@ export const subscribeItemParsingFromShare = async ({ } ); - if (!response.ok || !response.body) return 'FALLBACK'; + if (!response.ok || !response.body) return fallback; const reader = response.body.getReader(); let buffer: Uint8Array = new Uint8Array(0); while (true) { const { done, value } = await reader.read(); - if (done) return 'FALLBACK'; + if (done) return fallback; buffer = concatBytes(buffer, value); const { frames, rest } = extractFrames(buffer); @@ -163,9 +175,17 @@ export const subscribeItemParsingFromShare = async ({ try { const payload = JSON.parse(data) as NotificationSsePayloadT; - const result = payload.type ? PARSING_EVENT_TO_RESULT[payload.type] : null; - - if (result && payload.kind === 'WISH' && payload.refId === itemId) return result; + const status = payload.type ? PARSING_EVENT_TO_STATUS[payload.type] : null; + /** 합침 알림의 wishId 는 남는 위시라 요청한 위시는 따로 실려 옴 */ + const requestedWishId = status === 'MERGED' ? payload.refId : payload.wishId; + + if ( + status && + payload.kind === 'WISH' && + requestedWishId === wishId && + typeof payload.wishId === 'number' + ) + return { status, wishId: payload.wishId }; } catch { /** malformed JSON — 다음 이벤트를 기다린다 */ } @@ -173,7 +193,7 @@ export const subscribeItemParsingFromShare = async ({ } } catch { /** 연결 실패·타임아웃·abort — 저장 성공은 이미 확정이라 조용히 폴백 */ - return 'FALLBACK'; + return fallback; } finally { clearTimeout(timeoutId); signal?.removeEventListener('abort', abortConnection);