Prechádzať zdrojové kódy

Count unread messages in the sidebar and mark a thread read when it is opened (#322)

* Count unread messages in the sidebar and mark a thread read when it is opened

* Let a conversation be marked unread again
Bernt Christian Egeland 3 týždňov pred
rodič
commit
28ab4acfde

+ 5 - 1
messages/de/messaging.json

@@ -21,6 +21,10 @@
     "deleteFailed": "Konversation konnte nicht gelöscht werden",
     "delete": "Löschen",
     "cancel": "Abbrechen",
-    "loadMore": "Mehr laden"
+    "loadMore": "Mehr laden",
+    "unread": "{count} ungelesen",
+    "markUnread": "Als ungelesen markieren",
+    "nothingToMarkUnread": "Der Kunde hat in dieser Unterhaltung noch nicht geschrieben.",
+    "markUnreadFailed": "Die Unterhaltung konnte nicht als ungelesen markiert werden."
   }
 }

+ 1 - 0
messages/de/navigation.json

@@ -127,6 +127,7 @@
       "member": "Mitglied"
     },
     "badges": {
+      "messages": "{count} ungelesene Nachrichten",
       "workOrders": "{count} offene Arbeitsaufträge",
       "inspections": "{count} laufende Inspektionen",
       "reminders": "{count} fällige Erinnerungen"

+ 5 - 1
messages/en/messaging.json

@@ -21,6 +21,10 @@
     "deleteFailed": "Could not delete the conversation",
     "delete": "Delete",
     "cancel": "Cancel",
-    "loadMore": "Load more"
+    "loadMore": "Load more",
+    "unread": "{count} unread",
+    "markUnread": "Mark as unread",
+    "nothingToMarkUnread": "The customer has not written in this conversation yet.",
+    "markUnreadFailed": "Could not mark the conversation as unread."
   }
 }

+ 1 - 0
messages/en/navigation.json

@@ -127,6 +127,7 @@
       "member": "Member"
     },
     "badges": {
+      "messages": "{count} unread messages",
       "workOrders": "{count} open work orders",
       "inspections": "{count} inspections in progress",
       "reminders": "{count} reminders due"

+ 5 - 1
messages/es/messaging.json

@@ -21,6 +21,10 @@
     "deleteFailed": "No se pudo eliminar la conversación",
     "delete": "Eliminar",
     "cancel": "Cancelar",
-    "loadMore": "Cargar más"
+    "loadMore": "Cargar más",
+    "unread": "{count} sin leer",
+    "markUnread": "Marcar como no leído",
+    "nothingToMarkUnread": "El cliente aún no ha escrito en esta conversación.",
+    "markUnreadFailed": "No se pudo marcar la conversación como no leída."
   }
 }

+ 1 - 0
messages/es/navigation.json

@@ -127,6 +127,7 @@
       "member": "Miembro"
     },
     "badges": {
+      "messages": "{count} mensajes sin leer",
       "workOrders": "{count} órdenes de trabajo abiertas",
       "inspections": "{count} inspecciones en curso",
       "reminders": "{count} recordatorios vencidos"

+ 5 - 1
messages/fr/messaging.json

@@ -21,6 +21,10 @@
     "deleteFailed": "Impossible de supprimer la conversation",
     "delete": "Supprimer",
     "cancel": "Annuler",
-    "loadMore": "Charger plus"
+    "loadMore": "Charger plus",
+    "unread": "{count} non lus",
+    "markUnread": "Marquer comme non lu",
+    "nothingToMarkUnread": "Le client n'a pas encore écrit dans cette conversation.",
+    "markUnreadFailed": "Impossible de marquer la conversation comme non lue."
   }
 }

+ 1 - 0
messages/fr/navigation.json

@@ -127,6 +127,7 @@
       "member": "Membre"
     },
     "badges": {
+      "messages": "{count} messages non lus",
       "workOrders": "{count} ordres de travail ouverts",
       "inspections": "{count} inspections en cours",
       "reminders": "{count} rappels à échéance"

+ 5 - 1
messages/it/messaging.json

@@ -21,6 +21,10 @@
     "deleteFailed": "Impossibile eliminare la conversazione",
     "delete": "Elimina",
     "cancel": "Annulla",
-    "loadMore": "Carica altro"
+    "loadMore": "Carica altro",
+    "unread": "{count} non letti",
+    "markUnread": "Segna come non letto",
+    "nothingToMarkUnread": "Il cliente non ha ancora scritto in questa conversazione.",
+    "markUnreadFailed": "Impossibile segnare la conversazione come non letta."
   }
 }

+ 1 - 0
messages/it/navigation.json

