import type { NotificationDisplayRequest, NotificationPresenter, JsonObject, JsonValue, NetworkClient, PushNotificationAction, PushNotificationPayload, UserInfo, } from "../../types"; import { CONSTANTS } from "../../constants"; import type { Logger } from "../../utils/logger"; type NotificationEventStatus = "received" | "click" | "reply"; interface PushManagerConfig { apiKey: string; fetcher?: NetworkClient; getJwt?: () => string | null | undefined; onJwtRefresh?: () => Promise; onReinit?: () => Promise; notificationUrl?: string; notificationPresenter?: NotificationPresenter; organisationId?: string; getUserInfo?: () => UserInfo | null; } const NOTIFICATION_EVENT_PROVIDER = "FCM"; export class PushManager { constructor( private readonly config: PushManagerConfig, private readonly logger: Logger, ) {} async handleNotificationReceived( payload: PushNotificationPayload, ): Promise { this.logger.info("Processing push received event", { campaignId: payload.campaignId, messageId: payload.messageId, provider: payload.provider, }); await this.sendNotificationEvent("received", payload); } async handleNotificationOpened( payload: PushNotificationPayload, ): Promise { this.logger.info("Processing push opened event", { campaignId: payload.campaignId, messageId: payload.messageId, provider: payload.provider, }); await this.sendNotificationEvent("click", payload); } async handleNotificationActionClicked( payload: PushNotificationPayload, action?: PushNotificationAction, ): Promise { this.logger.info("Processing push action click event", { campaignId: payload.campaignId, messageId: payload.messageId, actionId: action?.id, actionLabel: action?.label ?? action?.title, }); await this.sendNotificationEvent("reply", payload, action); } async showNotification( payload: PushNotificationPayload, source: NotificationDisplayRequest["source"] = "manual", ): Promise { if (!this.config.notificationPresenter) { this.logger.warn( "No notification presenter configured. Skipping display.", { source, campaignId: payload.campaignId, messageId: payload.messageId, }, ); return; } this.logger.info("Showing notification", { source, campaignId: payload.campaignId, messageId: payload.messageId, hasImage: Boolean(payload.imageUrl), hasGif: Boolean(payload.gifUrl), hasVideo: Boolean(payload.videoUrl), actionCount: payload.actions?.length ?? 0, }); await this.config.notificationPresenter.show({ payload, source, }); this.logger.info("Notification display request completed", { source, campaignId: payload.campaignId, messageId: payload.messageId, }); } private async sendNotificationEvent( status: NotificationEventStatus, payload: PushNotificationPayload, action?: PushNotificationAction, ): Promise { if (!this.config.notificationUrl) { this.logger.warn("Notification event URL missing. Push event not sent.", { status, campaignId: payload.campaignId, messageId: payload.messageId, }); return; } const requestBody = this.buildNotificationEventPayload( status, payload, action, ); await this.dispatchNotificationEvent(status, payload, requestBody); } private async dispatchNotificationEvent( status: NotificationEventStatus, payload: PushNotificationPayload, requestBody: JsonObject, retriedAfterAuthRecovery = false, ): Promise { const notificationUrl = this.config.notificationUrl; if (!notificationUrl) { this.logger.warn("Notification event URL missing. Push event not sent.", { status, campaignId: payload.campaignId, messageId: payload.messageId, }); return; } const headers = this.buildNotificationEventHeaders(); this.logger.info("Sending notification event request", { endpoint: notificationUrl, headers: this.redactHeaders(headers), body: requestBody, }); try { const response = await (this.config.fetcher ?? fetch)(notificationUrl, { method: "POST", headers, body: JSON.stringify(requestBody), }); if (!response.ok) { const responseBody = await response.text().catch(() => ""); const recovered = await this.recoverAuthAndRetryIfNeeded( response.status, responseBody, status, payload, requestBody, retriedAfterAuthRecovery, ); if (recovered) { return; } this.logger.warn("Notification event API rejected event", { status, statusCode: response.status, campaignId: payload.campaignId, messageId: payload.messageId, responseBody, }); return; } this.logger.info("Notification event delivered", { status, campaignId: payload.campaignId, messageId: payload.messageId, }); } catch (error) { this.logger.warn("Notification event API failed", error); } } private async recoverAuthAndRetryIfNeeded( statusCode: number, responseBody: string, status: NotificationEventStatus, payload: PushNotificationPayload, requestBody: JsonObject, retriedAfterAuthRecovery: boolean, ): Promise { if ( retriedAfterAuthRecovery || !this.isJwtExpiredResponse(statusCode, responseBody) ) { return false; } if (this.config.onJwtRefresh) { this.logger.warn("Notification event JWT expired. Attempting refresh.", { status, campaignId: payload.campaignId, messageId: payload.messageId, }); const newJwt = await this.config.onJwtRefresh().catch((error) => { this.logger.warn("Notification event JWT refresh failed", error); return null; }); if (newJwt) { this.logger.info( "Notification event JWT refreshed. Retrying request.", { status, campaignId: payload.campaignId, messageId: payload.messageId, }, ); await this.dispatchNotificationEvent( status, payload, requestBody, true, ); return true; } } if (this.config.onReinit) { this.logger.warn( "Notification event JWT refresh failed. Reinitializing SDK before retry.", { status, campaignId: payload.campaignId, messageId: payload.messageId, }, ); await this.config.onReinit(); await this.dispatchNotificationEvent(status, payload, requestBody, true); return true; } return false; } private isJwtExpiredResponse( statusCode: number, responseBody: string, ): boolean { if (statusCode !== 401) { return false; } try { const payload = JSON.parse(responseBody) as { code?: unknown; message?: unknown; }; return ( payload.code === "JWT_EXPIRED" || (typeof payload.message === "string" && payload.message.toLowerCase().includes("jwt expired")) ); } catch { return responseBody.toLowerCase().includes("jwt expired"); } } private buildNotificationEventHeaders(): Record { const headers: Record = { ...CONSTANTS.requestHeaders, "Content-Type": "application/json", }; const jwt = this.config.getJwt?.(); if (jwt) { headers.Authorization = `Bearer ${jwt}`; } if (this.config.apiKey) { headers["x-api-key"] = this.config.apiKey; } return headers; } private redactHeaders( headers: Record, ): Record { return Object.fromEntries( Object.entries(headers).map(([key, value]) => { const normalizedKey = key.toLowerCase(); if ( normalizedKey === "authorization" || normalizedKey === "x-api-key" ) { return [key, value ? "***" : value]; } return [key, value]; }), ); } private buildNotificationEventPayload( status: NotificationEventStatus, payload: PushNotificationPayload, action?: PushNotificationAction, ): JsonObject { const rawPayload = { ...(payload.rawPayload ?? {}), ...(payload.data ?? {}), }; const userInfo = this.config.getUserInfo?.() ?? null; const eventPayload = this.buildNotificationEventMetadata( rawPayload, status, ); const requestBody: JsonObject = { provider: NOTIFICATION_EVENT_PROVIDER, status, }; if (eventPayload) { requestBody.payload = eventPayload; } this.assignValue( requestBody, "device", rawPayload["device"] ?? "unknown", ); this.assignValue( requestBody, "conversationId", this.firstRawString( rawPayload, "conversationId", "conversation_id", "sirrusConversationId", "sirrus_conversation_id", "logId", "log_id", ), ); this.assignValue( requestBody, "organisationId", this.firstRawString( rawPayload, "organisationId", "organizationId", "organisation_id", "organization_id", "orgId", "org_id", ) ?? this.config.organisationId, ); this.assignValue( requestBody, "journeyId", this.firstRawString(rawPayload, "journeyId", "journey_id"), ); this.assignValue( requestBody, "campaignId", this.firstRawString(rawPayload, "campaignId", "campaign_id") ?? payload.campaignId, ); this.assignValue( requestBody, "projectId", this.firstRawString( rawPayload, "projectId", "project_id", "sirrusProjectId", "sirrus_project_id", ), ); this.assignValue( requestBody, "nodeId", this.firstRawString(rawPayload, "nodeId", "node_id"), ); this.assignValue( requestBody, "journeyInstaceId", this.firstRawString( rawPayload, "journeyInstaceId", "journeyInstanceId", "journey_instace_id", "journey_instance_id", ), ); this.assignValue( requestBody, "profileId", this.firstRawString(rawPayload, "profileId", "profile_id"), ); this.assignValue( requestBody, "messageId", this.firstRawString( rawPayload, "sirrus_message_id", "sirrusMessageId", "messageId", "message_id", ) ?? payload.messageId, ); if (action) { this.assignValue( requestBody, "value", action.postback ?? action.label ?? action.title ?? action.id, ); this.assignValue( requestBody, "label", action.label ?? action.title ?? action.id, ); this.assignValue(requestBody, "actionId", action.id); this.assignValue(requestBody, "actionType", action.actionType); } return requestBody; } private buildNotificationEventMetadata( rawPayload: JsonObject, status: NotificationEventStatus, ): JsonObject | undefined { const eventPayload = this.jsonObjectValue(rawPayload.payload); if (!eventPayload) { return undefined; } if ( (status === "click" || status === "reply") && eventPayload.clickCount === undefined ) { eventPayload.clickCount = 1; } return eventPayload; } private jsonObjectValue( value: JsonValue | undefined, ): JsonObject | undefined { if (!value) { return undefined; } if (typeof value === "string") { try { const parsed = JSON.parse(value) as JsonValue; return this.jsonObjectValue(parsed); } catch { return undefined; } } if (typeof value === "object" && !Array.isArray(value)) { return { ...value }; } return undefined; } private firstRawString( payload: JsonObject, ...keys: string[] ): string | undefined { for (const key of keys) { const value = payload[key]; if (typeof value === "string" && value.trim().length > 0) { return value.trim(); } if (typeof value === "number" || typeof value === "boolean") { return String(value); } } return undefined; } private assignValue( target: JsonObject, key: string, value: JsonValue | undefined, ): void { if (value === undefined || value === "") { return; } target[key] = value; } }