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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ APP_ROLE=worker PORT=4001 yarn start:dev # (선택) 이벤트 소비까지 보
| **문서 · 메트릭** | `DOCS_ACCESS_TOKEN`(`/gql-docs`와 `/rest-docs` 접근 토큰, 운영 필수), `METRICS_ACCESS_TOKEN`(`/metrics` Bearer 토큰, 운영 필수) |
| **경보 (선택)** | `DISCORD_ALERT_WEBHOOK_URL`(없으면 로그로만 남깁니다), `ALERT_DEDUPE_WINDOW_MS`(기본 5분), `BOOT_ALERT_STATE_DIR`(부팅 실패 경보의 억제 파일 위치, 기본 `~/.caquick/boot-alert`) |
| **Outbox (선택)** | `OUTBOX_DISPATCH_ENABLED`(`false`로 끄는 용도만 있으며 켜는 것은 worker 역할이 결정), `OUTBOX_POLL_INTERVAL_MS`, `OUTBOX_BATCH_SIZE`, `OUTBOX_MAX_ATTEMPTS`, `OUTBOX_PARTITION_CONCURRENCY` |
| **푸시 (선택)** | `EXPO_PUSH_ENABLED`(`true`일 때만 worker가 판매자 앱 푸시를 보내고, 기본 `false`면 소비자가 전송 없이 ack합니다), `EXPO_PUSH_ACCESS_TOKEN`(Expo 액세스 토큰, 없으면 인증 헤더 없이 보냅니다), `EXPO_PUSH_TIMEOUT_MS`(기본 5000) |
| **시드 (선택)** | `ADMIN_SEED_USERNAME`, `ADMIN_SEED_PASSWORD`, `SELLER_SEED_PASSWORD` |

### 자주 쓰는 스크립트
Expand Down
5 changes: 5 additions & 0 deletions infra/app.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,8 @@ DISCORD_ALERT_WEBHOOK_URL=
ALERT_DEDUPE_WINDOW_MS=
OUTBOX_MAX_ATTEMPTS=
OUTBOX_PARTITION_CONCURRENCY=

