Skip to content
Open
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
81 changes: 81 additions & 0 deletions src/controllers/whatsapp-webhook.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { Body, Controller, HttpCode, HttpStatus, Logger, Post, Req } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Request } from 'express';
import { Auth } from 'src/decorators/auth.decorator';
import { Public } from 'src/decorators/public.decorator';
import { AuthType } from 'src/enums/auth-type.enum';

@Auth(AuthType.None)
@Controller('webhook/whatsapp')
@ApiTags('Solid Core')
export class WhatsappWebhookController {
private readonly logger = new Logger(WhatsappWebhookController.name);

@Public()
@Post()
@HttpCode(HttpStatus.OK)
async receiveWebhook(@Req() req: Request, @Body() body: unknown) {
const userAgent = req.headers['user-agent'] ?? null;
this.logger.log(`Received WhatsApp webhook${userAgent ? ` from ${userAgent}` : ''}`);
this.logger.debug(`WhatsApp webhook payload: ${JSON.stringify(body)}`);

const statusInfo = this.extractStatusInfo(body);
if (statusInfo) {
this.logger.log(
`WhatsApp delivery update: status=${statusInfo.status ?? 'unknown'}, messageId=${statusInfo.messageId ?? 'n/a'}, destination=${statusInfo.destination ?? 'n/a'}, reason=${statusInfo.reason ?? 'n/a'}`,
);
}

// Always acknowledge webhook receipt to avoid provider retries.
return {
success: true,
message: 'Webhook received',
};
}

private extractStatusInfo(body: unknown): {
status?: string;
messageId?: string;
destination?: string;
reason?: string;
} | null {
if (!body || typeof body !== 'object') {
return null;
}

const payload = body as Record<string, unknown>;

const status =
this.asString(payload.status) ||
this.asString(payload.messageStatus) ||
this.asString((payload.payload as Record<string, unknown>)?.status);

const messageId =
this.asString(payload.messageId) ||
this.asString(payload.id) ||
this.asString((payload.payload as Record<string, unknown>)?.id) ||
this.asString((payload.payload as Record<string, unknown>)?.messageId);

const destination =
this.asString(payload.destination) ||
this.asString(payload.phone) ||
this.asString((payload.payload as Record<string, unknown>)?.destination) ||
this.asString((payload.payload as Record<string, unknown>)?.phone);

const reason =
this.asString(payload.reason) ||
this.asString(payload.error) ||
this.asString((payload.payload as Record<string, unknown>)?.reason) ||
this.asString((payload.payload as Record<string, unknown>)?.error);

if (!status && !messageId && !destination && !reason) {
return null;
}

return { status, messageId, destination, reason };
}

private asString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
}
43 changes: 43 additions & 0 deletions src/listeners/user-registration.listener.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { User } from "../entities/user.entity";
import { OnEvent } from "@nestjs/event-emitter";
import { Injectable, Logger } from "@nestjs/common";
import { OnEvent } from "@nestjs/event-emitter";
import { AxiosError } from "axios";
import { WhatsAppFactory } from "src/factories/whatsapp.factory";
import { EventDetails, EventType } from "../interfaces";
import { User } from "../entities/user.entity";
import { WhatsAppFactory } from "src/factories/whatsapp.factory";

@Injectable()
Expand All @@ -14,6 +18,45 @@ export class UserRegistrationListener {
async handleUserRegistration(event: EventDetails<User>) {
this.logger.log(`User registered with details: ${JSON.stringify(event.payload)}`);

const to =
process.env.WHATSAPP_EVENT_NOTIFY_TO ||
process.env.COMMON_WHATSAPP_EVENT_NOTIFY_TO;

if (!to) {
this.logger.debug(
"Skipping WhatsApp registration notification. Set WHATSAPP_EVENT_NOTIFY_TO or COMMON_WHATSAPP_EVENT_NOTIFY_TO.",
);
return;
}

const userName = event?.payload?.username || event?.payload?.email || "User";

try {
const whatsappService = this.whatsAppFactory.getWhatsappService();
await whatsappService.sendWhatsAppMessage(to, null, {
payload: {
channel: "whatsapp",
source:
process.env.COMMON_GUPSHUP_WHATSAPP_SOURCE ||
process.env.GUPSHUP_SOURCE_NUMBER,
destination: to,
"src.name": process.env.COMMON_GUPSHUP_APP_NAME || "solidx",
message: {
type: "text",
text: `New user registered: ${userName}`,
},
},
});

this.logger.log(`Sent registration WhatsApp notification to ${to}`);
} catch (error) {
const axiosError = error as AxiosError;
const status = axiosError.response?.status;
const responseData = axiosError.response?.data;
const genericError = error as Error;

this.logger.error(
`Failed to send registration WhatsApp notification to ${to}. status=${status ?? 'unknown'}, response=${typeof responseData === 'object' ? JSON.stringify(responseData) : responseData}, message=${genericError?.message ?? 'unknown'}, stack=${genericError?.stack ?? 'n/a'}`,
const notifyTo = process.env.WHATSAPP_EVENT_NOTIFY_TO;
if (!notifyTo) {
this.logger.debug("WHATSAPP_EVENT_NOTIFY_TO not set. Skipping registration WhatsApp notification.");
Expand Down
3 changes: 3 additions & 0 deletions src/services/authentication.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,9 @@ export class AuthenticationService {

// Send welcome notifications (email/SMS) if enabled.
await this.notifyUserOnSignup(user);

// Emit registration event for all successful standard signups as well.
this.triggerRegistrationEvent(user);
}

generatePassword(length: number = 8): string {
Expand Down
45 changes: 45 additions & 0 deletions src/services/settings/default-settings-provider.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1218,6 +1218,16 @@ const getSolidCoreSettings = (isProd: boolean) =>
sortOrder: 30,
controlType: "shortText",
},

//gupshup-settings-provider.service.ts
{
moduleName: "solid-core",
key: "gupShupAppName",
value: process.env.COMMON_GUPSHUP_APP_NAME,
level: SettingLevel.SystemAdminReadonly,
label: "Gupshup App Name",
group: "gupshup-settings",
sortOrder: 10,
{
moduleName: "solid-core",
key: "metaWhatsappApiUrl",
Expand All @@ -1230,6 +1240,12 @@ const getSolidCoreSettings = (isProd: boolean) =>
},
{
moduleName: "solid-core",
key: "gupshupApiUrl",
value: process.env.COMMON_GUPSHUP_API_URL,
level: SettingLevel.SystemAdminReadonly,
label: "Gupshup API URL",
group: "gupshup-settings",
sortOrder: 10,
key: "metaWhatsappApiVersion",
value: process.env.COMMON_META_WHATSAPP_API_VERSION || "v23.0",
level: SettingLevel.SystemAdminReadonly,
Expand All @@ -1240,6 +1256,12 @@ const getSolidCoreSettings = (isProd: boolean) =>
},
{
moduleName: "solid-core",
key: "gupshupWhatsappProvider",
value: process.env.COMMON_WHATSAPP_PROVIDER,
level: SettingLevel.SystemAdminReadonly,
label: "Gupshup WhatsApp Provider",
group: "gupshup-settings",
sortOrder: 10,
key: "metaWhatsappPhoneNumberId",
value: process.env.COMMON_META_WHATSAPP_PHONE_NUMBER_ID,
level: SettingLevel.SystemAdminReadonly,
Expand All @@ -1250,6 +1272,12 @@ const getSolidCoreSettings = (isProd: boolean) =>
},
{
moduleName: "solid-core",
key: "gupshupWhatsappApiKey",
value: process.env.COMMON_GUPSHUP_WHATSAPP_API_KEY,
level: SettingLevel.SystemEnv,
label: "Gupshup API Key",
group: "gupshup-settings",
sortOrder: 10,
key: "metaWhatsappBusinessAccountId",
value: process.env.COMMON_META_WHATSAPP_BUSINESS_ACCOUNT_ID,
level: SettingLevel.SystemAdminReadonly,
Expand All @@ -1260,6 +1288,23 @@ const getSolidCoreSettings = (isProd: boolean) =>
},
{
moduleName: "solid-core",
key: "gupshupWhatsappSource",
value: process.env.COMMON_GUPSHUP_WHATSAPP_SOURCE,
level: SettingLevel.SystemEnv,
label: "Gupshup WhatsApp Source",
group: "gupshup-settings",
sortOrder: 10,
controlType: "shortText",
},
{
moduleName: "solid-core",
key: "gupshupNotifyTo",
value: process.env.WHATSAPP_EVENT_NOTIFY_TO,
level: SettingLevel.SystemEnv,
label: "Gupshup Notify To",
group: "gupshup-settings",
sortOrder: 10,
controlType: "shortText",
key: "metaWhatsappAccessToken",
value: process.env.COMMON_META_WHATSAPP_ACCESS_TOKEN,
level: SettingLevel.SystemEnv,
Expand Down
119 changes: 119 additions & 0 deletions src/services/whatsapp/GupshupWhatsappService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { HttpService } from '@nestjs/axios';
import { Injectable, Logger } from '@nestjs/common';
import { AxiosError } from 'axios';
import { QueueMessage } from 'src/interfaces/mq';
import { IWhatsAppTransport } from '../../interfaces';
import { WhatsAppProvider } from 'src/decorators/whatsapp-provider.decorator';

@Injectable()
@WhatsAppProvider()
export class GupshupWhatsappService implements IWhatsAppTransport {
readonly logger = new Logger(GupshupWhatsappService.name);

constructor(private readonly httpService: HttpService) {}

async sendWhatsAppMessage(
to: string,
templateId: string,
parameters: any,
parentEntity?: any,
parentEntityId?: any,
): Promise<any> {
const message = {
payload: {
to,
templateId,
...parameters,
},
parentEntity,
parentEntityId,
};

await this.sendWhatsAppMessageSynchronously(message);
return message;
}

async sendWhatsAppMessageSynchronously(message: QueueMessage<any>): Promise<void> {
const requestBody = this.createWhatsappRequest(message);

const apiKey =
process.env.COMMON_GUPSHUP_WHATSAPP_API_KEY || process.env.GUPSHUP_API_KEY;
const url =
process.env.COMMON_GUPSHUP_WHATSAPP_API_URL || process.env.GUPSHUP_API_URL;

if (!apiKey || !url) {
throw new Error('Missing Gupshup configuration: API key or URL');
}

const isWaEndpoint = url.includes('/wa/api/v1/msg');

try {
if (isWaEndpoint) {
const form = this.toWaFormEncoded(requestBody);
await this.httpService.axiosRef.post(url, form.toString(), {
headers: {
apikey: apiKey,
'Content-Type': 'application/x-www-form-urlencoded',
},
});
} else {
await this.httpService.axiosRef.post(url, requestBody, {
headers: {
apikey: apiKey,
'Content-Type': 'application/json',
},
});
}

this.logger.debug(
`Sent Gupshup WhatsApp message to ${message.payload.to} using ${isWaEndpoint ? 'wa' : 'json'} endpoint`,
);
} catch (error) {
const axiosError = error as AxiosError;
const status = axiosError.response?.status;
const responseData = axiosError.response?.data;

this.logger.error(
`Gupshup send failed: status=${status ?? 'unknown'}, url=${url}, response=${typeof responseData === 'object' ? JSON.stringify(responseData) : responseData}`,
);

throw error;
}
}

private createWhatsappRequest(message: QueueMessage<any>): any {
const { to, templateId, ...parameters } = message.payload;
const source =
process.env.COMMON_GUPSHUP_WHATSAPP_SOURCE || process.env.GUPSHUP_SOURCE_NUMBER;

if (parameters?.payload) {
return parameters.payload;
}

return {
channel: 'whatsapp',
source,
destination: to,
message: {
type: 'template',
template: {
id: templateId,
params: parameters,
},
},
};
}

private toWaFormEncoded(payload: Record<string, any>): URLSearchParams {
const params = new URLSearchParams();
const appName = process.env.COMMON_GUPSHUP_APP_NAME || 'solidx';

params.append('channel', payload.channel || 'whatsapp');
params.append('source', payload.source || '');
params.append('destination', payload.destination || '');
params.append('src.name', payload['src.name'] || appName);
params.append('message', JSON.stringify(payload.message || {}));

return params;
}
}
Loading