본문으로 건너뛰기

Phase 1: Push Delivery Reporter Service + Hook Points

참조 패턴: AgentzProxyService.notifySessionEnd() (best-effort HTTP), ChannelAnalyticsService.recordPushSent() (fire-and-forget) 변경 파일: 1 NEW + 3 EDIT

변경 파일​

파일유형설명
apps/dha-sleep-api/src/app/push/push-delivery-reporter.service.tsNEWPlatform 보고 서비스
apps/dha-sleep-api/src/app/push/push-job.processor.tsEDITCOMPLETED/FAILED/CANCELLED 보고 hook
apps/dha-sleep-api/src/app/push/push-scheduler.service.tsEDITSCHEDULED 보고 hook
apps/dha-sleep-api/src/app/push/push.module.tsEDITReporter 서비스 등록

1-1. Push Delivery Reporter Service [NEW]​

파일: apps/dha-sleep-api/src/app/push/push-delivery-reporter.service.ts

역할: agentz-studio platform에 push delivery 상태를 보고하는 best-effort HTTP 서비스

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { LoggerService } from '@core/logging';

export interface PushDeliveryReportParams {
pushId: string;
status: 'SCHEDULED' | 'COMPLETED' | 'FAILED' | 'CANCELLED';
timestamp: Date;
pushType?: string;
sessionId?: string | null;
userId?: string;
channel?: string;
error?: string;
retryCount?: number;
scheduledAt?: Date;
metadata?: Record<string, unknown>;
}

/**
* Push Delivery Reporter Service
*
* Reports push delivery status to agentz-studio platform.
* Best-effort: failure does not propagate to caller.
*
* Endpoint: POST {agentzApiUrl}/push-delivery/report
* Auth: x-api-key header (same key used for chat/invoke calls)
*
* @see AgentzProxyService.notifySessionEnd() — same best-effort HTTP pattern
* @see agentz-studio Plan 120 — Push Delivery Monitoring Platform
*/
@Injectable()
export class PushDeliveryReporterService {
private readonly enabled: boolean;
private readonly apiUrl: string;
private readonly apiKey: string;

constructor(
private readonly config: ConfigService,
private readonly logger: LoggerService,
) {
this.logger.setContext(PushDeliveryReporterService.name);
this.enabled = this.config.get<boolean>('agentz.enabled', false);
this.apiUrl = this.config.get<string>('agentz.apiUrl', '');
this.apiKey = this.config.get<string>('agentz.apiKey', '');
}

/**
* Report push delivery status to platform.
*
* Best-effort: catches all errors internally.
* Called as fire-and-forget from PushJobProcessor and PushSchedulerService.
*/
async report(params: PushDeliveryReportParams): Promise<void> {
if (!this.enabled || !this.apiUrl || !this.apiKey) {
this.logger.debug('Push delivery reporting disabled or not configured');
return;
}

const baseUrl = this.apiUrl.endsWith('/') ? this.apiUrl.slice(0, -1) : this.apiUrl;
const url = `${baseUrl}/push-delivery/report`;

const body = {
pushId: params.pushId,
status: params.status,
timestamp: params.timestamp.toISOString(),
...(params.pushType && { pushType: params.pushType }),
...(params.sessionId && { sessionId: params.sessionId }),
...(params.userId && { userId: params.userId }),
...(params.channel && { channel: params.channel }),
...(params.error && { error: params.error }),
...(params.retryCount !== undefined && { retryCount: params.retryCount }),
...(params.scheduledAt && { scheduledAt: params.scheduledAt.toISOString() }),
...(params.metadata && { metadata: params.metadata }),
};

try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': this.apiKey,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(5_000), // 5s timeout
});

if (response.ok) {
this.logger.debug('Push delivery reported', {
pushId: params.pushId,
status: params.status,
});
} else {
const responseBody = await response.text().catch(() => 'N/A');
this.logger.warn('Push delivery report failed', {
pushId: params.pushId,
status: params.status,
httpStatus: response.status,
body: responseBody,
});
}
} catch (error) {
// Best-effort: do not propagate failure
this.logger.warn('Push delivery report error (non-fatal)', {
pushId: params.pushId,
status: params.status,
error: error instanceof Error ? error.message : String(error),
});
}
}
}