# ---- 앱: 판매자 푸시(Expo Push Service) ----
EXPO_PUSH_ENABLED= # true일 때만 worker가 전송한다(기본 false — 소비자가 전송 없이 ack)
EXPO_PUSH_ACCESS_TOKEN=
EXPO_PUSH_TIMEOUT_MS=
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import appConfig, {
import authConfig from '@/config/auth.config';
import databaseConfig from '@/config/database.config';
import docsConfig from '@/config/docs.config';
import expoPushConfig from '@/config/expo-push.config';
import kakaoLocalConfig from '@/config/kakao-local.config';
import metricsConfig from '@/config/metrics.config';
import oidcConfig from '@/config/oidc.config';
Expand Down Expand Up @@ -124,6 +125,7 @@ export class AppModule implements NestModule {
authConfig,
databaseConfig,
docsConfig,
expoPushConfig,
kakaoLocalConfig,
metricsConfig,
oidcConfig,
Expand Down
51 changes: 51 additions & 0 deletions src/config/expo-push.config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { readExpoPushConfig } from '@/config/expo-push.config';

describe('expoPushConfig', () => {
it('미설정이면 꺼짐·토큰 없음·타임아웃 5000', () => {
expect(readExpoPushConfig({})).toEqual({
enabled: false,
accessToken: null,
requestTimeoutMs: 5_000,
});
});

// 전송 스위치 — true/false(대소문자·공백 무시)만 인정하고 나머지는 꺼짐
it.each([
['true', true],
['TRUE', true],
[' true ', true],
['false', false],
['', false],
['yes', false],
['1', false],
['on', false],
[undefined, false],
])('EXPO_PUSH_ENABLED=%p → %s', (value, expected) => {
expect(readExpoPushConfig({ EXPO_PUSH_ENABLED: value }).enabled).toBe(
expected,
);
});

it.each([
[' tok ', 'tok'],
['', null],
[' ', null],
[undefined, null],
])('EXPO_PUSH_ACCESS_TOKEN=%p → %p', (value, expected) => {
expect(
readExpoPushConfig({ EXPO_PUSH_ACCESS_TOKEN: value }).accessToken,
).toBe(expected);
});

it.each([
['250', 250],
['0', 5_000],
['-1', 5_000],
['abc', 5_000],
['', 5_000],
])('EXPO_PUSH_TIMEOUT_MS=%p → %s', (value, expected) => {
expect(
readExpoPushConfig({ EXPO_PUSH_TIMEOUT_MS: value }).requestTimeoutMs,
).toBe(expected);
});
});
35 changes: 35 additions & 0 deletions src/config/expo-push.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { registerAs } from '@nestjs/config';

import {
parseEnvBoolean,
parseEnvNumber,
parseEnvString,
} from '@/common/utils/env-parse';

export interface ExpoPushConfig {
/** 꺼져 있으면 소비자가 전송 없이 ack만 한다(이력도 남기지 않는다). 기본 false. */
enabled: boolean;
/** Expo 액세스 토큰(선택). 있으면 Authorization: Bearer. */
accessToken: string | null;
requestTimeoutMs: number;
}

const DEFAULT_TIMEOUT_MS = 5_000;

/** 선택 설정 — 부팅을 막는 조건이 없다. 비표준 값은 기본값으로 떨어진다. */
export function readExpoPushConfig(
env: NodeJS.ProcessEnv = process.env,
): ExpoPushConfig {
return {
enabled: parseEnvBoolean(env.EXPO_PUSH_ENABLED, false),
accessToken: parseEnvString(env.EXPO_PUSH_ACCESS_TOKEN) ?? null,
requestTimeoutMs: parseEnvNumber(
env.EXPO_PUSH_TIMEOUT_MS,
DEFAULT_TIMEOUT_MS,
),
};
}

export default registerAs('expoPush', (): ExpoPushConfig =>
readExpoPushConfig(),
);
1 change: 1 addition & 0 deletions src/features/conversation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ export { ConversationRepository } from '@/features/conversation/repositories/con
// 구매자 메시지 전송 이벤트 계약(outbox). 판매자 푸시 소비자(notification)가 payload 스냅샷만 읽는다.
export {
CONVERSATION_BUYER_MESSAGE_SENT,
type ConversationBuyerMessageSentPayload,
parseConversationBuyerMessageSentPayload,
} from '@/features/conversation/events/conversation-buyer-message-sent.event';
5 changes: 5 additions & 0 deletions src/features/notification/constants/seller-push.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,8 @@ export const PUSH_DEVICE_DISABLED_REASON = {
} as const;
export type PushDeviceDisabledReason =
(typeof PUSH_DEVICE_DISABLED_REASON)[keyof typeof PUSH_DEVICE_DISABLED_REASON];

/** Expo ticket·receipt 오류 코드. 토큰이 죽은 디바이스는 즉시 비활성한다. */
export const EXPO_ERROR_DEVICE_NOT_REGISTERED = 'DeviceNotRegistered';
/** Expo가 오류 코드를 안 준 오류 */
export const EXPO_ERROR_UNKNOWN = 'UNKNOWN';
10 changes: 9 additions & 1 deletion src/features/notification/notification.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AuditLogModule } from '@/features/audit-log';
import { AuthModule } from '@/features/auth';
import { NotificationAdminRepository } from '@/features/notification/repositories/notification-admin.repository';
import { NotificationRepository } from '@/features/notification/repositories/notification.repository';
import { SellerPushDeliveryRepository } from '@/features/notification/repositories/seller-push-delivery.repository';
import { SellerPushDeviceRepository } from '@/features/notification/repositories/seller-push-device.repository';
import { AdminNotificationMutationResolver } from '@/features/notification/resolvers/notification-admin-mutation.resolver';
import { AdminNotificationQueryResolver } from '@/features/notification/resolvers/notification-admin-query.resolver';
Expand All @@ -14,10 +15,13 @@ import { AdminNotificationService } from '@/features/notification/services/notif
import { UserNotificationService } from '@/features/notification/services/notification-my.service';
import { NotificationOutboxConsumer } from '@/features/notification/services/notification-outbox.consumer';
import { SellerPushDeviceService } from '@/features/notification/services/seller-push-device.service';
import { SellerPushOutboxConsumer } from '@/features/notification/services/seller-push-outbox.consumer';
import { SellerPushReceiptScheduler } from '@/features/notification/services/seller-push-receipt.scheduler';
import { OutboxModule } from '@/features/outbox';
import { StoreModule } from '@/features/store';
import { EXPO_PUSH_TRANSPORT, expoPushTransport } from '@/global/expo-push';

/** 알림 소유 feature: 구매자 알림센터(목록·읽음)·관리자 일괄 발송 요청과 이력·outbox 소비자(알림 생성의 단일 진입점)·판매자 푸시 디바이스. */
/** 알림 소유 feature: 구매자 알림센터(목록·읽음)·관리자 일괄 발송 요청과 이력·outbox 소비자(알림 생성의 단일 진입점)·판매자 푸시 디바이스와 전송. */
@Module({
// StoreModule은 판매자 컨텍스트(SellerBaseService → StoreSellerRepository)용
imports: [AuthModule, AuditLogModule, OutboxModule, StoreModule],
Expand All @@ -34,6 +38,10 @@ import { StoreModule } from '@/features/store';
SellerPushDeviceRepository,
SellerPushDeviceService,
SellerPushDeviceMutationResolver,
SellerPushDeliveryRepository,
SellerPushOutboxConsumer,
SellerPushReceiptScheduler,
{ provide: EXPO_PUSH_TRANSPORT, useValue: expoPushTransport },
],
// 미읽 수는 뷰어 카운트(user → 05c mypage)가 배럴로 읽는다
exports: [NotificationRepository],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import { SellerPushDeliveryRepository } from '@/features/notification/repositories/seller-push-delivery.repository';
import type { PrismaClient } from '@/generated/prisma/client';
import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client';
import { closeTruncateConnection, truncateAll } from '@/test/db/truncate';
import {
createSellerPushDelivery,
createSellerPushDevice,
} from '@/test/factories';
import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder';

const EVENT_ID = '11111111-1111-4111-8111-111111111111';
const AT = new Date('2026-10-05T12:00:00.000Z');

describe('SellerPushDeliveryRepository (real DB)', () => {
let repo: SellerPushDeliveryRepository;
let prisma: PrismaClient;

beforeAll(async () => {
const { module, prisma: p } = await createTestingModuleWithRealDb({
providers: [SellerPushDeliveryRepository],
});
repo = module.get(SellerPushDeliveryRepository);
prisma = p;
});

afterAll(async () => {
await closeTruncateConnection();
await disconnectTestPrismaClient();
});

beforeEach(async () => {
await truncateAll();
});

describe('claim', () => {
it('디바이스마다 PENDING 행을 만들고 그 행들을 디바이스 id 순으로 돌려준다', async () => {
const a = await createSellerPushDevice(prisma);
const b = await createSellerPushDevice(prisma);

const rows = await repo.claim(EVENT_ID, [b.id, a.id]);

expect(rows.map((r) => r.push_device_id)).toEqual([a.id, b.id]);
expect(await prisma.sellerPushDelivery.count()).toBe(2);
expect(
await prisma.sellerPushDelivery.findMany({ select: { status: true } }),
).toEqual([{ status: 'PENDING' }, { status: 'PENDING' }]);
});

it('재전달: ticket이 기록된 디바이스는 빼고 PENDING인 디바이스만 다시 돌려주며 행을 더 만들지 않는다', async () => {
const done = await createSellerPushDevice(prisma);
const pending = await createSellerPushDevice(prisma);
await createSellerPushDelivery(prisma, {
source_event_id: EVENT_ID,
push_device_id: done.id,
status: 'TICKET_OK',
ticket_id: 't-1',
sent_at: AT,
});
await createSellerPushDelivery(prisma, {
source_event_id: EVENT_ID,
push_device_id: pending.id,
});

const rows = await repo.claim(EVENT_ID, [done.id, pending.id]);

expect(rows.map((r) => r.push_device_id)).toEqual([pending.id]);
expect(await prisma.sellerPushDelivery.count()).toBe(2);
});

it('다른 이벤트의 행은 선점에 영향을 주지 않는다', async () => {
const device = await createSellerPushDevice(prisma);
await createSellerPushDelivery(prisma, {
source_event_id: '22222222-2222-4222-8222-222222222222',
push_device_id: device.id,
status: 'TICKET_OK',
});

const rows = await repo.claim(EVENT_ID, [device.id]);

expect(rows).toHaveLength(1);
expect(await prisma.sellerPushDelivery.count()).toBe(2);
});

it('반증: 없는 디바이스 id는 FK 오류로 던진다(skipDuplicates였다면 조용히 0건)', async () => {
await expect(repo.claim(EVENT_ID, [424242n])).rejects.toThrow();
expect(await prisma.sellerPushDelivery.count()).toBe(0);
});

it('디바이스가 없으면 빈 배열', async () => {
expect(await repo.claim(EVENT_ID, [])).toEqual([]);
});
});

describe('markTickets', () => {
it('ticket 결과를 행마다 기록한다', async () => {
const ok = await createSellerPushDelivery(prisma);
const failed = await createSellerPushDelivery(prisma);

await repo.markTickets([
{ id: ok.id, status: 'TICKET_OK', ticketId: 't-ok', sentAt: AT },
{
id: failed.id,
status: 'TICKET_ERROR',
errorCode: 'DeviceNotRegistered',
sentAt: AT,
},
]);

expect(
await prisma.sellerPushDelivery.findUnique({ where: { id: ok.id } }),
).toMatchObject({
status: 'TICKET_OK',
ticket_id: 't-ok',
error_code: null,
sent_at: AT,
});
expect(
await prisma.sellerPushDelivery.findUnique({
where: { id: failed.id },
}),
).toMatchObject({
status: 'TICKET_ERROR',
ticket_id: null,
error_code: 'DeviceNotRegistered',
sent_at: AT,
});
});
});

describe('listForReceipt', () => {
it('기준 시각 이전에 보낸 미확인 TICKET_OK 행만 오래된 순으로, limit까지', async () => {
const old = await createSellerPushDelivery(prisma, {
status: 'TICKET_OK',
ticket_id: 't-old',
sent_at: new Date('2026-10-05T11:00:00.000Z'),
});
const older = await createSellerPushDelivery(prisma, {
status: 'TICKET_OK',
ticket_id: 't-older',
sent_at: new Date('2026-10-05T10:00:00.000Z'),
});
await createSellerPushDelivery(prisma, {
status: 'TICKET_OK',
ticket_id: 't-recent',
sent_at: AT,
});
await createSellerPushDelivery(prisma, {
status: 'TICKET_OK',
ticket_id: 't-checked',
sent_at: new Date('2026-10-05T10:00:00.000Z'),
receipt_checked_at: AT,
});
await createSellerPushDelivery(prisma, {
status: 'TICKET_ERROR',
error_code: 'MessageTooBig',
sent_at: new Date('2026-10-05T10:00:00.000Z'),
});

const rows = await repo.listForReceipt({ sentBefore: AT, limit: 10 });
expect(rows.map((r) => r.id)).toEqual([older.id, old.id]);
expect(rows[0]).toEqual({
id: older.id,
push_device_id: older.push_device_id,
ticket_id: 't-older',
sent_at: older.sent_at,
});

const limited = await repo.listForReceipt({ sentBefore: AT, limit: 1 });
expect(limited.map((r) => r.id)).toEqual([older.id]);
});
});

describe('markReceipts', () => {
it('영수증 결과와 확인 시각을 기록하고, 오류가 아니면 error_code를 건드리지 않는다', async () => {
const ok = await createSellerPushDelivery(prisma, {
status: 'TICKET_OK',
ticket_id: 't-1',
});
const failed = await createSellerPushDelivery(prisma, {
status: 'TICKET_OK',
ticket_id: 't-2',
});
const unknown = await createSellerPushDelivery(prisma, {
status: 'TICKET_OK',
ticket_id: 't-3',
});

await repo.markReceipts(
[
{ id: ok.id, status: 'RECEIPT_OK' },
{
id: failed.id,
status: 'RECEIPT_ERROR',
errorCode: 'MessageTooBig',
},
{ id: unknown.id, status: 'RECEIPT_UNKNOWN' },
],
AT,
);

const byId = async (id: bigint) =>
prisma.sellerPushDelivery.findUnique({ where: { id } });
expect(await byId(ok.id)).toMatchObject({
status: 'RECEIPT_OK',
error_code: null,
receipt_checked_at: AT,
});
expect(await byId(failed.id)).toMatchObject({
status: 'RECEIPT_ERROR',
error_code: 'MessageTooBig',
receipt_checked_at: AT,
});
expect(await byId(unknown.id)).toMatchObject({
status: 'RECEIPT_UNKNOWN',
error_code: null,
receipt_checked_at: AT,
});
});

it('빈 결과는 아무것도 하지 않는다', async () => {
await expect(repo.markReceipts([], AT)).resolves.toBeUndefined();
await expect(repo.markTickets([])).resolves.toBeUndefined();
});
});
});
Loading
Loading