erp-core-backend-v2/src/modules/whatsapp/services/whatsapp.service.ts
rckrdmrd d616370440 fix: Resolve entity/service field mismatches and build errors (84→19)
- Add class-validator and class-transformer dependencies
- Fix inventory entities index.ts exports
- Add ConflictError to shared types
- Fix ai.service.ts quota field names
- Fix audit.service.ts field names and remove missing methods
- Fix storage.service.ts bucket and file field names
- Rewrite partners.service.ts/controller.ts to match entity
- Fix product.entity.ts computed column syntax
- Fix inventory-adjustment-line.entity.ts computed column
- Fix webhooks.service.ts field names
- Fix whatsapp.service.ts order field names
- Fix swagger.config.ts import.meta.url issue

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 02:27:03 -06:00

465 lines
14 KiB
TypeScript

import { Repository, FindOptionsWhere, LessThan, Between, In } from 'typeorm';
import { WhatsAppAccount, WhatsAppContact, WhatsAppMessage, WhatsAppTemplate } from '../entities';
export interface MessageFilters {
contactId?: string;
direction?: string;
messageType?: string;
status?: string;
startDate?: Date;
endDate?: Date;
}
export interface ContactFilters {
conversationStatus?: string;
optedIn?: boolean;
tag?: string;
}
export class WhatsAppService {
constructor(
private readonly accountRepository: Repository<WhatsAppAccount>,
private readonly contactRepository: Repository<WhatsAppContact>,
private readonly messageRepository: Repository<WhatsAppMessage>,
private readonly templateRepository: Repository<WhatsAppTemplate>
) {}
// ============================================
// ACCOUNTS
// ============================================
async findAllAccounts(tenantId: string): Promise<WhatsAppAccount[]> {
return this.accountRepository.find({
where: { tenantId },
order: { name: 'ASC' },
});
}
async findActiveAccounts(tenantId: string): Promise<WhatsAppAccount[]> {
return this.accountRepository.find({
where: { tenantId, status: 'active' },
order: { name: 'ASC' },
});
}
async findAccount(id: string): Promise<WhatsAppAccount | null> {
return this.accountRepository.findOne({ where: { id } });
}
async findAccountByPhoneNumber(
tenantId: string,
phoneNumber: string
): Promise<WhatsAppAccount | null> {
return this.accountRepository.findOne({ where: { tenantId, phoneNumber } });
}
async createAccount(
tenantId: string,
data: Partial<WhatsAppAccount>,
createdBy?: string
): Promise<WhatsAppAccount> {
const account = this.accountRepository.create({
...data,
tenantId,
createdBy,
status: 'pending',
});
return this.accountRepository.save(account);
}
async updateAccount(id: string, data: Partial<WhatsAppAccount>): Promise<WhatsAppAccount | null> {
const account = await this.findAccount(id);
if (!account) return null;
Object.assign(account, data);
return this.accountRepository.save(account);
}
async updateAccountStatus(id: string, status: string): Promise<boolean> {
const result = await this.accountRepository.update(id, { status: status as any });
return (result.affected ?? 0) > 0;
}
async incrementMessageCount(accountId: string, direction: 'sent' | 'received'): Promise<void> {
const field = direction === 'sent' ? 'totalMessagesSent' : 'totalMessagesReceived';
await this.accountRepository
.createQueryBuilder()
.update()
.set({ [field]: () => `${field} + 1` })
.where('id = :id', { id: accountId })
.execute();
}
// ============================================
// CONTACTS
// ============================================
async findContacts(
tenantId: string,
accountId: string,
filters: ContactFilters = {},
limit: number = 50
): Promise<WhatsAppContact[]> {
const where: FindOptionsWhere<WhatsAppContact> = { tenantId, accountId };
if (filters.conversationStatus) {
where.conversationStatus = filters.conversationStatus as any;
}
if (filters.optedIn !== undefined) {
where.optedIn = filters.optedIn;
}
return this.contactRepository.find({
where,
order: { lastMessageAt: 'DESC' },
take: limit,
});
}
async findContact(id: string): Promise<WhatsAppContact | null> {
return this.contactRepository.findOne({ where: { id } });
}
async findContactByPhone(accountId: string, phoneNumber: string): Promise<WhatsAppContact | null> {
return this.contactRepository.findOne({ where: { accountId, phoneNumber } });
}
async createContact(
tenantId: string,
accountId: string,
data: Partial<WhatsAppContact>
): Promise<WhatsAppContact> {
const contact = this.contactRepository.create({
...data,
tenantId,
accountId,
});
return this.contactRepository.save(contact);
}
async updateContact(id: string, data: Partial<WhatsAppContact>): Promise<WhatsAppContact | null> {
const contact = await this.findContact(id);
if (!contact) return null;
Object.assign(contact, data);
return this.contactRepository.save(contact);
}
async updateContactConversationWindow(id: string, expiresAt: Date): Promise<void> {
await this.contactRepository.update(id, {
conversationWindowExpiresAt: expiresAt,
canSendTemplateOnly: false,
});
}
async expireConversationWindows(): Promise<number> {
const now = new Date();
const result = await this.contactRepository.update(
{ conversationWindowExpiresAt: LessThan(now), canSendTemplateOnly: false },
{ canSendTemplateOnly: true }
);
return result.affected ?? 0;
}
async optInContact(id: string): Promise<boolean> {
const result = await this.contactRepository.update(id, {
optedIn: true,
optedInAt: new Date(),
optedOut: false,
optedOutAt: undefined,
});
return (result.affected ?? 0) > 0;
}
async optOutContact(id: string): Promise<boolean> {
const result = await this.contactRepository.update(id, {
optedOut: true,
optedOutAt: new Date(),
});
return (result.affected ?? 0) > 0;
}
async addTagToContact(id: string, tag: string): Promise<WhatsAppContact | null> {
const contact = await this.findContact(id);
if (!contact) return null;
if (!contact.tags.includes(tag)) {
contact.tags.push(tag);
return this.contactRepository.save(contact);
}
return contact;
}
async removeTagFromContact(id: string, tag: string): Promise<WhatsAppContact | null> {
const contact = await this.findContact(id);
if (!contact) return null;
contact.tags = contact.tags.filter((t) => t !== tag);
return this.contactRepository.save(contact);
}
// ============================================
// MESSAGES
// ============================================
async findMessages(
accountId: string,
filters: MessageFilters = {},
limit: number = 50
): Promise<WhatsAppMessage[]> {
const where: FindOptionsWhere<WhatsAppMessage> = { accountId };
if (filters.contactId) where.contactId = filters.contactId;
if (filters.direction) where.direction = filters.direction as any;
if (filters.messageType) where.messageType = filters.messageType as any;
if (filters.status) where.status = filters.status as any;
if (filters.startDate && filters.endDate) {
where.createdAt = Between(filters.startDate, filters.endDate);
}
return this.messageRepository.find({
where,
order: { createdAt: 'DESC' },
take: limit,
relations: ['contact'],
});
}
async findMessage(id: string): Promise<WhatsAppMessage | null> {
return this.messageRepository.findOne({
where: { id },
relations: ['contact'],
});
}
async findMessageByWaId(waMessageId: string): Promise<WhatsAppMessage | null> {
return this.messageRepository.findOne({ where: { waMessageId } });
}
async findConversationMessages(
contactId: string,
limit: number = 100
): Promise<WhatsAppMessage[]> {
return this.messageRepository.find({
where: { contactId },
order: { createdAt: 'ASC' },
take: limit,
});
}
async createMessage(
tenantId: string,
accountId: string,
contactId: string,
data: Partial<WhatsAppMessage>
): Promise<WhatsAppMessage> {
const message = this.messageRepository.create({
...data,
tenantId,
accountId,
contactId,
status: 'pending',
});
const savedMessage = await this.messageRepository.save(message);
// Update contact stats
const direction = data.direction;
if (direction) {
const field = direction === 'outbound' ? 'totalMessagesSent' : 'totalMessagesReceived';
await this.contactRepository
.createQueryBuilder()
.update()
.set({
[field]: () => `${field} + 1`,
lastMessageAt: new Date(),
lastMessageDirection: direction,
})
.where('id = :id', { id: contactId })
.execute();
// Update account stats
await this.incrementMessageCount(accountId, direction === 'outbound' ? 'sent' : 'received');
}
return savedMessage;
}
async updateMessageStatus(
id: string,
status: string,
timestamp?: Date
): Promise<WhatsAppMessage | null> {
const message = await this.findMessage(id);
if (!message) return null;
message.status = status as any;
message.statusUpdatedAt = timestamp || new Date();
if (status === 'sent' && !message.sentAt) message.sentAt = timestamp || new Date();
if (status === 'delivered' && !message.deliveredAt) message.deliveredAt = timestamp || new Date();
if (status === 'read' && !message.readAt) message.readAt = timestamp || new Date();
return this.messageRepository.save(message);
}
async updateMessageError(id: string, errorCode: string, errorMessage: string): Promise<void> {
await this.messageRepository.update(id, {
status: 'failed',
errorCode,
errorMessage,
statusUpdatedAt: new Date(),
});
}
// ============================================
// TEMPLATES
// ============================================
async findTemplates(
tenantId: string,
accountId: string,
category?: string
): Promise<WhatsAppTemplate[]> {
const where: FindOptionsWhere<WhatsAppTemplate> = { tenantId, accountId, isActive: true };
if (category) where.category = category as any;
return this.templateRepository.find({
where,
order: { name: 'ASC' },
});
}
async findApprovedTemplates(
tenantId: string,
accountId: string
): Promise<WhatsAppTemplate[]> {
return this.templateRepository.find({
where: { tenantId, accountId, metaStatus: 'APPROVED', isActive: true },
order: { name: 'ASC' },
});
}
async findTemplate(id: string): Promise<WhatsAppTemplate | null> {
return this.templateRepository.findOne({ where: { id } });
}
async findTemplateByName(
accountId: string,
name: string,
language: string = 'es_MX'
): Promise<WhatsAppTemplate | null> {
return this.templateRepository.findOne({ where: { accountId, name, language } });
}
async createTemplate(
tenantId: string,
accountId: string,
data: Partial<WhatsAppTemplate>
): Promise<WhatsAppTemplate> {
const template = this.templateRepository.create({
...data,
tenantId,
accountId,
metaStatus: 'PENDING',
});
return this.templateRepository.save(template);
}
async updateTemplate(
id: string,
data: Partial<WhatsAppTemplate>
): Promise<WhatsAppTemplate | null> {
const template = await this.findTemplate(id);
if (!template) return null;
// Increment version on update
Object.assign(template, data, { version: template.version + 1 });
return this.templateRepository.save(template);
}
async updateTemplateStatus(
id: string,
metaStatus: string,
metaTemplateId?: string,
rejectionReason?: string
): Promise<WhatsAppTemplate | null> {
const template = await this.findTemplate(id);
if (!template) return null;
template.metaStatus = metaStatus as any;
if (metaTemplateId) template.metaTemplateId = metaTemplateId;
if (rejectionReason) template.rejectionReason = rejectionReason;
if (metaStatus === 'APPROVED') template.approvedAt = new Date();
return this.templateRepository.save(template);
}
async incrementTemplateUsage(id: string): Promise<void> {
await this.templateRepository
.createQueryBuilder()
.update()
.set({
usageCount: () => 'usage_count + 1',
lastUsedAt: new Date(),
})
.where('id = :id', { id })
.execute();
}
async deactivateTemplate(id: string): Promise<boolean> {
const result = await this.templateRepository.update(id, { isActive: false });
return (result.affected ?? 0) > 0;
}
// ============================================
// STATISTICS
// ============================================
async getAccountStats(
accountId: string,
startDate: Date,
endDate: Date
): Promise<{
totalMessages: number;
sent: number;
received: number;
delivered: number;
read: number;
failed: number;
}> {
const stats = await this.messageRepository
.createQueryBuilder('msg')
.select('COUNT(*)', 'total')
.addSelect("SUM(CASE WHEN msg.direction = 'outbound' THEN 1 ELSE 0 END)", 'sent')
.addSelect("SUM(CASE WHEN msg.direction = 'inbound' THEN 1 ELSE 0 END)", 'received')
.addSelect("SUM(CASE WHEN msg.status = 'delivered' THEN 1 ELSE 0 END)", 'delivered')
.addSelect("SUM(CASE WHEN msg.status = 'read' THEN 1 ELSE 0 END)", 'readCount')
.addSelect("SUM(CASE WHEN msg.status = 'failed' THEN 1 ELSE 0 END)", 'failed')
.where('msg.account_id = :accountId', { accountId })
.andWhere('msg.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
.getRawOne();
return {
totalMessages: parseInt(stats?.total) || 0,
sent: parseInt(stats?.sent) || 0,
received: parseInt(stats?.received) || 0,
delivered: parseInt(stats?.delivered) || 0,
read: parseInt(stats?.readCount) || 0,
failed: parseInt(stats?.failed) || 0,
};
}
async getContactsWithExpiredWindow(accountId: string): Promise<WhatsAppContact[]> {
const now = new Date();
return this.contactRepository.find({
where: {
accountId,
conversationWindowExpiresAt: LessThan(now),
canSendTemplateOnly: false,
},
});
}
}