Skip to content

feat: 판매자 푸시 발송 소비자(Expo Push Service)·영수증 스케줄러 - #512

Merged
chanwoo7 merged 3 commits into
developfrom
feat/seller-push-consumer
Oct 5, 2026
Merged

chanwoo7 merged 3 commits into
developfrom
feat/seller-push-consumer

Conversation

@chanwoo7

@chanwoo7 chanwoo7 commented Oct 5, 2026

Copy link
Copy Markdown
Member

요약

  • 판매자 앱에 새 주문·새 문의 푸시를 보내는 outbox 소비자와 Expo 영수증 스케줄러를 추가합니다. #507의 이벤트(order.submitted·conversation.buyer_message_sent)를 #510의 디바이스로 전달하는 마지막 조각이며, SDL·Prisma 변경은 없습니다.
    • 소비자 SellerPushOutboxConsumer(worker, 큐 q.SellerPushOutboxConsumer), 스케줄러 SellerPushReceiptScheduler(5분)
    • 전송 어댑터 src/global/expo-push, 설정 expo-push.config, 메트릭 caquick_expo_push_sends_total{result}

변경

  • 설정은 선택 항목이라 부팅을 막지 않고, 비표준 값은 기본값으로 떨어집니다.
    • EXPO_PUSH_ENABLED(기본 false) · EXPO_PUSH_ACCESS_TOKEN(공백은 null) · EXPO_PUSH_TIMEOUT_MS(기본 5000, 0·abc는 기본값)
    • app.module load 등록, README 환경 변수 표 "푸시 (선택)" 행, infra/app.env.example 키 3개
  • 전송 어댑터는 kakao-local과 같은 방식으로 토큰·계약·기본 구현만 두고 NotificationModule.providers에 useValue로 등록합니다(@Global 모듈 없음, global은 common·config만 의존).
    • send(≤100, 초과 throw) · getReceipts(≤1000) · fetch + withTimeout · Authorization: Bearer(토큰 있을 때만)
    • 비 2xx는 상태 코드를 담은 ExpoPushHttpError, 401/403은 ExpoPushAuthError, 200이어도 errors면 거절, ticket 수가 요청과 다르면 거절
  • 소비자는 이벤트 payload 스냅샷만으로 메시지를 만들고 매장의 활성 디바이스에 보냅니다.
    • enabled=false → debug 로그 후 ack(전송 이력 없음) / payload 파싱 실패·구독 외 event_type → throw(호스트 retry → DLQ)
    • 선점은 NotificationRepository.createFromEvent 방식 — 기존 행 제외 후 createMany(skipDuplicates 없음, FK 오류가 삼켜지지 않음), unique (source_event_id, push_device_id)가 최종 방어, PENDING 행만 전송
    • 100개 배치 → TICKET_OK(ticket_id·sent_at) / TICKET_ERROR(error_code), DeviceNotRegistered는 즉시 disableByIds(DEVICE_NOT_REGISTERED)
    • 전송 예외는 그대로 throw(행은 PENDING 유지 → 재전달 때 그 행만 재전송), ExpoPushAuthError는 expo-push:auth(error) 경보 뒤 throw
    • 메시지: 주문 새 주문 / {상품명} {수량}개 · 픽업 M/d HH:mm(KST, kst-time 재사용) / data { kind: ORDER_SUBMITTED, orderId }, 문의 새 문의 / preview / data { kind: BUYER_MESSAGE, conversationId }, channelId: default
  • 스케줄러는 ticket만으로 알 수 없는 APNs/FCM 거절을 영수증으로 뒤늦게 확인합니다.
    • 대상: TICKET_OK · sent_at < now − 15분 · receipt_checked_at IS NULL 최대 300건
    • ok → RECEIPT_OK, error → RECEIPT_ERROR + error_code(DeviceNotRegistered면 디바이스 비활성), 응답에 없고 24h 경과 → RECEIPT_UNKNOWN(그 전엔 유지해 다음 틱에 다시)
    • 실패는 던지지 않고 warn + expo-push:receipts(warn) 경보, 인증 실패는 expo-push:auth(error); worker 역할 가드·enabled 가드·동시 실행 플래그
  • 그 밖의 변경입니다.
    • SellerPushDeliveryRepository(claim·markTickets·listForReceipt·markReceipts), seller-push-messages.helper
    • MetricsService.expoPushSends Counter 필드(라벨 TICKET_OK/TICKET_ERROR/AUTH_ERROR, 기존 Counter 필드 방식)
    • order·conversation 배럴에 payload 타입 export(OrderSubmittedPayload·ConversationBuyerMessageSentPayload)
    • 오류 코드 상수 EXPO_ERROR_DEVICE_NOT_REGISTERED·EXPO_ERROR_UNKNOWN을 seller-push.constants에