참조 패턴: AgentzProxyService.notifySessionEnd() (lines 297-340) — 동일한 best-effort fetch + AbortSignal.timeout 패턴


1-2. PushJobProcessor — COMPLETED/FAILED/CANCELLED 보고 Hook [EDIT]​

파일: apps/dha-sleep-api/src/app/push/push-job.processor.ts

역할: push 상태 변경 시점에 reporter 호출 추가

변경 1: Import + DI 추가​

// 변경 전 (line 16):
import { ChannelAnalyticsService } from '../chat/services/channel-analytics.service';

// 변경 후:
import { ChannelAnalyticsService } from '../chat/services/channel-analytics.service';
import { PushDeliveryReporterService } from './push-delivery-reporter.service';
// 변경 전 (line 27-33):
constructor(
private readonly prisma: PrismaService,
private readonly notificationRouter: NotificationChannelRouterService,
private readonly channelAnalytics: ChannelAnalyticsService,
private readonly logger: LoggerService,
private readonly commandBus: CommandBus,
) {

// 변경 후:
constructor(
private readonly prisma: PrismaService,
private readonly notificationRouter: NotificationChannelRouterService,
private readonly channelAnalytics: ChannelAnalyticsService,
private readonly logger: LoggerService,
private readonly commandBus: CommandBus,
private readonly pushDeliveryReporter: PushDeliveryReporterService,
) {

변경 2: CANCELLED 보고 (user activity 취소 후)​

// 변경 전 (line 85-89):
this.logger.info(`Push ${pushId} cancelled - user recently active within ${PUSH_CANCEL_QUIET_MINUTES}min`, {
userId,
scheduledAt: push.scheduledAt.toISOString(),
});
return;

// 변경 후:
this.logger.info(`Push ${pushId} cancelled - user recently active within ${PUSH_CANCEL_QUIET_MINUTES}min`, {
userId,
scheduledAt: push.scheduledAt.toISOString(),
});

// Report cancellation to platform (fire-and-forget)
this.pushDeliveryReporter.report({
pushId,
status: 'CANCELLED',
timestamp: new Date(),
pushType: type,
sessionId: push.createdFromSessionId,
userId,
scheduledAt: push.scheduledAt,
}).catch(() => { /* best-effort */ });

return;

변경 3: COMPLETED 보고 (발송 성공 후)​

// 변경 전 (line 130-137):
// Record push sent analytics (fire-and-forget)
this.channelAnalytics.recordPushSent({
userId,
pushId,
deliveryChannel: routeResult.channel,
pushType: type,
systemCurrentTime: sentAt,
});

// 변경 후:
// Record push sent analytics (fire-and-forget)
this.channelAnalytics.recordPushSent({
userId,
pushId,
deliveryChannel: routeResult.channel,
pushType: type,
systemCurrentTime: sentAt,
});

// Report delivery to platform (fire-and-forget)
this.pushDeliveryReporter.report({
pushId,
status: 'COMPLETED',
timestamp: sentAt,
pushType: type,
sessionId: push.createdFromSessionId,
userId,
channel: routeResult.channel,
scheduledAt: push.scheduledAt,
metadata: routeResult.messageId
? { messageId: String(routeResult.messageId) }
: undefined,
}).catch(() => { /* best-effort */ });

변경 4: FAILED 보고 (handleSendPush 실패 분기)​

주의: 기존 코드의 retry 동작 분석 결과, handleFailedJob()의 attemptsMade >= 3 가드에 도달하는 것은 사실상 불가능하다. 이유: 첫 번째 실패 시 DB status가 FAILED로 변경(line 153)되고, retry attempt 2에서는 push.status !== SCHEDULED 체크(line 61)에 걸려 성공(return)으로 처리되기 때문이다. 따라서 FAILED 보고는 handleSendPush()의 실패 분기(line 151-171)에서 수행해야 한다.

// 변경 전 (line 151-171):
} else {
// 6b. 실패 시 FAILED로 변경
await this.prisma.scheduledPush.update({
where: { id: pushId },
data: {
status: EngagementStatus.FAILED,
metadata: {
...(push.metadata as object || {}),
failureReason: routeResult.error,
failedChannel: routeResult.channel,
},
},
});

this.logger.error(`Push ${pushId} failed via ${routeResult.channel}: ${routeResult.error}`, undefined, {
userId,
channel: routeResult.channel,
});

// 재시도를 위해 에러 throw
throw new Error(routeResult.error || 'Push send failed');
}

// 변경 후:
} else {
// 6b. 실패 시 FAILED로 변경
await this.prisma.scheduledPush.update({
where: { id: pushId },
data: {
status: EngagementStatus.FAILED,
metadata: {
...(push.metadata as object || {}),
failureReason: routeResult.error,
failedChannel: routeResult.channel,
},
},
});

this.logger.error(`Push ${pushId} failed via ${routeResult.channel}: ${routeResult.error}`, undefined, {
userId,
channel: routeResult.channel,
});

// Report failure to platform (fire-and-forget)
// NOTE: 기존 코드에서 line 153의 status→FAILED + line 171의 throw로 인해
// Bull retry attempt 2에서는 line 61의 status 체크에서 "already processed"로 스킵된다.
// 따라서 이 지점이 FAILED 보고의 유일한 실행 기회이다.
this.pushDeliveryReporter.report({
pushId,
status: 'FAILED',
timestamp: new Date(),
pushType: type,
sessionId: push.createdFromSessionId,
userId,
channel: routeResult.channel,
error: routeResult.error,
scheduledAt: push.scheduledAt,
}).catch(() => { /* best-effort */ });

// 재시도를 위해 에러 throw
throw new Error(routeResult.error || 'Push send failed');
}