@@ -127,6 +127,7 @@
       "member": "Membro"
     },
     "badges": {
+      "messages": "{count} messaggi non letti",
       "workOrders": "{count} ordini di lavoro aperti",
       "inspections": "{count} ispezioni in corso",
       "reminders": "{count} promemoria in scadenza"

+ 5 - 1
messages/lt/messaging.json

@@ -21,6 +21,10 @@
     "deleteFailed": "Nepavyko ištrinti pokalbio",
     "delete": "Ištrinti",
     "cancel": "Atšaukti",
-    "loadMore": "Įkelti daugiau"
+    "loadMore": "Įkelti daugiau",
+    "unread": "{count} neperskaityta",
+    "markUnread": "Pažymėti kaip neskaitytą",
+    "nothingToMarkUnread": "Klientas šiame pokalbyje dar nieko nerašė.",
+    "markUnreadFailed": "Nepavyko pažymėti pokalbio kaip neskaityto."
   }
 }

+ 1 - 0
messages/lt/navigation.json

@@ -127,6 +127,7 @@
       "member": "Narys"
     },
     "badges": {
+      "messages": "{count} neperskaityti pranešimai",
       "workOrders": "{count} atviri darbo užsakymai",
       "inspections": "{count} vykdomos apžiūros",
       "reminders": "{count} priminimai, kurių terminas suėjo"

+ 5 - 1
messages/nb/messaging.json

@@ -21,6 +21,10 @@
     "deleteFailed": "Kunne ikke slette samtalen",
     "delete": "Slett",
     "cancel": "Avbryt",
-    "loadMore": "Last mer"
+    "loadMore": "Last mer",
+    "unread": "{count} ulest",
+    "markUnread": "Merk som ulest",
+    "nothingToMarkUnread": "Kunden har ikke skrevet i denne samtalen ennå.",
+    "markUnreadFailed": "Kunne ikke merke samtalen som ulest."
   }
 }

+ 1 - 0
messages/nb/navigation.json

@@ -127,6 +127,7 @@
       "member": "Medlem"
     },
     "badges": {
+      "messages": "{count} uleste meldinger",
       "workOrders": "{count} åpne arbeidsordrer",
       "inspections": "{count} pågående inspeksjoner",
       "reminders": "{count} forfalte påminnelser"

+ 5 - 1
messages/nl/messaging.json

@@ -21,6 +21,10 @@
     "deleteFailed": "Kon het gesprek niet verwijderen",
     "delete": "Verwijderen",
     "cancel": "Annuleren",
-    "loadMore": "Meer laden"
+    "loadMore": "Meer laden",
+    "unread": "{count} ongelezen",
+    "markUnread": "Markeren als ongelezen",
+    "nothingToMarkUnread": "De klant heeft in dit gesprek nog niets geschreven.",
+    "markUnreadFailed": "Het gesprek kon niet als ongelezen worden gemarkeerd."
   }
 }

+ 1 - 0
messages/nl/navigation.json

@@ -127,6 +127,7 @@
       "member": "Lid"
     },
     "badges": {
+      "messages": "{count} ongelezen berichten",
       "workOrders": "{count} open werkorders",
       "inspections": "{count} lopende inspecties",
       "reminders": "{count} herinneringen vervallen"

+ 5 - 1
messages/pl/messaging.json

@@ -21,6 +21,10 @@
     "deleteFailed": "Nie udało się usunąć rozmowy",
     "delete": "Usuń",
     "cancel": "Anuluj",
-    "loadMore": "Wczytaj więcej"
+    "loadMore": "Wczytaj więcej",
+    "unread": "{count} nieprzeczytane",
+    "markUnread": "Oznacz jako nieprzeczytane",
+    "nothingToMarkUnread": "Klient jeszcze nic nie napisał w tej rozmowie.",
+    "markUnreadFailed": "Nie udało się oznaczyć rozmowy jako nieprzeczytanej."
   }
 }

+ 1 - 0
messages/pl/navigation.json

@@ -127,6 +127,7 @@
       "member": "Członek"
     },
     "badges": {
+      "messages": "{count} nieprzeczytane wiadomości",
       "workOrders": "{count} otwarte zlecenia",
       "inspections": "{count} przeglądy w toku",
       "reminders": "{count} zaległe przypomnienia"

+ 5 - 1
messages/pt-BR/messaging.json

@@ -21,6 +21,10 @@
     "deleteFailed": "Não foi possível excluir a conversa",
     "delete": "Excluir",
     "cancel": "Cancelar",
-    "loadMore": "Carregar mais"
+    "loadMore": "Carregar mais",
+    "unread": "{count} não lidas",
+    "markUnread": "Marcar como não lida",
+    "nothingToMarkUnread": "O cliente ainda não escreveu nesta conversa.",
+    "markUnreadFailed": "Não foi possível marcar a conversa como não lida."
   }
 }