테스트

  • 새 spec 6개 + 기존 1개 갱신, 94건 통과(모듈 배선 게이트 module-wiring.spec 포함)입니다.
    • expo-push.config.spec 19건: EXPO_PUSH_ENABLED 9값 it.each(비표준 값 false), 토큰 공백 → null, 타임아웃 0·-1·abc → 5000
    • expo-push.transport.spec 20건: URL·헤더(토큰 유무)·JSON 본문, 101개 throw(100개는 전송), 비 2xx 4종, 401/403, 타임아웃, errors 거절, 형식 오류 3종, getReceipts 1001개 throw, 기본 구현의 전역 fetch
    • seller-push-delivery.repository.spec 9건: 선점·재전달 제외·다른 이벤트 무관·없는 디바이스 id는 FK 오류로 던짐(skipDuplicates였다면 조용히 0건)·listForReceipt 조건/정렬/limit·markReceipts
    • seller-push-outbox.consumer.spec(real DB, transport stub) 14건: 활성 2 → send 1회·메시지 2·행 TICKET_OK / 해제·타 매장 제외 / 0 → 미호출 / 같은 eventId 재호출 → send 0회(반증: 다른 eventId는 1회) / 150 → 100+50 / DeviceNotRegistered → 비활성 / 코드 없는 오류 UNKNOWN / throw → PENDING 유지·재호출 재전송 / 두 번째 배치만 실패 → 재전달은 남은 50개만 / 401 → 경보+throw·AUTH_ERROR 2 / enabled=false / payload 오류 throw / 문의 이벤트 body·kind / 구독 외 throw
    • seller-push-receipt.scheduler.spec(real DB) 10건: 15분 경과만 조회(반증: 14분은 미조회) / 대상 0 → 미호출 / DeviceNotRegistered 비활성(MessageTooBig은 유지) / 24h UNKNOWN·24h−1분 유지 후 다음 틱 재조회 / throw → warn 경보·행 불변 / 401 → auth 경보 / api·ws 미실행 / enabled=false / 앞선 틱 진행 중 건너뜀·끝난 뒤 재실행
    • seller-push-messages.helper.spec 6건, metrics.service.spec 지표 표 1행 추가
  • 반증으로 가드 5개(claim의 PENDING 필터, 소비자 enabled, 스케줄러 running 플래그, 15분 지연, 100개 상한)를 지우고 돌려 기대한 7건만 실패하는 것을 확인한 뒤 원복했습니다.
  • 정적 검사(tsc·lint·arch:check) 통과이며, SDL 변경이 없어 roles-coverage·dto:check·docs:check 영향이 없습니다.

플랜 대조

플랜 항목 13 불릿 상태
expo-push.config + transport 어댑터(토큰 주입) 한 것
SellerPushOutboxConsumer(멱등 선점, 배치, DeviceNotRegistered) 한 것
영수증 스케줄러 한 것
경보 2종·메트릭 카운터 한 것
README·app.env.example 한 것