handleFailedJob()은 수정하지 않는다. 기존 attemptsMade >= 3 가드의 동작 변경은 이 플랜의 범위 밖이다.


1-3. PushSchedulerService — SCHEDULED 보고 Hook [EDIT]​

파일: apps/dha-sleep-api/src/app/push/push-scheduler.service.ts

역할: push 생성 시 SCHEDULED 상태 보고

변경 1: Import + DI 추가​

// 변경 전 (line 7):
import { PushQueueService } from './push-queue.service';

// 변경 후:
import { PushQueueService } from './push-queue.service';
import { PushDeliveryReporterService } from './push-delivery-reporter.service';
// 변경 전 (line 41-49):
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
private readonly logger: LoggerService,
private readonly pushQueueService: PushQueueService,
private readonly queryBus: QueryBus,
@Inject(SCHEDULED_PUSH_REPOSITORY)
private readonly scheduledPushRepository: ScheduledPushRepository,
) {

// 변경 후:
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
private readonly logger: LoggerService,
private readonly pushQueueService: PushQueueService,
private readonly queryBus: QueryBus,
@Inject(SCHEDULED_PUSH_REPOSITORY)
private readonly scheduledPushRepository: ScheduledPushRepository,
private readonly pushDeliveryReporter: PushDeliveryReporterService,
) {

변경 2: Replace 취소 시 CANCELLED 보고 (schedulePushFromPlan 내)​

근거: 대체된(old) push와 새(new) push는 다른 sessionId를 가진다 (각각 별도 대화에서 생성). Platform sweep은 sessionId + userId + pushType natural key로 correlation하므로, old push를 CANCELLED로 보고하지 않으면 OVERDUE(false positive)로 감지된다. existingPush select에 id, scheduledAt, createdFromSessionId는 있지만 type은 없으므로 select에 추가.

// 변경 전 (line 106-115):
const existingPush = await this.prisma.scheduledPush.findFirst({
where: {
userId,
status: EngagementStatus.SCHEDULED,
scheduledAt: {
gte: new Date(scheduledAtDate.getTime() - windowMs),
lte: new Date(scheduledAtDate.getTime() + windowMs),
},
},
select: { id: true, scheduledAt: true, createdFromSessionId: true },
});

// 변경 후:
const existingPush = await this.prisma.scheduledPush.findFirst({
where: {
userId,
status: EngagementStatus.SCHEDULED,
scheduledAt: {
gte: new Date(scheduledAtDate.getTime() - windowMs),
lte: new Date(scheduledAtDate.getTime() + windowMs),
},
},
select: { id: true, scheduledAt: true, createdFromSessionId: true, type: true },
});
// 변경 전 (line 125-131):
this.logger.info('Replacing existing push with newer context', {
userId,
sessionId: params.sessionId,
cancelledPushId: existingPush.id,
cancelledSessionId: existingPush.createdFromSessionId,
newScheduledAt: pushPlan.scheduledAt,
});

// 변경 후:
this.logger.info('Replacing existing push with newer context', {
userId,
sessionId: params.sessionId,
cancelledPushId: existingPush.id,
cancelledSessionId: existingPush.createdFromSessionId,
newScheduledAt: pushPlan.scheduledAt,
});

// Report replace-cancellation to platform (fire-and-forget)
// old push의 sessionId로 보고해야 platform tracking과 매칭됨
this.pushDeliveryReporter.report({
pushId: existingPush.id,
status: 'CANCELLED',
timestamp: new Date(),
pushType: existingPush.type.toLowerCase(),
sessionId: existingPush.createdFromSessionId,
userId,
scheduledAt: existingPush.scheduledAt,
}).catch(() => { /* best-effort */ });

pushType 변환: existingPush.type은 Prisma enum(EVENING_CHECKIN, SCREAMING_SNAKE_CASE). Platform correlation은 webhook 원본값(evening_checkin, lowercase)과 매칭하므로 .toLowerCase() 적용.

변경 3: SCHEDULED 보고 (saveScheduledPush 후)​

// 변경 전 (line 313-318):
this.logger.debug('Push job registered to Bull', {
pushId: savedPush.id,
scheduledAt: plan.scheduledAt,
delayMs,
});
}
}

