import { Injectable, Logger } from "@nestjs/common"; import { HttpService } from "@nestjs/axios"; import { ConfigService } from "@nestjs/config"; import { firstValueFrom } from "rxjs"; import { PrismaService } from "../prisma/prisma.service"; /** * SMS/WhatsApp Message Templates * Localized for African markets */ const MESSAGE_TEMPLATES = { // Booking request notification bookingRequest: { en: (data: { podcastName: string; message: string; actionUrl: string }) => `🎙️ New booking request from ${data.podcastName}!\n\n"${data.message.substring(0, 100)}..."\n\nReply YES to accept or visit: ${data.actionUrl}`, sw: (data: { podcastName: string; message: string; actionUrl: string }) => `🎙️ Ombi jipya la kuhudhuria kutoka ${data.podcastName}!\n\n"${data.message.substring(0, 100)}..."\n\nJibu NDIO kukubali au tembelea: ${data.actionUrl}`, yo: (data: { podcastName: string; message: string; actionUrl: string }) => `🎙️ Ìbéèrè tuntun láti ${data.podcastName}!\n\n"${data.message.substring(0, 100)}..."\n\nDáhùn BÉÈNI láti gbà tàbí ṣàbẹ̀wò: ${data.actionUrl}`, zu: (data: { podcastName: string; message: string; actionUrl: string }) => `🎙️ Isicelo esisha esivela ku-${data.podcastName}!\n\n"${data.message.substring(0, 100)}..."\n\nPhendula YE ukuvuma noma uvakashele: ${data.actionUrl}`, }, // Booking accepted notification bookingAccepted: { en: (data: { guestName: string; podcastName: string }) => `✅ ${data.guestName} has accepted your booking request for ${data.podcastName}! Check your email for next steps.`, sw: (data: { guestName: string; podcastName: string }) => `✅ ${data.guestName} amekubali ombi lako la kuhudhuria kwa ${data.podcastName}! Angalia barua pepe yako kwa hatua zinazofuata.`, yo: (data: { guestName: string; podcastName: string }) => `✅ ${data.guestName} ti gba ibeere rẹ fun ${data.podcastName}! Ṣayẹwo imeeli rẹ fun awọn igbesẹ atẹle.`, zu: (data: { guestName: string; podcastName: string }) => `✅ U-${data.guestName} usamukele isicelo sakho sokubhukha se-${data.podcastName}! Bheka i-imeyli yakho ukuze uthole izinyathelo ezilandelayo.`, }, // Booking declined notification bookingDeclined: { en: (data: { guestName: string; podcastName: string }) => `❌ ${data.guestName} is not available for ${data.podcastName} at this time. Try another guest from the network.`, sw: (data: { guestName: string; podcastName: string }) => `❌ ${data.guestName} hapatikani kwa ${data.podcastName} kwa sasa. Jaribu mgeni mwingine kutoka kwa mtandao.`, yo: (data: { guestName: string; podcastName: string }) => `❌ ${data.guestName} ko si fun ${data.podcastName} ni akoko yii. Gbiyanju alejo miiran lati nẹtiwọọki.`, zu: (data: { guestName: string; podcastName: string }) => `❌ U-${data.guestName} akatholakali ku-${data.podcastName} okwamanje. Zama enye isivakashi kusuka kunethiwekhi.`, }, // Reminder notification reminder: { en: (data: { type: string; time: string; actionUrl: string }) => `⏰ Reminder: ${data.type} scheduled for ${data.time}. ${data.actionUrl}`, sw: (data: { type: string; time: string; actionUrl: string }) => `⏰ Ukumbusho: ${data.type} umepangwa kwa ${data.time}. ${data.actionUrl}`, yo: (data: { type: string; time: string; actionUrl: string }) => `⏰ Ìránnilétí: ${data.type} ti ṣeto fun ${data.time}. ${data.actionUrl}`, zu: (data: { type: string; time: string; actionUrl: string }) => `⏰ Isikhumbuzo: U-${data.type} uhlelelwe u-${data.time}. ${data.actionUrl}`, }, // Profile view notification (premium feature) profileView: { en: (data: { count: number }) => `👀 Your profile has ${data.count} new views this week! Upgrade to see who's interested.`, sw: (data: { count: number }) => `👀 Profaili yako ina maoni ${data.count} mapya wiki hii! Pandisha hadhi ili uone nani ana nia.`, yo: (data: { count: number }) => `👀 Profaili rẹ ni awọn iwo ${data.count} tuntun ni ọsẹ yii! Ṣe igbesoke lati ri ẹni ti o nifẹ.`, zu: (data: { count: number }) => `👀 Iphrofayili yakho inokubukwa okusha oku-${data.count} kuleli sonto! Thuthukisa ukuze ubone ukuthi ubani onentshisekelo.`, }, // USSD fallback for basic phones ussdPrompt: { en: () => `MicProtocol Network: Press 1 for pending requests, 2 for new bookings, 3 for your stats`, sw: () => `Mtandao wa MicProtocol: Bonyeza 1 kwa maombi yaliyosubiri, 2 kwa uhifadhi mpya, 3 kwa takwimu zako`, yo: () => `Nẹtiwọọki MicProtocol: Tẹ 1 fun awọn ibeere isunmọtẹlẹ, 2 fun awọn ifiṣura tuntun, 3 fun awọn iṣiro rẹ`, zu: () => `Inethiwekhi ye-MicProtocol: Cindezela 1 ukuze uthole izicelo ezilindile, 2 ukuze uthole ukubhukha okusha, 3 ukuze uthole izibalo zakho`, }, }; /** * SMS/WhatsApp Service * * Handles mobile notifications for the African market context: * - WhatsApp Business API integration (primary) * - SMS fallback for non-smartphone users * - USSD codes for feature phones * - Mobile Money integration hooks * - Multi-language support (English, Swahili, Yoruba, Zulu) */ @Injectable() export class SmsService { private readonly logger = new Logger(SmsService.name); private readonly twilioEnabled: boolean; private readonly twilioAccountSid: string | undefined; private readonly twilioAuthToken: string | undefined; private readonly twilioPhoneNumber: string | undefined; private readonly whatsappEnabled: boolean; private readonly africasTalkingEnabled: boolean; private readonly africasTalkingFrom: string | undefined; constructor( private readonly httpService: HttpService, private readonly configService: ConfigService, private readonly prisma: PrismaService, ) { this.twilioEnabled = this.configService.get("TWILIO_ENABLED") || false; this.twilioAccountSid = this.configService.get("TWILIO_ACCOUNT_SID"); this.twilioAuthToken = this.configService.get("TWILIO_AUTH_TOKEN"); this.twilioPhoneNumber = this.configService.get("TWILIO_PHONE_NUMBER"); this.whatsappEnabled = this.configService.get("WHATSAPP_ENABLED") || false; this.africasTalkingEnabled = this.configService.get("AFRICAS_TALKING_ENABLED") || false; this.africasTalkingFrom = this.configService.get("AFRICAS_TALKING_FROM"); } /** * Send booking request notification * Primary channel: WhatsApp, Fallback: SMS */ async sendBookingRequest( to: string, data: { podcastName: string; message: string; actionUrl: string; }, language = "en" ): Promise<{ success: boolean; messageId?: string; channel: string }> { const template = MESSAGE_TEMPLATES.bookingRequest[language as keyof typeof MESSAGE_TEMPLATES.bookingRequest] || MESSAGE_TEMPLATES.bookingRequest.en; const message = template(data); // Try WhatsApp first if (this.whatsappEnabled) { try { const result = await this.sendWhatsApp(to, message); return { success: true, messageId: result.messageId, channel: "whatsapp" }; } catch (error) { this.logger.warn(`WhatsApp failed, falling back to SMS: ${error}`); } } // Fallback to SMS try { const result = await this.sendSMS(to, message); return { success: true, messageId: result.messageId, channel: "sms" }; } catch (error) { this.logger.error(`SMS failed: ${error}`); return { success: false, channel: "none" }; } } /** * Send booking response notification (to host) */ async sendBookingResponse( to: string, data: { guestName: string; podcastName: string; accepted: boolean; }, language = "en" ): Promise<{ success: boolean }> { const template = data.accepted ? MESSAGE_TEMPLATES.bookingAccepted[language as keyof typeof MESSAGE_TEMPLATES.bookingAccepted] || MESSAGE_TEMPLATES.bookingAccepted.en : MESSAGE_TEMPLATES.bookingDeclined[language as keyof typeof MESSAGE_TEMPLATES.bookingDeclined] || MESSAGE_TEMPLATES.bookingDeclined.en; const message = template(data); try { if (this.whatsappEnabled) { await this.sendWhatsApp(to, message); } else { await this.sendSMS(to, message); } return { success: true }; } catch (error) { this.logger.error(`Failed to send booking response: ${error}`); return { success: false }; } } /** * Send reminder notification */ async sendReminder( to: string, data: { type: string; time: string; actionUrl: string; }, language = "en" ): Promise<{ success: boolean }> { const template = MESSAGE_TEMPLATES.reminder[language as keyof typeof MESSAGE_TEMPLATES.reminder] || MESSAGE_TEMPLATES.reminder.en; const message = template(data); try { if (this.whatsappEnabled) { await this.sendWhatsApp(to, message); } else { await this.sendSMS(to, message); } return { success: true }; } catch (error) { this.logger.error(`Failed to send reminder: ${error}`); return { success: false }; } } /** * Send profile view notification (weekly digest) */ async sendProfileViewNotification( to: string, viewCount: number, language = "en" ): Promise<{ success: boolean }> { const template = MESSAGE_TEMPLATES.profileView[language as keyof typeof MESSAGE_TEMPLATES.profileView] || MESSAGE_TEMPLATES.profileView.en; const message = template({ count: viewCount }); try { // This is a non-urgent notification, use SMS to save costs await this.sendSMS(to, message); return { success: true }; } catch (error) { this.logger.error(`Failed to send view notification: ${error}`); return { success: false }; } } /** * Send WhatsApp message via Twilio WhatsApp Business API */ private async sendWhatsApp( to: string, message: string ): Promise<{ messageId: string }> { if (!this.twilioEnabled || !this.twilioAccountSid || !this.twilioAuthToken) { this.logger.log(`[WHATSAPP MOCK] To: ${to}, Message: ${message.substring(0, 50)}...`); return { messageId: `mock_${Date.now()}` }; } // Format number for WhatsApp (E.164) const formattedTo = this.formatPhoneNumber(to, true); const from = `whatsapp:${this.twilioPhoneNumber}`; try { const response = await firstValueFrom( this.httpService.post( `https://api.twilio.com/2010-04-01/Accounts/${this.twilioAccountSid}/Messages.json`, new URLSearchParams({ To: formattedTo, From: from, Body: message, }), { auth: { username: this.twilioAccountSid, password: this.twilioAuthToken, }, } ) ); return { messageId: (response.data as any).sid }; } catch (error) { this.logger.error("WhatsApp send failed", error); throw error; } } /** * Send SMS via Twilio or Africa's Talking */ private async sendSMS( to: string, message: string ): Promise<{ messageId: string }> { if (this.africasTalkingEnabled) { return this.sendSMSViaAfricasTalking(to, message); } if (!this.twilioEnabled || !this.twilioAccountSid || !this.twilioAuthToken) { this.logger.log(`[SMS MOCK] To: ${to}, Message: ${message.substring(0, 50)}...`); return { messageId: `mock_${Date.now()}` }; } const formattedTo = this.formatPhoneNumber(to, false); try { const response = await firstValueFrom( this.httpService.post( `https://api.twilio.com/2010-04-01/Accounts/${this.twilioAccountSid}/Messages.json`, new URLSearchParams({ To: formattedTo, From: this.twilioPhoneNumber!, Body: message, }), { auth: { username: this.twilioAccountSid, password: this.twilioAuthToken, }, } ) ); return { messageId: (response.data as any).sid }; } catch (error) { this.logger.error("SMS send failed", error); throw error; } } /** * Send SMS via Africa's Talking (optimized for African markets) */ private async sendSMSViaAfricasTalking( to: string, message: string ): Promise<{ messageId: string }> { const apiKey = this.configService.get("AFRICAS_TALKING_API_KEY"); const username = this.configService.get("AFRICAS_TALKING_USERNAME") || "sandbox"; if (!apiKey) { this.logger.log(`[AFRICAS_TALKING MOCK] To: ${to}, Message: ${message.substring(0, 50)}...`); return { messageId: `mock_at_${Date.now()}` }; } const formattedTo = this.formatPhoneNumber(to, false); const isSandbox = username.toLowerCase() === "sandbox"; const url = isSandbox ? "https://api.sandbox.africastalking.com/version1/messaging" : "https://api.africastalking.com/version1/messaging"; const params: Record = { username, to: formattedTo, message, }; if (this.africasTalkingFrom) { params.from = this.africasTalkingFrom; } try { const response = await firstValueFrom( this.httpService.post( url, new URLSearchParams(params), { headers: { apiKey, Accept: "application/json", }, } ) ); const data = response.data as any; return { messageId: data.SMSMessageData.Recipients[0].messageId }; } catch (error) { this.logger.error("Africa's Talking SMS failed", error); throw error; } } /** * Format phone number to E.164 */ private formatPhoneNumber(phone: string, forWhatsApp: boolean): string { // Remove all non-numeric characters let cleaned = phone.replace(/\D/g, ""); // Add country code if missing (assume African numbers) if (cleaned.length < 10) { // Probably a local number, need to add country code // Default to Kenya (+254) if starts with 0 if (cleaned.startsWith("0")) { cleaned = "254" + cleaned.substring(1); } } // Ensure it starts with + if (!cleaned.startsWith("+")) { cleaned = "+" + cleaned; } // WhatsApp format requires "whatsapp:" prefix if (forWhatsApp) { return `whatsapp:${cleaned}`; } return cleaned; } /** * Detect language based on phone number country code */ detectLanguage(phoneNumber: string): string { const countryCode = this.extractCountryCode(phoneNumber); const languageMap: Record = { "254": "sw", // Kenya - Swahili "255": "sw", // Tanzania - Swahili "234": "yo", // Nigeria - Yoruba (or Igbo, Hausa) "233": "en", // Ghana - English "27": "zu", // South Africa - Zulu (or Afrikaans, Xhosa) "256": "en", // Uganda - English "250": "en", // Rwanda - English "251": "en", // Ethiopia - English "20": "en", // Egypt - English "212": "en", // Morocco - English }; return languageMap[countryCode] || "en"; } /** * Extract country code from phone number */ private extractCountryCode(phone: string): string { const cleaned = phone.replace(/\D/g, ""); // Try common African country codes (longer first) const codes = ["254", "255", "234", "233", "27", "256", "250", "251", "20", "212"]; for (const code of codes) { if (cleaned.startsWith(code) || cleaned.startsWith("0" + code)) { return code; } } // Try with + prefix removed const withoutPlus = cleaned.replace(/^\+/, ""); for (const code of codes) { if (withoutPlus.startsWith(code)) { return code; } } return ""; } /** * Validate phone number format */ validatePhoneNumber(phone: string): boolean { // Basic validation for African phone numbers const cleaned = phone.replace(/\D/g, ""); // Should be 9-15 digits (including country code) if (cleaned.length < 9 || cleaned.length > 15) { return false; } // Check for valid African country codes const validCodes = ["254", "255", "234", "233", "27", "256", "250", "251", "20", "212", "267", "265", "258", "260"]; const withoutPlus = cleaned.replace(/^\+/, ""); return validCodes.some(code => withoutPlus.startsWith(code)); } /** * Process USSD request * For feature phone users: *384*88# */ async processUSSD( sessionId: string, phoneNumber: string, text: string ): Promise { this.logger.log(`USSD request from ${phoneNumber}: ${text}`); const lang = this.detectLanguage(phoneNumber); const promptTemplate = MESSAGE_TEMPLATES.ussdPrompt[lang as keyof typeof MESSAGE_TEMPLATES.ussdPrompt] || MESSAGE_TEMPLATES.ussdPrompt.en; const responses = { "": promptTemplate(), "1": await this.getPendingRequestsUSSD(phoneNumber), "2": "CON New bookings:\n1. Search guests\n2. My bookmarks\n3. Back", "3": await this.getStatsUSSD(phoneNumber), }; return responses[text as keyof typeof responses] || responses[""]; } /** * Get pending requests for USSD display */ private async getPendingRequestsUSSD(phoneNumber: string): Promise { try { // Find profile by phone number const profile = await this.prisma.guestPublicProfile.findFirst({ where: { OR: [ { phoneNumber: { contains: phoneNumber.replace(/\D/g, "") } }, { whatsappNumber: { contains: phoneNumber.replace(/\D/g, "") } }, ], }, select: { id: true }, }); if (!profile) { return "END Profile not found. Please register at https://MicProtocol.io/network"; } // Get pending booking requests const pendingRequests = await this.prisma.networkBookingRequest.findMany({ where: { toProfileId: profile.id, status: "pending", }, include: { fromPodcast: { select: { title: true }, }, }, take: 3, orderBy: { createdAt: "desc" }, }); if (pendingRequests.length === 0) { return "CON You have no pending requests.\n0. Back"; } let response = `CON You have ${pendingRequests.length} pending request(s):\n`; pendingRequests.forEach((req, idx) => { response += `${idx + 1}. ${req.fromPodcast.title.substring(0, 20)}\n`; }); response += "0. Back"; return response; } catch (error) { this.logger.error(`Failed to get pending requests for USSD: ${error}`); return "END Error loading requests. Please try again later."; } } /** * Get stats for USSD display */ private async getStatsUSSD(phoneNumber: string): Promise { try { // Find profile by phone number const profile = await this.prisma.guestPublicProfile.findFirst({ where: { OR: [ { phoneNumber: { contains: phoneNumber.replace(/\D/g, "") } }, { whatsappNumber: { contains: phoneNumber.replace(/\D/g, "") } }, ], }, select: { id: true, totalAppearances: true, reliabilityScore: true, }, }); if (!profile) { return "END Profile not found. Please register at https://MicProtocol.io/network"; } // Get profile views from last 30 days const viewsLastMonth = await this.prisma.profileView.count({ where: { profileId: profile.id, viewedAt: { gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), }, }, }); const rating = profile.reliabilityScore ? (Number(profile.reliabilityScore) / 20).toFixed(1) : "N/A"; return `CON Your stats:\n` + `Appearances: ${profile.totalAppearances}\n` + `Views (30d): ${viewsLastMonth}\n` + `Rating: ${rating}/5\n` + `0. Back`; } catch (error) { this.logger.error(`Failed to get stats for USSD: ${error}`); return "END Error loading stats. Please try again later."; } } /** * Get service health status */ getHealth(): { whatsapp: boolean; sms: boolean; ussd: boolean; } { return { whatsapp: this.whatsappEnabled && !!this.twilioAccountSid, sms: (this.twilioEnabled && !!this.twilioAccountSid) || this.africasTalkingEnabled, ussd: true, // USSD is stateless }; } }