설계와 달리 한 결정

  • 스케줄러에도 enabled 가드를 두어 꺼진 동안에는 Expo를 부르지 않습니다(설계는 소비자만 언급). 인증 실패로 끈 뒤 영수증 조회가 expo-push:auth를 계속 내지 않게 하기 위해서입니다.
  • claim은 현재 활성 디바이스 집합으로 한정해 PENDING을 돌려줍니다(push_device_id 순). 전송 전에 해제된 디바이스의 PENDING 행은 전송하지 않고 남겨 둡니다.
  • 어댑터는 200 응답의 errors와 ticket 수 불일치를 추가로 거절합니다 — 순서 기반 디바이스 매핑을 믿을 수 없을 때 조용히 기록하지 않기 위해서입니다.
  • AUTH_ERROR 메트릭은 요청이 아니라 메시지 수 단위로 셉니다(TICKET_*와 같은 단위).
  • 소비자 spec은 outboxPublisherProviders를 넣지 않았습니다 — 소비자·두 repository 모두 OutboxPublisher를 주입받지 않아 필요가 없습니다.

후속 이슈(등록 예정)

  • 전송 전에 해제된 디바이스의 PENDING 전달 행 정리(현재는 무해하게 남음 — 영수증 대상이 아니고 재전달에도 제외).
  • 정지·탈퇴 판매자 디바이스 비활성(플랜 §3.3 후속 목록에 이미 있음 — 소비자는 Store·Account를 읽지 않아 그 사이 푸시가 갑니다).

운영 반영

  • 운영 시크릿(DOTENV)에 EXPO_PUSH_ENABLED=true 추가 필요(메인 세션 작업). 미설정이면 worker가 이벤트를 전송 없이 ack하므로 그 기간의 푸시는 복구되지 않습니다.
  • EXPO_PUSH_ACCESS_TOKEN은 선택(Expo 대시보드에서 발급 시 함께 추가).

@coderabbitai

coderabbitai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Repository: CaQuick/caquick-be/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: e1489c66-e4a3-4ebe-9bc0-780fcc237cfb

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown

🧹 knip — dead-code 리포트

Unused exported types (1)
전체 리포트
Unused exported types (1)
RateLimitPolicy  type  src/global/rate-limit/index.ts:4:8

청소 후보(오탐 가능) · 기준 docs/guide/architecture-conventions.md

@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

🩺 NestJS Doctor — 90/100 (Excellent)

진단 484건 (error 12).

Category error warning info
architecture 1 1 44
correctness 0 266 0
performance 0 36 28
schema 0 0 76
security 11 21 0
architecture / security 상위 항목
  • error architecture/architecture/no-manual-instantiation: Manual instantiation of 'OutboxRepository' detected. Use dependency injection instead.
  • info architecture/architecture/no-barrel-export-internals: Barrel file re-exports internal type 'IAuditLogRepository'.
  • warning security/security/no-exposed-env-vars: Direct 'process.env.NODE_ENV' access in 'AuthController'. Use ConfigService instead.
  • warning security/security/require-guards-on-endpoints: Endpoint 'start' has no @UseGuards() at class or method level.
  • warning security/security/require-guards-on-endpoints: Endpoint 'callback' has no @UseGuards() at class or method level.
  • warning security/security/require-guards-on-endpoints: Endpoint 'refresh' has no @UseGuards() at class or method level.
  • warning security/security/require-guards-on-endpoints: Endpoint 'logout' has no @UseGuards() at class or method level.
  • warning security/security/require-guards-on-endpoints: Endpoint 'sellerLogin' has no @UseGuards() at class or method level.
  • warning security/security/require-guards-on-endpoints: Endpoint 'sellerRefresh' has no @UseGuards() at class or method level.
  • warning security/security/require-guards-on-endpoints: Endpoint 'sellerLogout' has no @UseGuards() at class or method level.
  • warning security/security/require-guards-on-endpoints: Endpoint 'devIssueToken' has no @UseGuards() at class or method level.
  • warning security/security/require-guards-on-endpoints: Endpoint 'adminLogin' has no @UseGuards() at class or method level.
  • warning security/security/require-guards-on-endpoints: Endpoint 'adminRefresh' has no @UseGuards() at class or method level.
  • warning security/security/require-guards-on-endpoints: Endpoint 'adminLogout' has no @UseGuards() at class or method level.
  • warning security/security/require-guards-on-endpoints: Endpoint 'getJwks' has no @UseGuards() at class or method level.