+ 1 - 0
messages/pt-BR/navigation.json

@@ -127,6 +127,7 @@
       "member": "Membro"
     },
     "badges": {
+      "messages": "{count} mensagens não lidas",
       "workOrders": "{count} ordens de serviço abertas",
       "inspections": "{count} inspeções em andamento",
       "reminders": "{count} lembretes vencidos"

+ 5 - 1
messages/ru/messaging.json

@@ -21,6 +21,10 @@
     "deleteFailed": "Не удалось удалить переписку",
     "delete": "Удалить",
     "cancel": "Отмена",
-    "loadMore": "Загрузить ещё"
+    "loadMore": "Загрузить ещё",
+    "unread": "{count} непрочитанных",
+    "markUnread": "Отметить как непрочитанное",
+    "nothingToMarkUnread": "Клиент ещё ничего не написал в этом разговоре.",
+    "markUnreadFailed": "Не удалось отметить разговор как непрочитанный."
   }
 }

+ 1 - 0
messages/ru/navigation.json

@@ -127,6 +127,7 @@
       "member": "Участник"
     },
     "badges": {
+      "messages": "{count} непрочитанных сообщений",
       "workOrders": "{count} открытых заказ-нарядов",
       "inspections": "{count} проверок в работе",
       "reminders": "{count} напоминаний просрочено"

+ 5 - 1
messages/tr/messaging.json

@@ -21,6 +21,10 @@
     "deleteFailed": "Görüşme silinemedi",
     "delete": "Sil",
     "cancel": "İptal",
-    "loadMore": "Daha fazla yükle"
+    "loadMore": "Daha fazla yükle",
+    "unread": "{count} okunmamış",
+    "markUnread": "Okunmadı olarak işaretle",
+    "nothingToMarkUnread": "Müşteri bu konuşmada henüz bir şey yazmadı.",
+    "markUnreadFailed": "Konuşma okunmadı olarak işaretlenemedi."
   }
 }

+ 1 - 0
messages/tr/navigation.json

