본문으로 건너뛰기

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로 PushDeliveryTrackingPushDeliveryReport를 매칭한다.

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 시 보고 스킵 확인