오탐 포함 가능 · 기준 docs/guide/architecture-conventions.md

@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Coverage report

St.❔
Category Percentage Covered / Total
🟢 Statements
98.01% (+0.06% 🔼)
10684/10901
🟢 Branches
92.94% (+0.12% 🔼)
4065/4374
🟢 Functions
97.53% (+0.1% 🔼)
2132/2186
🟢 Lines
98.58% (+0.04% 🔼)
9720/9860
Show new covered files 🐣
St.❔
File Statements Branches Functions Lines
🟢
... / seller-set-product-tags-by-name.input.ts
100% 100% 100% 100%
🟢
... / seller-push-delivery.repository.ts
100% 100% 100% 100%
🟢
... / seller-push-outbox.consumer.ts
100% 100% 100% 100%
🟢
... / seller-push-messages.helper.ts
100% 100% 100% 100%
🟢
... / expo-push.transport.ts
100% 100% 100% 100%
🟢
... / seller-push-receipt.scheduler.ts
100% 90.91% 100% 100%
Show files with reduced coverage 🔻
St.❔
File Statements Branches Functions Lines
🟢
... / product-seller-taxonomy.service.ts
96.2% (-0.46% 🔻)
88.46% (-0.43% 🔻)
100% 100%

Test suite run success

4236 tests passing in 383 suites.

Report generated by 🧪jest coverage report action from 3a7d013

@codecov