@@ -127,6 +127,7 @@
       "member": "Üye"
     },
     "badges": {
+      "messages": "{count} okunmamış mesaj",
       "workOrders": "{count} açık iş emri",
       "inspections": "{count} devam eden muayene",
       "reminders": "{count} vadesi gelen hatırlatma"

+ 24 - 0
prisma/migrations/20260904120000_message_read_tracking/migration.sql

@@ -0,0 +1,24 @@
+-- Read tracking for inbound messages. A null "readAt" on an inbound row is an
+-- unread message: the sidebar counts those and the inbox marks a thread's
+-- rows read when it is opened. Outbound rows never get a value.
+--
+-- Everything already in the tables is stamped as read, so the count starts
+-- from zero rather than from every message the workshop has ever received.
+-- One transaction: Prisma does not wrap a migration in one, and a half-run
+-- here would leave some channels counting history and others not.
+
+BEGIN;
+
+ALTER TABLE "sms_messages" ADD COLUMN "readAt" TIMESTAMP(3);
+ALTER TABLE "telegram_messages" ADD COLUMN "readAt" TIMESTAMP(3);
+ALTER TABLE "whatsapp_messages" ADD COLUMN "readAt" TIMESTAMP(3);
+
+UPDATE "sms_messages" SET "readAt" = "createdAt" WHERE "direction" = 'inbound';
+UPDATE "telegram_messages" SET "readAt" = "createdAt" WHERE "direction" = 'inbound';
+UPDATE "whatsapp_messages" SET "readAt" = "createdAt" WHERE "direction" = 'inbound';
+
+CREATE INDEX "sms_messages_organizationId_readAt_idx" ON "sms_messages"("organizationId", "readAt");
+CREATE INDEX "telegram_messages_organizationId_readAt_idx" ON "telegram_messages"("organizationId", "readAt");
+CREATE INDEX "whatsapp_messages_organizationId_readAt_idx" ON "whatsapp_messages"("organizationId", "readAt");
+
+COMMIT;

+ 22 - 10
prisma/schema/messaging.prisma

@@ -16,18 +16,21 @@ model Notification {
 }
 
 model SmsMessage {
-  id                String   @id @default(cuid())
+  id                String    @id @default(cuid())
   direction         String // "inbound" | "outbound"
   fromNumber        String
   toNumber          String
   body              String
-  status            String   @default("queued") // queued | sent | delivered | failed | received
+  status            String    @default("queued") // queued | sent | delivered | failed | received
   providerMsgId     String?
   errorMessage      String?
   relatedEntityType String?
   relatedEntityId   String?
-  createdAt         DateTime @default(now())
-  updatedAt         DateTime @updatedAt
+  /// When the workshop first opened the thread this arrived in. Null on an
+  /// inbound message nobody has read yet; never set on an outbound row.
+  readAt            DateTime?
+  createdAt         DateTime  @default(now())
+  updatedAt         DateTime  @updatedAt
 
   organizationId String
   organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@ -38,21 +41,25 @@ model SmsMessage {
   @@index([organizationId, customerId, createdAt(sort: Desc)])
   @@index([organizationId, createdAt(sort: Desc)])
   @@index([toNumber, organizationId])
+  @@index([organizationId, readAt])
   @@map("sms_messages")
 }
 
 model TelegramMessage {
-  id                String   @id @default(cuid())
+  id                String    @id @default(cuid())
   direction         String // "inbound" | "outbound"
   chatId            String // Telegram chat ID
   body              String
-  status            String   @default("queued") // queued | sent | delivered | failed | received
+  status            String    @default("queued") // queued | sent | delivered | failed | received
   telegramMessageId String? // Telegram's message ID for tracking
   errorMessage      String?
   relatedEntityType String?
   relatedEntityId   String?
-  createdAt         DateTime @default(now())
-  updatedAt         DateTime @updatedAt
+  /// When the workshop first opened the thread this arrived in. Null on an
+  /// inbound message nobody has read yet; never set on an outbound row.
+  readAt            DateTime?
+  createdAt         DateTime  @default(now())
+  updatedAt         DateTime  @updatedAt
 
   organizationId String
   organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@ -62,6 +69,7 @@ model TelegramMessage {
 
   @@index([organizationId, customerId, createdAt(sort: Desc)])
   @@index([organizationId, chatId, createdAt(sort: Desc)])
+  @@index([organizationId, readAt])
   @@map("telegram_messages")
 }
 
@@ -98,8 +106,11 @@ model WhatsappMessage {
   relatedEntityType String?
   relatedEntityId   String?
 
-  createdAt DateTime @default(now())
-  updatedAt DateTime @updatedAt
+  /// When the workshop first opened the thread this arrived in. Null on an
+  /// inbound message nobody has read yet; never set on an outbound row.
+  readAt    DateTime?
+  createdAt DateTime  @default(now())
+  updatedAt DateTime  @updatedAt
 
   organizationId String
   organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@ -110,6 +121,7 @@ model WhatsappMessage {
   @@index([organizationId, customerId, createdAt(sort: Desc)])
   @@index([organizationId, createdAt(sort: Desc)])
   @@index([providerMessageId])
+  @@index([organizationId, readAt])
   @@map("whatsapp_messages")
 }
 

+ 1 - 0
src/__tests__/features/messaging/inbox-paging.test.ts

@@ -22,6 +22,7 @@ function thread(channel: InboxThread['channel'], minutesAgo: number): InboxThrea
     lastMessage: 'hello',
     lastDirection: 'inbound',
     lastAt: at.toISOString(),
+    unread: 0,
   }
 }
 

+ 6 - 1
src/app/(authenticated)/layout.tsx

@@ -33,6 +33,7 @@ import { getManifest } from '@/integrations/registry'
 import { PlateLookupProvider } from '@/components/plate-lookup-context'
 import { PlateLookupCommand } from '@/features/vehicles/Components/PlateLookupCommand'
 import { OPEN_SERVICE_STATUSES } from '@/lib/service-record'
+import { countUnreadMessages } from '@/features/messaging/Lib/unreadCount'
 import { addZonedDays, safeTimeZone, startOfZonedDay } from '@/lib/timezone'
 
 export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
@@ -129,7 +130,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
   // something. Only the screens this role can see are counted at all.
   const timeZone = safeTimeZone(data.timezone)
   const endOfToday = startOfZonedDay(addZonedDays(new Date(), 1, timeZone), timeZone)
-  const [openWorkOrders, activeInspections, dueReminders] = await Promise.all([
+  const [openWorkOrders, activeInspections, dueReminders, unreadMessages] = await Promise.all([
     visibleSubjects.includes(PermissionSubject.WORK_ORDERS)
       ? db.serviceRecord.count({
           where: {
@@ -152,11 +153,15 @@ export default async function DashboardLayout({ children }: { children: React.Re
           },
         })
       : 0,
+    visibleSubjects.includes(PermissionSubject.CUSTOMERS)
+      ? countUnreadMessages(data.organizationId)
+      : 0,
   ])
   const sidebarCounts = {
     workOrders: openWorkOrders,
     inspections: activeInspections,
     reminders: dueReminders,
+    messages: unreadMessages,
   }
 
   // Product announcements join the same queue as the hints a setting flip

+ 4 - 1
src/components/app-sidebar.tsx

@@ -95,9 +95,10 @@ type OrgInfo = { id: string; name: string; role: string }
  * These are things still in hand, never running totals: a total would only
  * ever grow, and a four-digit pill says nothing. Work orders count the ones
  * not yet finished, inspections the ones still being walked, reminders the
- * ones past their date.
+ * ones past their date, messages the inbound ones nobody has opened.
  */
 export type SidebarCounts = {
+  messages: number
   workOrders: number
   inspections: number
   reminders: number
@@ -173,6 +174,8 @@ export function AppSidebar({
       url: '/messages',
       icon: MessageSquare,
       subject: 'customers',
+      count: counts?.messages,
+      countKey: 'sidebar.badges.messages',
     },
   ].filter((item) => canAccess(item.subject))
 

+ 163 - 12
src/features/messaging/Actions/inboxActions.ts

@@ -35,6 +35,8 @@ export interface InboxThread {
   lastMessage: string
   lastDirection: string
   lastAt: string
+  /** Inbound messages nobody has opened yet. */
+  unread: number
 }
 
 export interface InboxPage {
@@ -62,13 +64,22 @@ interface ThreadRow {
   createdAt: Date
 }
 
+interface UnreadRow {
+  identity: string
+  unread: number
+}
+
 /** Empty bodies are common on media messages, so say what arrived instead. */
 function preview(body: string | null, mediaType?: string | null): string {
   if (body?.trim()) return body
   return mediaType ? `[${mediaType}]` : ''
 }
 
-function toThread(channel: MessagingChannel, row: ThreadRow): InboxThread {
+function toThread(
+  channel: MessagingChannel,
+  row: ThreadRow,
+  unread: Map<string, number>
+): InboxThread {
   const contact = row.contact ?? ''
   return {
     key: `${channel}:${row.identity}`,
@@ -79,9 +90,14 @@ function toThread(channel: MessagingChannel, row: ThreadRow): InboxThread {
     lastMessage: preview(row.body, row.mediaType),
     lastDirection: row.direction,
     lastAt: row.createdAt.toISOString(),
+    unread: unread.get(row.identity) ?? 0,
   }
 }
 
+function byIdentity(rows: UnreadRow[]): Map<string, number> {
+  return new Map(rows.map((row) => [row.identity, row.unread]))
+}
+
 export interface InboxQuery {
   /** ISO timestamp of the last row already shown. */
   cursor?: string | null
@@ -121,8 +137,13 @@ export async function getInboxThreads(query: InboxQuery = {}) {
       // (organizationId, customerId, createdAt DESC) indexes already order for.
       // History outlives configuration, so this does not check `channels`: a
       // workshop that switched a provider off can still read what it sent.
-      const [smsRows, telegramRows, whatsappRows] = await Promise.all([
-        db.$queryRaw<ThreadRow[]>`
+      //
+      // Unread is counted per thread in its own small query rather than
+      // folded into the DISTINCT ON, which only ever sees one row per thread.
+      // Unread rows are few, so the whole workshop is counted at once.
+      const [smsRows, telegramRows, whatsappRows, smsUnread, telegramUnread, whatsappUnread] =
+        await Promise.all([
+          db.$queryRaw<ThreadRow[]>`
           SELECT * FROM (
             SELECT DISTINCT ON (m."customerId")
               m."customerId" AS identity,
@@ -145,7 +166,7 @@ export async function getInboxThreads(query: InboxQuery = {}) {
           ORDER BY t."createdAt" DESC
           LIMIT ${limit}
         `,
-        db.$queryRaw<ThreadRow[]>`
+          db.$queryRaw<ThreadRow[]>`
           SELECT * FROM (
             SELECT DISTINCT ON (m."customerId")
               m."customerId" AS identity,
@@ -168,10 +189,10 @@ export async function getInboxThreads(query: InboxQuery = {}) {
           ORDER BY t."createdAt" DESC
           LIMIT ${limit}
         `,
-        // WhatsApp threads can belong to a number we never matched to a
-        // customer, so they group by customer when there is one and by the
-        // number itself when there is not.
-        db.$queryRaw<ThreadRow[]>`
+          // WhatsApp threads can belong to a number we never matched to a
+          // customer, so they group by customer when there is one and by the
+          // number itself when there is not.
+          db.$queryRaw<ThreadRow[]>`
           SELECT * FROM (
             SELECT DISTINCT ON (COALESCE(m."customerId", CASE WHEN m."direction" = 'inbound' THEN m."fromNumber" ELSE m."toNumber" END))
               COALESCE(m."customerId", CASE WHEN m."direction" = 'inbound' THEN m."fromNumber" ELSE m."toNumber" END) AS identity,
@@ -195,13 +216,34 @@ export async function getInboxThreads(query: InboxQuery = {}) {
           ORDER BY t."createdAt" DESC
           LIMIT ${limit}
         `,
-      ])
+          db.$queryRaw<UnreadRow[]>`
+          SELECT "customerId" AS identity, COUNT(*)::int AS unread
+          FROM "sms_messages"
+          WHERE "organizationId" = ${organizationId}
+            AND "direction" = 'inbound' AND "readAt" IS NULL AND "customerId" IS NOT NULL
+          GROUP BY "customerId"
+        `,
+          db.$queryRaw<UnreadRow[]>`
+          SELECT "customerId" AS identity, COUNT(*)::int AS unread
+          FROM "telegram_messages"
+          WHERE "organizationId" = ${organizationId}
+            AND "direction" = 'inbound' AND "readAt" IS NULL AND "customerId" IS NOT NULL
+          GROUP BY "customerId"
+        `,
+          db.$queryRaw<UnreadRow[]>`
+          SELECT COALESCE("customerId", "fromNumber") AS identity, COUNT(*)::int AS unread
+          FROM "whatsapp_messages"
+          WHERE "organizationId" = ${organizationId}
+            AND "direction" = 'inbound' AND "readAt" IS NULL
+          GROUP BY COALESCE("customerId", "fromNumber")
+        `,
+        ])
 
       const { threads, nextCursor } = mergeChannelPages(
         [
-          smsRows.map((row) => toThread('sms', row)),
-          telegramRows.map((row) => toThread('telegram', row)),
-          whatsappRows.map((row) => toThread('whatsapp', row)),
+          smsRows.map((row) => toThread('sms', row, byIdentity(smsUnread))),
+          telegramRows.map((row) => toThread('telegram', row, byIdentity(telegramUnread))),
+          whatsappRows.map((row) => toThread('whatsapp', row, byIdentity(whatsappUnread))),
         ],
         limit
       )
@@ -215,3 +257,112 @@ export async function getInboxThreads(query: InboxQuery = {}) {
     }
   )
 }
+
+/**
+ * Marks everything inbound in one thread as read.
+ *
+ * Called when the thread is opened. Reading a conversation is what the
+ * customers permission already grants, so no extra permission is asked for.
+ * Returns how many rows changed, so the caller can skip a refresh when it was
+ * nothing.
+ */
+export async function markThreadRead(thread: {
+  channel: MessagingChannel
+  customerId: string | null
+  /** The phone number a WhatsApp thread without a customer is filed under. */
+  contact: string
+}) {
+  return withAuth(
+    async ({ organizationId }): Promise<{ marked: number }> => {
+      const now = new Date()
+      const unread = { organizationId, direction: 'inbound', readAt: null }
+
+      if (thread.channel === 'whatsapp') {
+        const result = await db.whatsappMessage.updateMany({
+          where: thread.customerId
+            ? { ...unread, customerId: thread.customerId }
+            : { ...unread, customerId: null, fromNumber: thread.contact },
+          data: { readAt: now },
+        })
+        return { marked: result.count }
+      }
+
+      if (!thread.customerId) return { marked: 0 }
+
+      const result =
+        thread.channel === 'sms'
+          ? await db.smsMessage.updateMany({
+              where: { ...unread, customerId: thread.customerId },
+              data: { readAt: now },
+            })
+          : await db.telegramMessage.updateMany({
+              where: { ...unread, customerId: thread.customerId },
+              data: { readAt: now },
+            })
+      return { marked: result.count }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS },
+      ],
+    }
+  )
+}
+
+/**
+ * Puts a thread back in the unread pile.
+ *
+ * Only the newest inbound message is unstamped: one waiting message is what
+ * "come back to this" means, and it keeps the pill honest about how much is
+ * actually new. A thread the customer has never written to has nothing to
+ * mark, and says so with a zero.
+ */
+export async function markThreadUnread(thread: {
+  channel: MessagingChannel
+  customerId: string | null
+  contact: string
+}) {
+  return withAuth(
+    async ({ organizationId }): Promise<{ marked: number }> => {
+      const inbound = { organizationId, direction: 'inbound' }
+      const newest = { orderBy: { createdAt: 'desc' as const }, select: { id: true } }
+
+      if (thread.channel === 'whatsapp') {
+        const latest = await db.whatsappMessage.findFirst({
+          where: thread.customerId
+            ? { ...inbound, customerId: thread.customerId }
+            : { ...inbound, customerId: null, fromNumber: thread.contact },
+          ...newest,
+        })
+        if (!latest) return { marked: 0 }
+        await db.whatsappMessage.update({ where: { id: latest.id }, data: { readAt: null } })
+        return { marked: 1 }
+      }
+
+      if (!thread.customerId) return { marked: 0 }
+
+      if (thread.channel === 'sms') {
+        const latest = await db.smsMessage.findFirst({
+          where: { ...inbound, customerId: thread.customerId },
+          ...newest,
+        })
+        if (!latest) return { marked: 0 }
+        await db.smsMessage.update({ where: { id: latest.id }, data: { readAt: null } })
+        return { marked: 1 }
+      }
+
+      const latest = await db.telegramMessage.findFirst({
+        where: { ...inbound, customerId: thread.customerId },
+        ...newest,
+      })
+      if (!latest) return { marked: 0 }
+      await db.telegramMessage.update({ where: { id: latest.id }, data: { readAt: null } })
+      return { marked: 1 }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS },
+      ],
+    }
+  )
+}

+ 114 - 27
src/features/messaging/Components/UnifiedInbox.tsx

@@ -1,6 +1,7 @@
 'use client'
 
 import { useCallback, useEffect, useRef, useState } from 'react'
+import { useRouter } from 'next/navigation'
 import { useTranslations } from 'next-intl'
 import { cn } from '@/lib/utils'
 import {
@@ -8,6 +9,7 @@ import {
   ChevronDown,
   Inbox,
   Loader2,
+  MailOpen,
   MoreVertical,
   Plus,
   Search,
@@ -36,6 +38,7 @@ import {
   DropdownMenu,
   DropdownMenuContent,
   DropdownMenuItem,
+  DropdownMenuSeparator,
   DropdownMenuTrigger,
 } from '@/components/ui/dropdown-menu'
 import { toast } from 'sonner'
@@ -53,7 +56,13 @@ import {
   deleteWhatsappConversation,
   deleteWhatsappConversationByPhone,
 } from '@/features/whatsapp/Actions/whatsappActions'
-import { getInboxThreads, type InboxThread, type MessagingChannel } from '../Actions/inboxActions'
+import {
+  getInboxThreads,
+  markThreadRead,
+  markThreadUnread,
+  type InboxThread,
+  type MessagingChannel,
+} from '../Actions/inboxActions'
 import { ChannelBadge, channelLabel } from './ChannelBadge'
 import { avatarTint, initials } from '../Lib/threadDisplay'
 import { useDebouncedSearch } from '@/hooks/use-debounced-search'
@@ -95,6 +104,7 @@ export function UnifiedInbox({
   onChanged?: () => void
 }) {
   const t = useTranslations('messaging.inbox')
+  const router = useRouter()
   const [threads, setThreads] = useState(initialThreads)
   const [cursor, setCursor] = useState<string | null>(initialCursor)
   const [loadingMore, setLoadingMore] = useState(false)
@@ -162,25 +172,77 @@ export function UnifiedInbox({
 
   const visibleThreads = threads
 
+  /**
+   * Opening a thread is reading it. The row loses its marker at once, the
+   * server is told, and the layout is refreshed so the sidebar pill follows.
+   */
+  const markRead = useCallback(
+    async (thread: InboxThread) => {
+      if (thread.unread === 0) return
+      setThreads((previous) =>
+        previous.map((row) => (row.key === thread.key ? { ...row, unread: 0 } : row))
+      )
+      const result = await markThreadRead({
+        channel: thread.channel,
+        customerId: thread.customerId,
+        contact: thread.contact,
+      })
+      if (result.success && result.data && result.data.marked > 0) router.refresh()
+    },
+    [router]
+  )
+
+  /**
+   * The opposite gesture, for "come back to this later". The thread stays
+   * open; it is only the marker and the pill that come back. Selecting it
+   * again reads it again, which is what a mail client does too.
+   */
+  const markUnread = useCallback(
+    async (thread: InboxThread) => {
+      const result = await markThreadUnread({
+        channel: thread.channel,
+        customerId: thread.customerId,
+        contact: thread.contact,
+      })
+      if (!result.success) {
+        toast.error(result.error ?? t('markUnreadFailed'))
+        return
+      }
+      if (!result.data || result.data.marked === 0) {
+        toast.info(t('nothingToMarkUnread'))
+        return
+      }
+      setThreads((previous) =>
+        previous.map((row) => (row.key === thread.key ? { ...row, unread: 1 } : row))
+      )
+      router.refresh()
+    },
+    [router, t]
+  )
+
   /** SMS and Telegram hand their history over as props, so it loads on select. */
-  const select = useCallback(async (thread: InboxThread) => {
-    setSelected(thread)
-    setSmsData(null)
-    setTelegramData(null)
-    if (!thread.customerId) return
-
-    if (thread.channel === 'sms') {
-      setLoadingConversation(true)
-      const result = await getConversation(thread.customerId)
-      if (result.success && result.data) setSmsData(result.data)
-      setLoadingConversation(false)
-    } else if (thread.channel === 'telegram') {
-      setLoadingConversation(true)
-      const result = await getTelegramConversation(thread.customerId)
-      if (result.success && result.data) setTelegramData(result.data)
-      setLoadingConversation(false)
-    }
-  }, [])
+  const select = useCallback(
+    async (thread: InboxThread) => {
+      setSelected(thread)
+      setSmsData(null)
+      setTelegramData(null)
+      void markRead(thread)
+      if (!thread.customerId) return
+
+      if (thread.channel === 'sms') {
+        setLoadingConversation(true)
+        const result = await getConversation(thread.customerId)
+        if (result.success && result.data) setSmsData(result.data)
+        setLoadingConversation(false)
+      } else if (thread.channel === 'telegram') {
+        setLoadingConversation(true)
+        const result = await getTelegramConversation(thread.customerId)
+        if (result.success && result.data) setTelegramData(result.data)
+        setLoadingConversation(false)
+      }
+    },
+    [markRead]
+  )
 
   /** Removes our copy of a conversation; the customer's phone keeps theirs. */
   const confirmDelete = async () => {
@@ -235,6 +297,7 @@ export function UnifiedInbox({
         lastMessage: '',
         lastDirection: 'outbound',
         lastAt: new Date(0).toISOString(),
+        unread: 0,
       }
     )
   }
@@ -293,6 +356,7 @@ export function UnifiedInbox({
           ) : (
             visibleThreads.map((thread) => {
               const isInbound = thread.lastDirection === 'inbound'
+              const isUnread = thread.unread > 0
               return (
                 <button
                   key={thread.key}
@@ -314,17 +378,35 @@ export function UnifiedInbox({
                     </div>
                     <div className="min-w-0 flex-1">
                       <div className="flex items-baseline justify-between gap-2">
-                        <span className="truncate text-sm font-medium">{thread.name}</span>
+                        <span
+                          className={cn(
+                            'truncate text-sm',
+                            isUnread ? 'font-semibold' : 'font-medium'
+                          )}
+                        >
+                          {thread.name}
+                        </span>
                         <ChannelBadge channel={thread.channel} />
                       </div>
-                      <p
-                        className={cn(
-                          'mt-0.5 truncate text-xs',
-                          isInbound ? 'text-foreground' : 'text-muted-foreground'
+                      <div className="mt-0.5 flex items-center gap-2">
+                        <p
+                          className={cn(
+                            'min-w-0 flex-1 truncate text-xs',
+                            isInbound ? 'text-foreground' : 'text-muted-foreground',
+                            isUnread && 'font-medium'
+                          )}
+                        >
+                          {thread.lastMessage}
+                        </p>
+                        {/* Waiting messages get a primary dot; the count is
+                            on the sidebar, where it sums every thread. */}
+                        {isUnread && (
+                          <span
+                            aria-label={t('unread', { count: thread.unread })}
+                            className="size-2 shrink-0 rounded-full bg-primary"
+                          />
                         )}
-                      >
-                        {thread.lastMessage}
-                      </p>
+                      </div>
                     </div>
                   </div>
                 </button>
@@ -382,6 +464,11 @@ export function UnifiedInbox({
                     </Button>
                   </DropdownMenuTrigger>
                   <DropdownMenuContent align="end">
+                    <DropdownMenuItem onClick={() => markUnread(selected)}>
+                      <MailOpen className="mr-2 h-4 w-4" />
+                      {t('markUnread')}
+                    </DropdownMenuItem>
+                    <DropdownMenuSeparator />
                     <DropdownMenuItem
                       className="text-destructive"
                       onClick={() => setDeleteTarget(selected)}

+ 21 - 0
src/features/messaging/Lib/unreadCount.ts

@@ -0,0 +1,21 @@
+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.
+ *
+ * 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 }
+  const [sms, telegram, whatsapp] = await Promise.all([
+    db.smsMessage.count({ where: { organizationId, ...unread } }),
+    db.telegramMessage.count({ where: { organizationId, ...unread } }),
+    db.whatsappMessage.count({ where: { organizationId, ...unread } }),
+  ])
+  return sms + telegram + whatsapp
+}