// 변경 후:
this.logger.debug('Push job registered to Bull', {
pushId: savedPush.id,
scheduledAt: plan.scheduledAt,
delayMs,
});

// Report SCHEDULED to platform (fire-and-forget)
this.pushDeliveryReporter.report({
pushId: savedPush.id!,
status: 'SCHEDULED',
timestamp: new Date(),
pushType: plan.type,
sessionId: params.sessionId,
userId: params.userId,
scheduledAt: new Date(plan.scheduledAt),
}).catch(() => { /* best-effort */ });
}
}

1-4. Push Module — Reporter 서비스 등록 [EDIT]​

파일: apps/dha-sleep-api/src/app/push/push.module.ts

변경 내용​

// 변경 전 (line 13):
import { PushController } from './push.controller';

// 변경 후:
import { PushController } from './push.controller';
import { PushDeliveryReporterService } from './push-delivery-reporter.service';
// 변경 전 (line 46-55):
providers: [
PushSchedulerService,
PushQueueService,
PushJobProcessor,
ScheduledPushMapper,
{
provide: SCHEDULED_PUSH_REPOSITORY,
useClass: PrismaScheduledPushRepository,
},
],
exports: [PushSchedulerService, PushQueueService],

// 변경 후:
providers: [
PushSchedulerService,
PushQueueService,
PushJobProcessor,
PushDeliveryReporterService,
ScheduledPushMapper,
{
provide: SCHEDULED_PUSH_REPOSITORY,
useClass: PrismaScheduledPushRepository,
},
],
exports: [PushSchedulerService, PushQueueService],

API 요청/응답 명세​

Request​

POST {agentzApiUrl}/push-delivery/report
Headers:
Content-Type: application/json
x-api-key: {agentzApiKey}

Body:
{
"pushId": "376e4d2f-0c6b-4ff1-a14e-2d30f0d0172b", // ScheduledPush.id
"status": "COMPLETED", // SCHEDULED | COMPLETED | FAILED | CANCELLED
"timestamp": "2026-03-20T21:01:00.000Z", // 상태 변경 시점
"pushType": "evening_checkin", // plan.type (lowercase snake_case, webhook 원본과 동일)
"sessionId": "sess_abc123", // ScheduledPush.createdFromSessionId
"userId": "user_12345", // ScheduledPush.userId
"channel": "fcm", // 발송 채널 (COMPLETED 시)
"error": "FCM Token Missing", // 에러 메시지 (FAILED 시)
"retryCount": 3, // 재시도 횟수 (FAILED 시)
"scheduledAt": "2026-03-20T21:00:00.000Z", // 원래 예정 시간
"metadata": { "messageId": "fcm_msg_xxx" } // 추가 메타데이터
}

