Ver código fonte

Count only messages the inbox can open, and adopt a chat's earlier messages when it links (#385)

Bernt Christian Egeland 2 semanas atrás
pai
commit
9a861a95dc

+ 15 - 0
prisma/migrations/20260911180000_attach_orphan_telegram_messages/migration.sql

@@ -0,0 +1,15 @@
+-- Telegram messages that arrived from a chat before it was linked to a
+-- customer were stored with no customer, which made them invisible in the
+-- inbox and impossible to mark read, while the sidebar still counted them.
+-- The webhook attaches such messages the moment the chat links; this does
+-- the same for the rows already there, by the chat id the customer carries.
+BEGIN;
+
+UPDATE "telegram_messages" m
+   SET "customerId" = c."id"
+  FROM "customers" c
+ WHERE m."customerId" IS NULL
+   AND c."telegramChatId" = m."chatId"
+   AND c."organizationId" = m."organizationId";
+
+COMMIT;

+ 45 - 0
src/__tests__/features/messaging/unread-count.test.ts

@@ -0,0 +1,45 @@
+// @vitest-environment node
+/**
+ * The sidebar's Messages pill counts what the inbox can open. A Telegram or
+ * SMS message from a chat matched to no customer has no thread, so it could
+ * never be marked read; two of them kept the pill at 2 on a workshop whose
+ * inbox showed nothing unread. WhatsApp lists unmatched numbers as threads,
+ * so every WhatsApp message still counts.
+ */
+import { describe, expect, it, vi } from 'vitest'
+
+const { db } = vi.hoisted(() => ({
+  db: {
+    smsMessage: { count: vi.fn(async () => 1) },
+    telegramMessage: { count: vi.fn(async () => 2) },
+    whatsappMessage: { count: vi.fn(async () => 3) },
+  },
+}))
+vi.mock('@/lib/db', () => ({ db }))
+
+import { countUnreadMessages } from '@/features/messaging/Lib/unreadCount'
+
+describe('countUnreadMessages', () => {
+  it('adds up the channels', async () => {
+    expect(await countUnreadMessages('org_1')).toBe(6)
+  })
+
+  it('counts only messages that belong to a customer on SMS and Telegram', async () => {
+    await countUnreadMessages('org_1')
+    const listed = {
+      organizationId: 'org_1',
+      direction: 'inbound',
+      readAt: null,
+      customerId: { not: null },
+    }
+    expect(db.smsMessage.count).toHaveBeenCalledWith({ where: listed })
+    expect(db.telegramMessage.count).toHaveBeenCalledWith({ where: listed })
+  })
+
+  it('counts every WhatsApp message, since unmatched numbers are listed as threads', async () => {
+    await countUnreadMessages('org_1')
+    expect(db.whatsappMessage.count).toHaveBeenCalledWith({
+      where: { organizationId: 'org_1', direction: 'inbound', readAt: null },
+    })
+  })
+})

+ 9 - 1
src/__tests__/features/telegram/webhook-start.test.ts

@@ -15,7 +15,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
 const { db, sendTelegramMessage, notify } = vi.hoisted(() => ({
   db: {
     customer: { findFirst: vi.fn(), update: vi.fn() },
-    telegramMessage: { create: vi.fn() },
+    telegramMessage: { create: vi.fn(), updateMany: vi.fn() },
   },
   sendTelegramMessage: vi.fn(),
   notify: vi.fn(),
@@ -52,6 +52,7 @@ describe('the Telegram webhook', () => {
   beforeEach(() => {
     vi.clearAllMocks()
     db.telegramMessage.create.mockResolvedValue({ id: 'msg_1' })
+    db.telegramMessage.updateMany.mockResolvedValue({ count: 0 })
   })
 
   it('links the chat to the customer named in a deep link, and says so', async () => {
@@ -74,6 +75,12 @@ describe('the Telegram webhook', () => {
     )
     expect(db.telegramMessage.create, 'a link is not a message').not.toHaveBeenCalled()
     expect(notify).not.toHaveBeenCalled()
+    // Whatever the chat sent before it was linked is the customer's now,
+    // so it can be seen, read and counted like the rest.
+    expect(db.telegramMessage.updateMany).toHaveBeenCalledWith({
+      where: { organizationId: ORG, chatId: '4242', customerId: null },
+      data: { customerId: 'cust_42' },
+    })
   })
 
   it('tells a stranger who opened the bot by name how to connect, and files nothing', async () => {
@@ -95,6 +102,7 @@ describe('the Telegram webhook', () => {
     await POST(update('/start cust_of_someone_else'), { params })
 
     expect(db.customer.update).not.toHaveBeenCalled()
+    expect(db.telegramMessage.updateMany).not.toHaveBeenCalled()
     expect(sendTelegramMessage).not.toHaveBeenCalled()
   })
 

+ 8 - 0
src/app/api/webhooks/telegram/[organizationId]/route.ts

@@ -144,6 +144,14 @@ async function handleStartCommand(
     data: { telegramChatId: chatId },
   })
 
+  // Anything this chat sent before it was linked (a message sent before the
+  // link was scanned, or while the webhook was down) belonged to nobody and
+  // could not be seen or read. It is theirs now.
+  await db.telegramMessage.updateMany({
+    where: { organizationId, chatId, customerId: null },
+    data: { customerId: customer.id },
+  })
+
   // Send confirmation message back
   try {
     await sendTelegramMessage(organizationId, {

+ 11 - 5
src/features/messaging/Lib/unreadCount.ts

@@ -3,18 +3,24 @@ import { db } from '@/lib/db'
 /**
  * Inbound messages the workshop has not opened yet, across every channel.
  *
- * Feeds the sidebar pill, so it is a count of what is waiting and nothing
- * else: opening a thread clears its share, and history from before read
- * tracking existed was stamped read by the migration that added it.
+ * Feeds the sidebar pill, so it is a count of what is waiting and can be
+ * opened, and nothing else: opening a thread clears its share, and history
+ * from before read tracking existed was stamped read by the migration that
+ * added it.
  *
  * Server-side only. It takes the organisation as an argument, so it must not
  * live in a 'use server' file where it would become callable from a browser.
  */
 export async function countUnreadMessages(organizationId: string): Promise<number> {
   const unread = { direction: 'inbound', readAt: null }
+  // Only what the inbox lists: an SMS or Telegram message from a chat matched
+  // to no customer has no thread to open, so it could never be marked read
+  // and the pill would say 2 forever. WhatsApp threads are listed under the
+  // bare number, so every WhatsApp message counts.
+  const listed = { customerId: { not: null } }
   const [sms, telegram, whatsapp] = await Promise.all([
-    db.smsMessage.count({ where: { organizationId, ...unread } }),
-    db.telegramMessage.count({ where: { organizationId, ...unread } }),
+    db.smsMessage.count({ where: { organizationId, ...unread, ...listed } }),
+    db.telegramMessage.count({ where: { organizationId, ...unread, ...listed } }),
     db.whatsappMessage.count({ where: { organizationId, ...unread } }),
   ])
   return sms + telegram + whatsapp