- 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>
465 lines
14 KiB
TypeScript
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,
|
|
},
|
|
});
|
|
}
|
|
}
|