Response (참고 — platform Plan 120)​

{
"id": "clxyz...",
"status": "COMPLETED",
"reportedAt": "2026-03-20T21:01:00.000Z"
}

보고 매핑 상세​

SCHEDULED 보고​

필드소스설명
pushIdsavedPush.idClient-side ScheduledPush UUID
status'SCHEDULED'고정값
timestampnew Date()보고 시점
pushTypeplan.typeevening_checkin, bedtime_reminder 등
sessionIdparams.sessionIdWebhook의 data.sessionId
userIdparams.userIdWebhook의 data.userId
scheduledAtnew Date(plan.scheduledAt)발송 예정 시간

COMPLETED 보고​

필드소스설명
pushIdjob.data.pushIdClient-side ScheduledPush UUID
status'COMPLETED'고정값
timestampsentAt실제 발송 시점
pushTypejob.data.typePush 타입
sessionIdpush.createdFromSessionIdDB에서 조회
userIdjob.data.userIdJob 데이터
channelrouteResult.channel'fcm', 'telegram', 'kakaotalk'
scheduledAtpush.scheduledAt원래 예정 시간
metadata.messageIdrouteResult.messageId채널별 메시지 ID

CANCELLED 보고​

필드소스설명
pushIdjob.data.pushIdClient-side ScheduledPush UUID
status'CANCELLED'고정값
timestampnew Date()취소 시점
pushTypejob.data.typePush 타입
sessionIdpush.createdFromSessionIdDB에서 조회
userIdjob.data.userIdJob 데이터
scheduledAtpush.scheduledAt원래 예정 시간

FAILED 보고 (handleSendPush 실패 분기에서)​

필드소스설명
pushIdpushId (job.data에서 추출, line 42)Client-side ScheduledPush UUID
status'FAILED'고정값
timestampnew Date()실패 시점
pushTypetype (job.data에서 추출, line 42)Push 타입 (lowercase: evening_checkin)
sessionIdpush.createdFromSessionIdline 51의 findUnique로 이미 조회됨 (추가 DB 조회 불필요)
userIduserId (job.data에서 추출, line 42)Job 데이터
channelrouteResult.channel실패한 채널
errorrouteResult.error채널 라우터가 반환한 에러 메시지
scheduledAtpush.scheduledAtline 51의 findUnique로 이미 조회됨

Platform Tracking Correlation​

Platform sweep은 sessionId + userId + pushType natural key로 PushDeliveryTracking과 PushDeliveryReport를 매칭한다.

Platform PushDeliveryTracking (webhook payload 파싱으로 생성)
sessionId: data.sessionId
userId: data.userId
pushType: data.pushPlan.type

Client PushDeliveryReport (이 서비스가 보고)
sessionId: ScheduledPush.createdFromSessionId ← webhook의 data.sessionId 저장
userId: ScheduledPush.userId ← webhook의 data.userId 저장
pushType: ScheduledPush.type ← webhook의 data.pushPlan.type 매핑

→ Natural key 일치 보장됨

검증 포인트​

  1. 빌드 성공: nx build dha-sleep-api (또는 nx serve dha-sleep-api)
  2. SCHEDULED 보고: Push 생성 시 platform API 호출 로그 확인
  3. COMPLETED 보고: Push 발송 성공 시 platform API 호출 로그 확인
  4. CANCELLED 보고: User activity 취소 시 platform API 호출 로그 확인
  5. FAILED 보고: Push 최종 실패 시 platform API 호출 로그 확인
  6. Best-effort: Platform API 다운 시 push 처리에 영향 없음 확인
  7. 비활성화: agentz.enabled=false 시 보고 스킵 확인