/opt/mawid/apps/api/src/whatsapp
NameSizeModeActions
owner-notifier.service.ts17450644editdlrm
wa-core.module.ts13520644editdlrm
wa-http.client.ts17360644editdlrm
wa-inbound.service.spec.ts50660644editdlrm
wa-inbound.service.ts54310644editdlrm
wa-outbound.worker.ts27230644editdlrm
wa-queue.ts4470644editdlrm
wa-sender.service.ts49590644editdlrm
wa-signature.spec.ts12640644editdlrm
wa-signature.ts6620644editdlrm
wa-types.ts16830644editdlrm
wa-webhook.controller.spec.ts26840644editdlrm
wa-webhook.controller.ts23080644editdlrm
wa-window.spec.ts8600644editdlrm
wa-window.ts5090644editdlrm
whatsapp.module.ts5790644editdlrm
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; } }