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, private readonly contactRepository: Repository, private readonly messageRepository: Repository, private readonly templateRepository: Repository ) {} // ============================================ // ACCOUNTS // ============================================ async findAllAccounts(tenantId: string): Promise { return this.accountRepository.find({ where: { tenantId }, order: { name: 'ASC' }, }); } async findActiveAccounts(tenantId: string): Promise { return this.accountRepository.find({ where: { tenantId, status: 'active' }, order: { name: 'ASC' }, }); } async findAccount(id: string): Promise { return this.accountRepository.findOne({ where: { id } }); } async findAccountByPhoneNumber( tenantId: string, phoneNumber: string ): Promise { return this.accountRepository.findOne({ where: { tenantId, phoneNumber } }); } async createAccount( tenantId: string, data: Partial, createdBy?: string ): Promise { const account = this.accountRepository.create({ ...data, tenantId, createdBy, status: 'pending', }); return this.accountRepository.save(account); } async updateAccount(id: string, data: Partial): Promise { 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 { const result = await this.accountRepository.update(id, { status: status as any }); return (result.affected ?? 0) > 0; } async incrementMessageCount(accountId: string, direction: 'sent' | 'received'): Promise { 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 { const where: FindOptionsWhere = { 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 { return this.contactRepository.findOne({ where: { id } }); } async findContactByPhone(accountId: string, phoneNumber: string): Promise { return this.contactRepository.findOne({ where: { accountId, phoneNumber } }); } async createContact( tenantId: string, accountId: string, data: Partial ): Promise { const contact = this.contactRepository.create({ ...data, tenantId, accountId, }); return this.contactRepository.save(contact); } async updateContact(id: string, data: Partial): Promise { 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 { await this.contactRepository.update(id, { conversationWindowExpiresAt: expiresAt, canSendTemplateOnly: false, }); } async expireConversationWindows(): Promise { 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 { 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 { const result = await this.contactRepository.update(id, { optedOut: true, optedOutAt: new Date(), }); return (result.affected ?? 0) > 0; } async addTagToContact(id: string, tag: string): Promise { 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 { 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 { const where: FindOptionsWhere = { 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 { return this.messageRepository.findOne({ where: { id }, relations: ['contact'], }); } async findMessageByWaId(waMessageId: string): Promise { return this.messageRepository.findOne({ where: { waMessageId } }); } async findConversationMessages( contactId: string, limit: number = 100 ): Promise { return this.messageRepository.find({ where: { contactId }, order: { createdAt: 'ASC' }, take: limit, }); } async createMessage( tenantId: string, accountId: string, contactId: string, data: Partial ): Promise { 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 { 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 { await this.messageRepository.update(id, { status: 'failed', errorCode, errorMessage, statusUpdatedAt: new Date(), }); } // ============================================ // TEMPLATES // ============================================ async findTemplates( tenantId: string, accountId: string, category?: string ): Promise { const where: FindOptionsWhere = { 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 { return this.templateRepository.find({ where: { tenantId, accountId, metaStatus: 'APPROVED', isActive: true }, order: { name: 'ASC' }, }); } async findTemplate(id: string): Promise { return this.templateRepository.findOne({ where: { id } }); } async findTemplateByName( accountId: string, name: string, language: string = 'es_MX' ): Promise { return this.templateRepository.findOne({ where: { accountId, name, language } }); } async createTemplate( tenantId: string, accountId: string, data: Partial ): Promise { const template = this.templateRepository.create({ ...data, tenantId, accountId, metaStatus: 'PENDING', }); return this.templateRepository.save(template); } async updateTemplate( id: string, data: Partial ): Promise { 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 { 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 { await this.templateRepository .createQueryBuilder() .update() .set({ usageCount: () => 'usage_count + 1', lastUsedAt: new Date(), }) .where('id = :id', { id }) .execute(); } async deactivateTemplate(id: string): Promise { 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 { const now = new Date(); return this.contactRepository.find({ where: { accountId, conversationWindowExpiresAt: LessThan(now), canSendTemplateOnly: false, }, }); } }