codecov Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.05213% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...fication/services/seller-push-receipt.scheduler.ts 96.49% 0 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6c686267c8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +84 to +88
const response = await withTimeout(
fetchFn(url, { method: 'POST', headers, body: JSON.stringify(body) }),
options.timeoutMs,
label,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Abort timed-out Expo POSTs before retrying them

When Expo takes longer than EXPO_PUSH_TIMEOUT_MS, withTimeout rejects without cancelling fetchFn, so the consumer retries while the original send POST can still complete and sellers may receive duplicate notifications. The timeout also ends as soon as response headers arrive, leaving response.json() able to hang indefinitely. Use an AbortController and keep the timeout active through reading the response body.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영: fetch에 AbortController signal을 넘기고 기한이 바디(JSON) 읽기까지 덮게 한 뒤, TimeoutError면 abort — 기한 초과 뒤 원 POST가 완료돼 재시도와 겹치는 중복 발송·헤더 뒤 json() 무한 대기 둘 다 막음. 던지는 에러는 기존과 같은 TimeoutError(fetch가 AbortError로 끝나도 호출자는 TimeoutError → retry 경로). 테스트 3건(기한 초과 시 signal.aborted, 바디 읽기 지연도 기한에 걸림, 제때 끝나면 abort 안 함) — AbortController 없는 코드로 되돌리면 실패 확인.

Comment on lines +133 to +134
await this.deliveries.markTickets(results);
await this.devices.disableByIds(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Disable rejected devices before finalizing deliveries

If the process exits or disableByIds fails after markTickets succeeds, the retry finds no PENDING delivery and never disables the device that returned DeviceNotRegistered, so future events keep targeting the dead token. The receipt scheduler has the same terminal-state-before-disable ordering and also excludes the finalized row on retry; make these state changes atomic or perform the idempotent disable before marking the delivery terminal.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영: 소비자·영수증 스케줄러 둘 다 멱등한 disableByIds(disabled_at IS NULL 조건)를 먼저 하고 그 다음 markTickets/markReceipts로 전달 행을 종료 상태로 표시하도록 순서 변경. 행을 닫은 뒤 죽어도 디바이스는 이미 비활성이고, 비활성 뒤 죽으면 행이 PENDING/TICKET_OK로 남아 재시도·다음 틱이 다시 본다. 테스트 2건(markTickets/markReceipts를 throw시켜도 디바이스는 비활성) — 순서를 되돌리면 실패 확인.

판매자 앱에 새 주문·새 문의 푸시를 보낸다. #507의 outbox 이벤트를 #510의 디바이스로 전달하는 마지막 조각.

- expo-push.config: EXPO_PUSH_ENABLED(기본 false)·ACCESS_TOKEN·TIMEOUT_MS(기본 5000). 비표준 값은 기본값, 부팅 실패 조건 없음.
  app.module load 등록, README 환경 변수 표·infra/app.env.example에 키 3개.
- src/global/expo-push: EXPO_PUSH_TRANSPORT 토큰·ExpoPushTransport 계약·fetch 기본 구현. send ≤100·getReceipts ≤1000(넘기면
  throw), withTimeout, 비 2xx는 상태 코드를 담은 ExpoPushHttpError, 401/403은 ExpoPushAuthError, 200+errors·ticket 수
  불일치도 거절. kakao-local과 같이 모듈 없이 NotificationModule.providers에 useValue로 등록.
- SellerPushOutboxConsumer(@SubscribeOutbox order.submitted·conversation.buyer_message_sent, worker): enabled=false면 debug
  로그 후 ack(이력 없음), payload 파싱 실패 throw, 매장 활성 디바이스 → claim(createFromEvent 방식 — 기존 행 제외 후
  createMany, skipDuplicates 없음, unique가 최종 방어, PENDING만 전송) → 100개 배치 send → TICKET_OK(ticket_id·sent_at) /
  TICKET_ERROR(error_code), DeviceNotRegistered는 즉시 디바이스 비활성. 전송 예외는 던져 호스트 retry/DLQ, 인증 실패는
  expo-push:auth 경보 뒤 throw. 메트릭 caquick_expo_push_sends_total{result}.
- SellerPushReceiptScheduler(5분, worker 역할 가드·enabled 가드·동시 실행 플래그): 15분 지난 TICKET_OK 최대 300건 →
  RECEIPT_OK / RECEIPT_ERROR(+DeviceNotRegistered 비활성), 24h 넘게 영수증 없으면 RECEIPT_UNKNOWN, 그 전엔 유지. 실패는 warn +
  expo-push:receipts(인증 실패는 expo-push:auth), 행은 그대로.
- SellerPushDeliveryRepository(claim·markTickets·listForReceipt·markReceipts), seller-push-messages.helper(주문 "새 주문" /
  "{상품} {n}개 · 픽업 M/d HH:mm"(KST), 문의 "새 문의" / preview, data kind·id, channelId default).
- order·conversation 배럴에 payload 타입 export.
- spec: config 19, transport 20, helper 6, delivery repo 9(FK 오류 전파 반증), consumer 14(재전달 send 0회·150→100+50·
  배치 중간 실패 뒤 남은 50만 재전송·401 경보·enabled=false·payload 오류·구독 외 throw), scheduler 10(15분 미만 미조회·
  24h UNKNOWN·api/ws 미실행·동시 실행 건너뜀), metrics 지표 표 1행. 가드 5개(PENDING 필터·enabled·running·15분·100개 상한)를
  지우면 기대한 7건만 실패하는 것 확인.
Codex 2라운드 리뷰 반영.

- 전송 어댑터: withTimeout이 fetch를 취소하지 않아 기한 초과 뒤에도 원 POST가
  완료돼 재시도와 겹치면 중복 발송될 수 있고, 헤더 도착 뒤 response.json()은
  기한 밖이라 무한 대기가 가능했다. AbortController를 fetch에 넘기고 기한이
  바디(JSON) 읽기까지 덮게 한 뒤 TimeoutError면 abort한다. 던지는 에러는
  기존과 같은 TimeoutError(fetch가 AbortError로 끝나도 호출자는 TimeoutError).
- 소비자·영수증 스케줄러: markTickets/markReceipts 뒤에 disableByIds를 하던
  순서를 뒤집는다. 전달 행을 종료 상태로 바꾼 뒤 죽으면 재시도·다음 틱이 그 행을
  다시 보지 않아 DeviceNotRegistered 디바이스가 영영 비활성되지 않았다.
  disableByIds는 멱등(disabled_at IS NULL 조건)이라 먼저 해도 안전하다.
- 회귀 테스트 5건: 전송 3(기한 초과 시 signal abort·AbortError여도 TimeoutError,
  바디 읽기 지연도 기한에 걸림, 제때 끝나면 abort 안 함), 소비자 1(markTickets가
  던져도 디바이스는 이미 비활성·행은 PENDING), 스케줄러 1(markReceipts가 던져도
  디바이스는 이미 비활성·행은 다음 틱 대상). 운영 코드를 HEAD로 되돌리면 이 5건만
  실패하는 것을 확인.
rebase 뒤 develop SDL 기준으로 seller·admin 루트 필드 수 갱신.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 24985ab39c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +106 to +107
const errorCode = receipt.details?.error ?? EXPO_ERROR_UNKNOWN;
results.push({ id: row.id, status: 'RECEIPT_ERROR', errorCode });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Alert on receipt-level credential failures

When Expo returns a receipt-level InvalidCredentials or MismatchSenderId error, the HTTP request itself succeeded, so the surrounding exception handler never runs; this branch merely stores RECEIPT_ERROR without logging, alerting, or incrementing a metric. These errors can disable pushes for an entire platform while remaining invisible outside direct database inspection. Expo documents both as receipt errors, so raise a deduplicated operational alert for these codes: Expo receipt error documentation.

Useful? React with 👍 / 👎.

Comment on lines +106 to +107
const errorCode = receipt.details?.error ?? EXPO_ERROR_UNKNOWN;
results.push({ id: row.id, status: 'RECEIPT_ERROR', errorCode });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retry notifications rejected by transient receipt errors

When a receipt contains MessageRateExceeded, this branch marks the delivery permanently as RECEIPT_ERROR; listForReceipt then excludes it, so the new-order or inquiry notification is never attempted again. Expo explicitly classifies this receipt error as transient and recommends exponential-backoff retries, so distinguish it from permanent failures and schedule a new send rather than terminally closing the delivery: Expo receipt error guidance.

Useful? React with 👍 / 👎.

Comment on lines +84 to +87
const rows = await this.deliveries.listForReceipt({
sentBefore: new Date(now.getTime() - RECEIPT_DELAY_MS),
limit: RECEIPT_BATCH_LIMIT,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Drain more than one receipt batch per scheduler run

With a single 300-row query every five minutes, this scheduler can check only one receipt per second on average. If traffic or a backlog exceeds that rate, TICKET_OK rows accumulate faster than they are drained and eventually reach Expo's 24-hour receipt-retention limit before being inspected, at which point valid failures such as DeviceNotRegistered are reduced to RECEIPT_UNKNOWN and dead tokens remain enabled. Loop through bounded batches (the API permits up to 1,000 IDs per request) or schedule work frequently enough to catch up: Expo receipt timing and retention guidance.

Useful? React with 👍 / 👎.

@chanwoo7
chanwoo7 force-pushed the feat/seller-push-consumer branch from 24985ab to 3a7d013 Compare October 5, 2026 16:24
@chanwoo7
chanwoo7 merged commit 918cb6f into develop Oct 5, 2026
17 checks passed
@chanwoo7
chanwoo7 deleted the feat/seller-push-consumer branch October 5, 2026 16:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant