/opt/mawid/apps/api/src/whatsapp
Edit: /opt/mawid/apps/api/src/whatsapp/wa-sender.service.ts (4959B)
import { Inject, Injectable, NotFoundException, UnprocessableEntityException } from '@nestjs/common';
import type { Conversation, Message } from '@mawid/db';
import { PrismaService } from '../prisma/prisma.service';
import { WA_OUTBOUND_JOB_OPTS, WA_OUTBOUND_QUEUE, type WaOutboundQueue } from './wa-queue';
import { isWithinCustomerServiceWindow } from './wa-window';
import type { WaButton, WaListRow, WaOutboundRequest } from './wa-types';
@Injectable()
export class WaSenderService {
constructor(
private readonly prisma: PrismaService,
@Inject(WA_OUTBOUND_QUEUE) private readonly queue: WaOutboundQueue,
) {}
/** Free-form messages are only allowed inside Meta's 24h customer service window. */
async canSendFreeform(conversation: Pick
): Promise {
const lastInbound = await this.prisma.message.findFirst({
where: { conversationId: conversation.id, direction: 'inbound' },
orderBy: { createdAt: 'desc' },
select: { createdAt: true },
});
return isWithinCustomerServiceWindow(lastInbound?.createdAt);
}
async sendText(clinicId: string, patientId: string, text: string): Promise {
return this.sendFreeform(clinicId, patientId, 'text', text, (to) => ({
messaging_product: 'whatsapp',
to,
type: 'text',
text: { body: text },
}));
}
async sendButtons(
clinicId: string,
patientId: string,
text: string,
buttons: WaButton[],
): Promise {
return this.sendFreeform(clinicId, patientId, 'interactive', text, (to) => ({
messaging_product: 'whatsapp',
to,
type: 'interactive',
interactive: {
type: 'button',
body: { text },
action: {
buttons: buttons.map((b) => ({ type: 'reply', reply: { id: b.id, title: b.title } })),
},
},
}));
}
async sendList(
clinicId: string,
patientId: string,
text: string,
buttonLabel: string,
rows: WaListRow[],
): Promise {
return this.sendFreeform(clinicId, patientId, 'interactive', text, (to) => ({
messaging_product: 'whatsapp',
to,
type: 'interactive',
interactive: {
type: 'list',
body: { text },
action: { button: buttonLabel, sections: [{ rows }] },
},
}));
}
/** Template messages are allowed outside the 24h window. */
async sendTemplate(
clinicId: string,
patientId: string,
templateName: string,
languageCode: string,
components?: unknown[],
bodyText?: string,
): Promise {
const patient = await this.getPatient(clinicId, patientId);
const conversation = await this.getOrCreateConversation(clinicId, patientId);
return this.persistAndEnqueue(conversation.id, 'template', bodyText ?? templateName, {
messaging_product: 'whatsapp',
to: patient.waPhone.replace(/^\+/, ''),
type: 'template',
template: { name: templateName, language: { code: languageCode }, components },
});
}
private async sendFreeform(
clinicId: string,
patientId: string,
type: string,
body: string,
buildRequest: (to: string) => WaOutboundRequest,
): Promise {
const patient = await this.getPatient(clinicId, patientId);
const conversation = await this.getOrCreateConversation(clinicId, patientId);
if (!(await this.canSendFreeform(conversation))) {
throw new UnprocessableEntityException(
'24h customer service window is closed — use a template message',
);
}
return this.persistAndEnqueue(
conversation.id,
type,
body,
buildRequest(patient.waPhone.replace(/^\+/, '')),
);
}
private async persistAndEnqueue(
conversationId: string,
type: string,
body: string,
request: WaOutboundRequest,
): Promise {
const message = await this.prisma.message.create({
data: {
conversationId,
direction: 'outbound',
type,
body,
payload: { request: request as object, status: 'queued' },
},
});
await this.prisma.conversation.update({
where: { id: conversationId },
data: { lastMessageAt: message.createdAt },
});
await this.queue.add('send', { messageId: message.id }, WA_OUTBOUND_JOB_OPTS);
return message;
}
async getOrCreateConversation(clinicId: string, patientId: string): Promise {
const existing = await this.prisma.conversation.findFirst({
where: { clinicId, patientId },
orderBy: { createdAt: 'desc' },
});
if (existing) return existing;
return this.prisma.conversation.create({ data: { clinicId, patientId } });
}
private async getPatient(clinicId: string, patientId: string) {
const patient = await this.prisma.patient.findFirst({ where: { id: patientId, clinicId } });
if (!patient) throw new NotFoundException('Patient not found');
return patient;
}
}