From c1e4385fa924672d037c5dfa9c72b70eb39f1c58 Mon Sep 17 00:00:00 2001 From: Mooka-Mooka <66691790+Mooka-Mooka@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:38:58 +0300 Subject: [PATCH 1/2] Update build script to include db:generate --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 56e32fcc8a..ca59a57793 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "./dist/main.js", "type": "commonjs", "scripts": { - "build": "tsc --noEmit && tsup", +"build": "npm run db:generate && tsc --noEmit && tsup", "start": "tsx ./src/main.ts", "start:prod": "node dist/main", "dev:server": "tsx watch ./src/main.ts", From 4d78b13814da3e491f28523d1b4e1bb7c53bf40c Mon Sep 17 00:00:00 2001 From: Malik Nagi Date: Mon, 26 Oct 2026 19:14:30 +0300 Subject: [PATCH 2/2] fix(mysql): restore Prisma schema and query compatibility --- README.md | 17 ++ .../migration.sql | 5 + prisma/mysql-schema.prisma | 3 + .../channel/meta/whatsapp.business.service.ts | 2 +- .../whatsapp/whatsapp.baileys.service.ts | 133 ++++++++++------ .../chatwoot/services/chatwoot.service.ts | 50 +++--- src/api/repository/repository.service.ts | 14 +- src/api/services/channel.service.ts | 86 ++++++++-- test/mysql-compatibility.test.cjs | 150 ++++++++++++++++++ 9 files changed, 377 insertions(+), 83 deletions(-) create mode 100644 prisma/mysql-migrations/20261026000000_restore_mysql_compatibility/migration.sql create mode 100644 test/mysql-compatibility.test.cjs diff --git a/README.md b/README.md index 4586e4028a..798dc33087 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,23 @@ Join our [community](https://evolutionfoundation.com.br/community) to discuss id --- +## MySQL compatibility in this fork + +Set `DATABASE_PROVIDER=mysql` and a dedicated MySQL `DATABASE_CONNECTION_URI` before building. +`npm run build` generates the provider-specific Prisma client before type-checking and bundling. +Do not skip the TypeScript check or generate a PostgreSQL client for a MySQL deployment. + +Before starting the updated API, back up an existing database and run `npm run db:deploy` +(`npm run db:deploy:win` on Windows). The added MySQL migration restores `IsOnWhatsapp.lid` +and adds the compound `Label(labelId, instanceId)` unique index. It does not delete duplicate +labels: if duplicates exist, resolve them deliberately before applying the migration. The existing +upstream migration history is unchanged; review pending migrations when upgrading an older database. + +Compatibility tests use a disposable local MariaDB/MySQL database named +`evolution_mysql_test_` after applying the MySQL migrations: +`node --test test/mysql-compatibility.test.cjs`. They execute service methods without starting +WhatsApp or contacting external services. The live WhatsApp connection still needs a separate smoke test. + ## Security For security issues, **do not open a public issue**. Email **suporte@evofoundation.com.br** or use GitHub's private vulnerability reporting. See [SECURITY.md](./SECURITY.md) for details. diff --git a/prisma/mysql-migrations/20261026000000_restore_mysql_compatibility/migration.sql b/prisma/mysql-migrations/20261026000000_restore_mysql_compatibility/migration.sql new file mode 100644 index 0000000000..c8bd1bfc79 --- /dev/null +++ b/prisma/mysql-migrations/20261026000000_restore_mysql_compatibility/migration.sql @@ -0,0 +1,5 @@ +-- Fail on existing duplicates rather than silently deleting or merging customer data. +CREATE UNIQUE INDEX `Label_labelId_instanceId_key` ON `Label`(`labelId`, `instanceId`); + +-- Restore the field removed by the Kafka migration and used by the WhatsApp cache. +ALTER TABLE `IsOnWhatsapp` ADD COLUMN `lid` VARCHAR(100) NULL; diff --git a/prisma/mysql-schema.prisma b/prisma/mysql-schema.prisma index 71b5a743f0..46f597011d 100644 --- a/prisma/mysql-schema.prisma +++ b/prisma/mysql-schema.prisma @@ -249,6 +249,8 @@ model Label { updatedAt DateTime @updatedAt @db.Timestamp Instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade) instanceId String + + @@unique([labelId, instanceId]) } model Proxy { @@ -655,6 +657,7 @@ model IsOnWhatsapp { id String @id @default(cuid()) remoteJid String @unique @db.VarChar(100) jidOptions String + lid String? @db.VarChar(100) createdAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP")) @db.Timestamp updatedAt DateTime @updatedAt @db.Timestamp } diff --git a/src/api/integrations/channel/meta/whatsapp.business.service.ts b/src/api/integrations/channel/meta/whatsapp.business.service.ts index 1e4808c156..d8c62457d6 100644 --- a/src/api/integrations/channel/meta/whatsapp.business.service.ts +++ b/src/api/integrations/channel/meta/whatsapp.business.service.ts @@ -758,7 +758,7 @@ export class BusinessStartupService extends ChannelStartupService { where: { instanceId: this.instanceId, key: { - path: ['id'], + path: this.prismaRepository.jsonPath('id'), equals: key.id, }, }, diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 60e857fcc1..07b432731c 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -522,12 +522,9 @@ export class BaileysStartupService extends ChannelStartupService { private async getMessage(key: proto.IMessageKey, full = false) { try { - // Use raw SQL to avoid JSON path issues - const webMessageInfo = (await this.prismaRepository.$queryRaw` - SELECT * FROM "Message" - WHERE "instanceId" = ${this.instanceId} - AND "key"->>'id' = ${key.id} - `) as proto.IWebMessageInfo[]; + const webMessageInfo = (await this.prismaRepository.message.findMany({ + where: { instanceId: this.instanceId, key: { path: this.prismaRepository.jsonPath('id'), equals: key.id } }, + })) as unknown as proto.IWebMessageInfo[]; if (full) { return webMessageInfo[0]; @@ -1637,13 +1634,12 @@ export class BaileysStartupService extends ChannelStartupService { const searchId = originalMessageId || key.id; - const messages = (await this.prismaRepository.$queryRaw` - SELECT * FROM "Message" - WHERE "instanceId" = ${this.instanceId} - AND "key"->>'id' = ${searchId} - LIMIT 1 - `) as any[]; - findMessage = messages[0] || null; + findMessage = await this.prismaRepository.message.findFirst({ + where: { + instanceId: this.instanceId, + key: { path: this.prismaRepository.jsonPath('id'), equals: searchId }, + }, + }); if (!findMessage?.id) { this.logger.warn(`Original message not found for update. Skipping. Key: ${JSON.stringify(key)}`); @@ -3776,7 +3772,7 @@ export class BaileysStartupService extends ChannelStartupService { if (messageId) { const isLogicalDeleted = configService.get('DATABASE').DELETE_DATA.LOGICAL_MESSAGE_DELETE; let message = await this.prismaRepository.message.findFirst({ - where: { key: { path: ['id'], equals: messageId } }, + where: { key: { path: this.prismaRepository.jsonPath('id'), equals: messageId } }, }); if (isLogicalDeleted) { if (!message) return response; @@ -4196,7 +4192,7 @@ export class BaileysStartupService extends ChannelStartupService { const messageId = messageSent.message?.protocolMessage?.key?.id; if (messageId && this.configService.get('DATABASE').SAVE_DATA.NEW_MESSAGE) { let message = await this.prismaRepository.message.findFirst({ - where: { key: { path: ['id'], equals: messageId } }, + where: { key: { path: this.prismaRepository.jsonPath('id'), equals: messageId } }, }); if (!message) throw new NotFoundException('Message not found'); @@ -4734,20 +4730,22 @@ export class BaileysStartupService extends ChannelStartupService { private async updateMessagesReadedByTimestamp(remoteJid: string, timestamp?: number): Promise { if (timestamp === undefined || timestamp === null) return 0; - // Use raw SQL to avoid JSON path issues - const result = await this.prismaRepository.$executeRaw` - UPDATE "Message" - SET "status" = ${status[4]} - WHERE "instanceId" = ${this.instanceId} - AND "key"->>'remoteJid' = ${remoteJid} - AND ("key"->>'fromMe')::boolean = false - AND "messageTimestamp" <= ${timestamp} - AND ("status" IS NULL OR "status" = ${status[3]}) - `; + const { count: result } = await this.prismaRepository.message.updateMany({ + where: { + instanceId: this.instanceId, + AND: [ + { key: { path: this.prismaRepository.jsonPath('remoteJid'), equals: remoteJid } }, + { key: { path: this.prismaRepository.jsonPath('fromMe'), equals: false } }, + ], + messageTimestamp: { lte: timestamp }, + OR: [{ status: null }, { status: status[3] }], + }, + data: { status: status[4] }, + }); if (result) { if (result > 0) { - this.updateChatUnreadMessages(remoteJid); + await this.updateChatUnreadMessages(remoteJid); } return result; @@ -4758,15 +4756,17 @@ export class BaileysStartupService extends ChannelStartupService { private async updateChatUnreadMessages(remoteJid: string): Promise { const [chat, unreadMessages] = await Promise.all([ - this.prismaRepository.chat.findFirst({ where: { remoteJid } }), - // Use raw SQL to avoid JSON path issues - this.prismaRepository.$queryRaw` - SELECT COUNT(*)::int as count FROM "Message" - WHERE "instanceId" = ${this.instanceId} - AND "key"->>'remoteJid' = ${remoteJid} - AND ("key"->>'fromMe')::boolean = false - AND "status" = ${status[3]} - `.then((result: any[]) => result[0]?.count || 0), + this.prismaRepository.chat.findFirst({ where: { remoteJid, instanceId: this.instanceId } }), + this.prismaRepository.message.count({ + where: { + instanceId: this.instanceId, + AND: [ + { key: { path: this.prismaRepository.jsonPath('remoteJid'), equals: remoteJid } }, + { key: { path: this.prismaRepository.jsonPath('fromMe'), equals: false } }, + ], + status: status[3], + }, + }), ]); if (chat && chat.unreadMessages !== unreadMessages) { @@ -4779,6 +4779,18 @@ export class BaileysStartupService extends ChannelStartupService { private async addLabel(labelId: string, instanceId: string, chatId: string) { const id = cuid(); + if (this.prismaRepository.isMysql) { + await this.prismaRepository.$executeRaw` + INSERT INTO \`Chat\` (\`id\`, \`instanceId\`, \`remoteJid\`, \`labels\`, \`createdAt\`, \`updatedAt\`) + VALUES (${id}, ${instanceId}, ${chatId}, JSON_ARRAY(${labelId}), NOW(), NOW()) + ON DUPLICATE KEY UPDATE + \`labels\` = IF(JSON_CONTAINS(COALESCE(\`labels\`, JSON_ARRAY()), JSON_QUOTE(${labelId})), + \`labels\`, JSON_ARRAY_APPEND(COALESCE(\`labels\`, JSON_ARRAY()), '$', ${labelId})), + \`updatedAt\` = NOW() + `; + return; + } + await this.prismaRepository.$executeRawUnsafe( `INSERT INTO "Chat" ("id", "instanceId", "remoteJid", "labels", "createdAt", "updatedAt") VALUES ($4, $2, $3, to_jsonb(ARRAY[$1]::text[]), NOW(), NOW()) ON CONFLICT ("instanceId", "remoteJid") @@ -4803,6 +4815,21 @@ export class BaileysStartupService extends ChannelStartupService { private async removeLabel(labelId: string, instanceId: string, chatId: string) { const id = cuid(); + if (this.prismaRepository.isMysql) { + // Escape JSON_SEARCH wildcards so labels are matched literally. + const searchLabel = labelId.replace(/[!%_]/g, '!$&'); + await this.prismaRepository.$executeRaw` + INSERT INTO \`Chat\` (\`id\`, \`instanceId\`, \`remoteJid\`, \`labels\`, \`createdAt\`, \`updatedAt\`) + VALUES (${id}, ${instanceId}, ${chatId}, JSON_ARRAY(), NOW(), NOW()) + ON DUPLICATE KEY UPDATE + \`labels\` = IF(JSON_SEARCH(COALESCE(\`labels\`, JSON_ARRAY()), 'one', ${searchLabel}, '!') IS NULL, + COALESCE(\`labels\`, JSON_ARRAY()), + JSON_REMOVE(\`labels\`, JSON_UNQUOTE(JSON_SEARCH(\`labels\`, 'one', ${searchLabel}, '!')))), + \`updatedAt\` = NOW() + `; + return; + } + await this.prismaRepository.$executeRawUnsafe( `INSERT INTO "Chat" ("id", "instanceId", "remoteJid", "labels", "createdAt", "updatedAt") VALUES ($4, $2, $3, '[]'::jsonb, NOW(), NOW()) ON CONFLICT ("instanceId", "remoteJid") @@ -5034,13 +5061,21 @@ export class BaileysStartupService extends ChannelStartupService { messageType: query?.where?.messageType, ...timestampFilter, AND: [ - keyFilters?.id ? { key: { path: ['id'], equals: keyFilters?.id } } : {}, - keyFilters?.fromMe ? { key: { path: ['fromMe'], equals: keyFilters?.fromMe } } : {}, - keyFilters?.participant ? { key: { path: ['participant'], equals: keyFilters?.participant } } : {}, + keyFilters?.id ? { key: { path: this.prismaRepository.jsonPath('id'), equals: keyFilters?.id } } : {}, + keyFilters?.fromMe + ? { key: { path: this.prismaRepository.jsonPath('fromMe'), equals: keyFilters?.fromMe } } + : {}, + keyFilters?.participant + ? { key: { path: this.prismaRepository.jsonPath('participant'), equals: keyFilters?.participant } } + : {}, { OR: [ - keyFilters?.remoteJid ? { key: { path: ['remoteJid'], equals: keyFilters?.remoteJid } } : {}, - keyFilters?.remoteJidAlt ? { key: { path: ['remoteJidAlt'], equals: keyFilters?.remoteJidAlt } } : {}, + keyFilters?.remoteJid + ? { key: { path: this.prismaRepository.jsonPath('remoteJid'), equals: keyFilters?.remoteJid } } + : {}, + keyFilters?.remoteJidAlt + ? { key: { path: this.prismaRepository.jsonPath('remoteJidAlt'), equals: keyFilters?.remoteJidAlt } } + : {}, ], }, ], @@ -5063,13 +5098,21 @@ export class BaileysStartupService extends ChannelStartupService { messageType: query?.where?.messageType, ...timestampFilter, AND: [ - keyFilters?.id ? { key: { path: ['id'], equals: keyFilters?.id } } : {}, - keyFilters?.fromMe ? { key: { path: ['fromMe'], equals: keyFilters?.fromMe } } : {}, - keyFilters?.participant ? { key: { path: ['participant'], equals: keyFilters?.participant } } : {}, + keyFilters?.id ? { key: { path: this.prismaRepository.jsonPath('id'), equals: keyFilters?.id } } : {}, + keyFilters?.fromMe + ? { key: { path: this.prismaRepository.jsonPath('fromMe'), equals: keyFilters?.fromMe } } + : {}, + keyFilters?.participant + ? { key: { path: this.prismaRepository.jsonPath('participant'), equals: keyFilters?.participant } } + : {}, { OR: [ - keyFilters?.remoteJid ? { key: { path: ['remoteJid'], equals: keyFilters?.remoteJid } } : {}, - keyFilters?.remoteJidAlt ? { key: { path: ['remoteJidAlt'], equals: keyFilters?.remoteJidAlt } } : {}, + keyFilters?.remoteJid + ? { key: { path: this.prismaRepository.jsonPath('remoteJid'), equals: keyFilters?.remoteJid } } + : {}, + keyFilters?.remoteJidAlt + ? { key: { path: this.prismaRepository.jsonPath('remoteJidAlt'), equals: keyFilters?.remoteJidAlt } } + : {}, ], }, ], diff --git a/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts b/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts index 906fff1881..b0af133896 100644 --- a/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts +++ b/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts @@ -1546,7 +1546,7 @@ export class ChatwootService { const lastMessage = await this.prismaRepository.message.findFirst({ where: { key: { - path: ['fromMe'], + path: this.prismaRepository.jsonPath('fromMe'), equals: false, }, instanceId: instance.instanceId, @@ -1576,7 +1576,7 @@ export class ChatwootService { where: { instanceId: instance.instanceId, key: { - path: ['id'], + path: this.prismaRepository.jsonPath('id'), equals: key.id, }, }, @@ -1617,18 +1617,19 @@ export class ChatwootService { return; } - // Use raw SQL to avoid JSON path issues - const result = await this.prismaRepository.$executeRaw` - UPDATE "Message" - SET - "chatwootMessageId" = ${chatwootMessageIds.messageId}, - "chatwootConversationId" = ${chatwootMessageIds.conversationId}, - "chatwootInboxId" = ${chatwootMessageIds.inboxId}, - "chatwootContactInboxSourceId" = ${chatwootMessageIds.contactInboxSourceId}, - "chatwootIsRead" = ${chatwootMessageIds.isRead || false} - WHERE "instanceId" = ${instance.instanceId} - AND "key"->>'id' = ${key.id} - `; + const { count: result } = await this.prismaRepository.message.updateMany({ + where: { + instanceId: instance.instanceId, + key: { path: this.prismaRepository.jsonPath('id'), equals: key.id }, + }, + data: { + chatwootMessageId: chatwootMessageIds.messageId, + chatwootConversationId: chatwootMessageIds.conversationId ?? null, + chatwootInboxId: chatwootMessageIds.inboxId ?? null, + chatwootContactInboxSourceId: chatwootMessageIds.contactInboxSourceId ?? null, + chatwootIsRead: chatwootMessageIds.isRead || false, + }, + }); this.logger.verbose(`Update result: ${result} rows affected`); @@ -1642,15 +1643,12 @@ export class ChatwootService { } private async getMessageByKeyId(instance: InstanceDto, keyId: string): Promise { - // Use raw SQL query to avoid JSON path issues with Prisma - const messages = await this.prismaRepository.$queryRaw` - SELECT * FROM "Message" - WHERE "instanceId" = ${instance.instanceId} - AND "key"->>'id' = ${keyId} - LIMIT 1 - `; - - return (messages as MessageModel[])[0] || null; + return this.prismaRepository.message.findFirst({ + where: { + instanceId: instance.instanceId, + key: { path: this.prismaRepository.jsonPath('id'), equals: keyId }, + }, + }); } private async getReplyToIds( @@ -2025,7 +2023,7 @@ export class ChatwootService { quotedMsg = await this.prismaRepository.message.findFirst({ where: { key: { - path: ['id'], + path: this.prismaRepository.jsonPath('id'), equals: quotedId, }, chatwootMessageId: { @@ -2337,7 +2335,7 @@ export class ChatwootService { await this.prismaRepository.message.deleteMany({ where: { key: { - path: ['id'], + path: this.prismaRepository.jsonPath('id'), equals: body.key.id, }, instanceId: instance.instanceId, @@ -2679,7 +2677,7 @@ export class ChatwootService { where: { Instance: { name: instance.instanceName }, messageTimestamp: { gte: Number(dayjs().subtract(6, 'hours').unix()) }, - AND: ids.map((id) => ({ key: { path: ['id'], not: id } })), + AND: ids.map((id) => ({ key: { path: this.prismaRepository.jsonPath('id'), not: id } })), }, }); diff --git a/src/api/repository/repository.service.ts b/src/api/repository/repository.service.ts index 793bb0c82f..ec92a33693 100644 --- a/src/api/repository/repository.service.ts +++ b/src/api/repository/repository.service.ts @@ -1,6 +1,6 @@ -import { ConfigService } from '@config/env.config'; +import { ConfigService, Database } from '@config/env.config'; import { Logger } from '@config/logger.config'; -import { PrismaClient } from '@prisma/client'; +import { Prisma, PrismaClient } from '@prisma/client'; export class Query { where?: T; @@ -16,6 +16,16 @@ export class PrismaRepository extends PrismaClient { private readonly logger = new Logger('PrismaRepository'); + public get isMysql(): boolean { + return this.configService.get('DATABASE').PROVIDER === 'mysql'; + } + + public jsonPath(...segments: string[]): Prisma.JsonFilterBase<'Message'>['path'] { + // Prisma generates a different path type for each provider; match that provider at runtime. + const path = this.isMysql ? '$' + segments.map((segment) => `.${JSON.stringify(segment)}`).join('') : segments; + return path as Prisma.JsonFilterBase<'Message'>['path']; + } + public async onModuleInit() { await this.$connect(); this.logger.info('Repository:Prisma - ON'); diff --git a/src/api/services/channel.service.ts b/src/api/services/channel.service.ts index 56bec08021..8b53d7e73e 100644 --- a/src/api/services/channel.service.ts +++ b/src/api/services/channel.service.ts @@ -623,10 +623,16 @@ export class ChannelStartupService { messageType: query?.where?.messageType, ...timestampFilter, AND: [ - keyFilters?.id ? { key: { path: ['id'], equals: keyFilters?.id } } : {}, - keyFilters?.fromMe ? { key: { path: ['fromMe'], equals: keyFilters?.fromMe } } : {}, - keyFilters?.remoteJid ? { key: { path: ['remoteJid'], equals: keyFilters?.remoteJid } } : {}, - keyFilters?.participants ? { key: { path: ['participants'], equals: keyFilters?.participants } } : {}, + keyFilters?.id ? { key: { path: this.prismaRepository.jsonPath('id'), equals: keyFilters?.id } } : {}, + keyFilters?.fromMe + ? { key: { path: this.prismaRepository.jsonPath('fromMe'), equals: keyFilters?.fromMe } } + : {}, + keyFilters?.remoteJid + ? { key: { path: this.prismaRepository.jsonPath('remoteJid'), equals: keyFilters?.remoteJid } } + : {}, + keyFilters?.participants + ? { key: { path: this.prismaRepository.jsonPath('participants'), equals: keyFilters?.participants } } + : {}, ], }, }); @@ -647,10 +653,16 @@ export class ChannelStartupService { messageType: query?.where?.messageType, ...timestampFilter, AND: [ - keyFilters?.id ? { key: { path: ['id'], equals: keyFilters?.id } } : {}, - keyFilters?.fromMe ? { key: { path: ['fromMe'], equals: keyFilters?.fromMe } } : {}, - keyFilters?.remoteJid ? { key: { path: ['remoteJid'], equals: keyFilters?.remoteJid } } : {}, - keyFilters?.participants ? { key: { path: ['participants'], equals: keyFilters?.participants } } : {}, + keyFilters?.id ? { key: { path: this.prismaRepository.jsonPath('id'), equals: keyFilters?.id } } : {}, + keyFilters?.fromMe + ? { key: { path: this.prismaRepository.jsonPath('fromMe'), equals: keyFilters?.fromMe } } + : {}, + keyFilters?.remoteJid + ? { key: { path: this.prismaRepository.jsonPath('remoteJid'), equals: keyFilters?.remoteJid } } + : {}, + keyFilters?.participants + ? { key: { path: this.prismaRepository.jsonPath('participants'), equals: keyFilters?.participants } } + : {}, ], }, orderBy: { @@ -741,7 +753,56 @@ export class ChannelStartupService { const limit = query?.take ? Prisma.sql`LIMIT ${query.take}` : Prisma.sql``; const offset = query?.skip ? Prisma.sql`OFFSET ${query.skip}` : Prisma.sql``; - const results = await this.prismaRepository.$queryRaw` + const results = this.prismaRepository.isMysql + ? await this.prismaRepository.$queryRaw(Prisma.sql` + WITH rankedMessages AS ( + SELECT + c.id AS contactId, + JSON_UNQUOTE(JSON_EXTRACT(m.\`key\`, '$.remoteJid')) AS remoteJid, + ch.name AS pushName, + c.profilePicUrl, + COALESCE(FROM_UNIXTIME(m.messageTimestamp), c.updatedAt) AS updatedAt, + ch.createdAt AS windowStart, + DATE_ADD(ch.createdAt, INTERVAL 24 HOUR) AS windowExpires, + ch.unreadMessages, + IF(DATE_ADD(ch.createdAt, INTERVAL 24 HOUR) > NOW(), 1, 0) AS windowActive, + m.id AS lastMessageId, + CAST(m.\`key\` AS CHAR) AS lastMessage_key, + CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(m.\`key\`, '$.fromMe')) = 'true' + THEN 'Você' ELSE m.pushName END AS lastMessagePushName, + m.participant AS lastMessageParticipant, + m.messageType AS lastMessageMessageType, + CAST(m.message AS CHAR) AS lastMessageMessage, + CAST(m.contextInfo AS CHAR) AS lastMessageContextInfo, + m.source AS lastMessageSource, + m.messageTimestamp AS lastMessageMessageTimestamp, + m.instanceId AS lastMessageInstanceId, + m.sessionId AS lastMessageSessionId, + m.status AS lastMessageStatus, + ROW_NUMBER() OVER ( + PARTITION BY JSON_UNQUOTE(JSON_EXTRACT(m.\`key\`, '$.remoteJid')) + ORDER BY m.messageTimestamp DESC, m.id DESC + ) AS rowNumber + FROM \`Message\` m + LEFT JOIN \`Contact\` c ON c.remoteJid = JSON_UNQUOTE(JSON_EXTRACT(m.\`key\`, '$.remoteJid')) + AND c.instanceId = m.instanceId + LEFT JOIN \`Chat\` ch ON ch.remoteJid = JSON_UNQUOTE(JSON_EXTRACT(m.\`key\`, '$.remoteJid')) + AND ch.instanceId = m.instanceId + WHERE m.instanceId = ${this.instanceId} + ${remoteJid ? Prisma.sql`AND JSON_UNQUOTE(JSON_EXTRACT(m.\`key\`, '$.remoteJid')) = ${remoteJid}` : Prisma.sql``} + ${ + query?.where?.messageTimestamp?.gte && query?.where?.messageTimestamp?.lte + ? Prisma.sql`AND m.messageTimestamp >= ${Math.floor(new Date(query.where.messageTimestamp.gte).getTime() / 1000)} + AND m.messageTimestamp <= ${Math.floor(new Date(query.where.messageTimestamp.lte).getTime() / 1000)}` + : Prisma.sql`` + } + ) + SELECT * FROM rankedMessages WHERE rowNumber = 1 + ORDER BY updatedAt DESC + ${query?.take ? limit : query?.skip ? Prisma.sql`LIMIT 18446744073709551615` : Prisma.sql``} + ${offset} + `) + : await this.prismaRepository.$queryRaw` WITH rankedMessages AS ( SELECT DISTINCT ON ("Message"."key"->>'remoteJid') "Contact"."id" as "contactId", @@ -791,6 +852,13 @@ export class ChannelStartupService { if (results && isArray(results) && results.length > 0) { const mappedResults = results.map((contact) => { + if (this.prismaRepository.isMysql) { + // Both providers return the explicitly cast JSON as text; preserve the existing API values. + for (const field of ['lastMessage_key', 'lastMessageMessage', 'lastMessageContextInfo']) { + if (typeof contact[field] === 'string') contact[field] = JSON.parse(contact[field]); + } + contact.windowActive = Boolean(contact.windowActive); + } const lastMessage = contact.lastMessageId ? { id: contact.lastMessageId, diff --git a/test/mysql-compatibility.test.cjs b/test/mysql-compatibility.test.cjs new file mode 100644 index 0000000000..8cc4ac1c5b --- /dev/null +++ b/test/mysql-compatibility.test.cjs @@ -0,0 +1,150 @@ +// Run against a disposable, migrated local database with DATABASE_PROVIDER=mysql: +// node --test test/mysql-compatibility.test.cjs +const assert = require('node:assert/strict'); +const { randomUUID } = require('node:crypto'); +const { readFileSync } = require('node:fs'); +const test = require('node:test'); +const ts = require('typescript'); +const { Prisma, PrismaClient } = require('@prisma/client'); + +// Execute the actual service methods without importing server.module or starting WhatsApp/network workers. +function methods(file, names) { + const source = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true); + const members = source.statements.filter(ts.isClassDeclaration).flatMap((node) => [...node.members]); + const selected = names.map((name) => { + const member = members.find((node) => node.name?.getText(source) === name); + assert.ok(member, `${file}: ${name} exists`); + return member.getText(source); + }); + const js = ts.transpileModule(`class Subject { ${selected.join('\n')} }`, { + compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS }, + }).outputText; + return new Function('Prisma', 'cuid', 'isArray', 'status', 'createJid', `${js}; return Subject.prototype;`)( + Prisma, randomUUID, Array.isArray, { 3: 'DELIVERY_ACK', 4: 'READ' }, (jid) => `${jid}@s.whatsapp.net`, + ); +} + +const repository = methods('src/api/repository/repository.service.ts', ['isMysql', 'jsonPath']); +const channel = methods('src/api/services/channel.service.ts', ['fetchMessages', 'fetchChats']); +const whatsapp = methods('src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts', [ + 'getMessage', 'updateMessagesReadedByTimestamp', 'updateChatUnreadMessages', 'addLabel', 'removeLabel', +]); +const chatwoot = methods('src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts', [ + 'getMessageByKeyId', 'updateChatwootMessageId', +]); + +test('JSON paths follow the provider, including escaped member names', () => { + for (const provider of ['mysql', 'postgresql', 'psql_bouncer']) { + const receiver = Object.create(repository); + receiver.configService = { get: () => ({ PROVIDER: provider }) }; + const segments = ['key.with.dot', 'quoted"key']; + assert.deepEqual(receiver.jsonPath(...segments), provider === 'mysql' ? '$."key.with.dot"."quoted\\"key"' : segments); + } +}); + +test('MySQL/MariaDB service operations preserve data and instance isolation', async (t) => { + assert.equal(process.env.DATABASE_PROVIDER, 'mysql'); + const uri = new URL(process.env.DATABASE_CONNECTION_URI); + assert.ok(['127.0.0.1', 'localhost'].includes(uri.hostname)); + assert.match(uri.pathname, /^\/evolution_mysql_test_\d+$/); + const db = new PrismaClient(); + db.isMysql = true; + db.jsonPath = repository.jsonPath; + const base = { + prismaRepository: db, + instanceId: 'instance-a', + cleanMessageData: (message) => message, + logger: { verbose() {}, error() {} }, + isImportHistoryAvailable: () => false, + }; + const wa = Object.assign(Object.create(whatsapp), base); + const chats = Object.assign(Object.create(channel), base); + const cw = Object.assign(Object.create(chatwoot), base); + const jid = '966500000001@s.whatsapp.net'; + const now = Math.floor(Date.now() / 1000); + try { + await db.instance.createMany({ data: [{ id: 'instance-a', name: 'a' }, { id: 'instance-b', name: 'b' }] }); + await db.chat.createMany({ data: [ + { id: 'chat-b', instanceId: 'instance-b', remoteJid: jid, name: 'Other account', unreadMessages: 9 }, + { id: 'chat-a', instanceId: 'instance-a', remoteJid: jid, name: 'Arabic العربية', unreadMessages: 2 }, + ] }); + for (const [id, instanceId, fromMe, time] of [ + ['incoming', 'instance-a', false, now - 10], + ['outgoing', 'instance-a', true, now], + ['other-account', 'instance-b', false, now + 10], + ]) { + await db.message.create({ data: { + id, instanceId, key: { id: fromMe ? 'sent' : 'shared-id', remoteJid: jid, fromMe }, + messageType: 'conversation', message: { conversation: 'مرحبا' }, source: 'web', + status: 'DELIVERY_ACK', messageTimestamp: time, + } }); + } + + await t.test('JSON filters accept strings and booleans and retain scope', async () => { + const result = await chats.fetchMessages({ where: { key: { fromMe: true, remoteJid: jid } } }); + assert.deepEqual(result.messages.records.map((m) => m.id), ['outgoing']); + assert.equal(result.messages.total, 1); + const message = await wa.getMessage({ id: 'shared-id' }, true); + assert.equal(message.id, 'incoming'); + assert.equal((await wa.getMessage({ id: 'shared-id' })).conversation, 'مرحبا'); + assert.equal(await cw.getMessageByKeyId({ instanceId: 'instance-a' }, "' OR 1=1 --"), null); + }); + + await t.test('read receipt updates do not touch outgoing or other-account messages/chats', async () => { + assert.equal(await wa.updateMessagesReadedByTimestamp(jid, now), 1); + assert.equal((await db.message.findUnique({ where: { id: 'incoming' } })).status, 'READ'); + assert.equal((await db.message.findUnique({ where: { id: 'outgoing' } })).status, 'DELIVERY_ACK'); + assert.equal((await db.message.findUnique({ where: { id: 'other-account' } })).status, 'DELIVERY_ACK'); + assert.equal((await db.chat.findUnique({ where: { id: 'chat-b' } })).unreadMessages, 9); + assert.equal((await db.chat.findUnique({ where: { id: 'chat-a' } })).unreadMessages, 0); + }); + + await t.test('chat list returns newest message, JSON objects, booleans and pagination', async () => { + const result = await chats.fetchChats({}); + assert.equal(result.length, 1); + assert.equal(result[0].lastMessage.id, 'outgoing'); + assert.equal(result[0].lastMessage.key.remoteJid, jid); + assert.equal(result[0].lastMessage.message.conversation, 'مرحبا'); + assert.equal(typeof result[0].windowActive, 'boolean'); + assert.equal(result[0].pushName, 'Arabic العربية'); + assert.deepEqual(await chats.fetchChats({ skip: 1 }), []); + assert.deepEqual(await chats.fetchChats({ where: { remoteJid: "x' OR 1=1 --@s.whatsapp.net" } }), []); + const filtered = await chats.fetchChats({ where: { messageTimestamp: { + gte: new Date((now - 15) * 1000).toISOString(), lte: new Date((now - 5) * 1000).toISOString(), + } }, take: 1 }); + assert.equal(filtered[0].lastMessage.id, 'incoming'); + }); + + await t.test('label operations are atomic, literal and scoped', async () => { + await Promise.all(['a', 'b', 'a', 'x%_!', "quote'"].map((label) => wa.addLabel(label, 'instance-a', jid))); + let chat = await db.chat.findUnique({ where: { id: 'chat-a' } }); + assert.deepEqual([...chat.labels].sort(), ['a', 'b', 'x%_!', "quote'"].sort()); + await wa.removeLabel('x%_!', 'instance-a', jid); + await wa.removeLabel('absent', 'instance-a', jid); + chat = await db.chat.findUnique({ where: { id: 'chat-a' } }); + assert.deepEqual([...chat.labels].sort(), ['a', 'b', "quote'"].sort()); + assert.equal((await db.chat.findUnique({ where: { id: 'chat-b' } })).labels, null); + await wa.removeLabel('absent', 'instance-a', 'new@s.whatsapp.net'); + assert.deepEqual((await db.chat.findFirst({ where: { instanceId: 'instance-a', remoteJid: 'new@s.whatsapp.net' } })).labels, []); + }); + + await t.test('compound label identity and LID cache can be written and read', async () => { + const identity = { labelId: 'label', instanceId: 'instance-a' }; + for (const name of ['first', 'updated']) await db.label.upsert({ + where: { labelId_instanceId: identity }, create: { ...identity, name, color: '1' }, update: { name }, + }); + await db.label.create({ data: { ...identity, instanceId: 'instance-b', name: 'other', color: '2' } }); + assert.equal((await db.label.findUnique({ where: { labelId_instanceId: identity } })).name, 'updated'); + await db.isOnWhatsapp.create({ data: { remoteJid: jid, jidOptions: jid, lid: '123@lid' } }); + assert.equal((await db.isOnWhatsapp.findUnique({ where: { remoteJid: jid } })).lid, '123@lid'); + }); + + await t.test('Chatwoot IDs update only the matching account', async () => { + await cw.updateChatwootMessageId({ key: { id: 'shared-id' } }, { messageId: 42, isRead: true }, { instanceId: 'instance-a' }); + assert.equal((await cw.getMessageByKeyId({ instanceId: 'instance-a' }, 'shared-id')).chatwootMessageId, 42); + assert.equal((await cw.getMessageByKeyId({ instanceId: 'instance-b' }, 'shared-id')).chatwootMessageId, null); + }); + } finally { + await db.$disconnect(); + } +});