Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Stats endpoint now returns chat names in top chats.** The `GET /stats/messages` and `GET /sessions/:id/stats` endpoints include a `chatName` field on each top-chat entry, populated from the contact's pushName or saved name at message time. The dashboard uses it to show readable names instead of raw JIDs. Existing rows start as `NULL` until a new message sets the name. (#558) Thanks @buluma.

### Fixed

- **Sending to — or operating on — a WhatsApp Channel (newsletter) no longer logs internal errors.** On the whatsapp-web.js engine a channel JID (`…@newsletter`) resolves to a `Channel`, which has none of the per-chat operations; the gateway now skips those for channels instead of throwing. The typing indicator that precedes a send, the typing/recording presence endpoint and its MCP tool, mark-unread, and delete-chat now cleanly no-op for a channel (presence does nothing; mark-unread and delete-chat report no change) rather than emitting an internal `TypeError`. Fetching chat labels for a channel previously failed with HTTP 500 — it now returns an empty list. Direct chats, groups, and broadcast lists are unaffected. (#554) Thanks @DanielOberlechner.
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/components/DashboardCharts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export function DashboardCharts() {
const byType = Object.entries(data?.byType ?? {})
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value);
const topChats = (data?.topChats ?? []).slice(0, 8).map(c => ({ name: shortChat(c.chatId), count: c.messageCount }));
const topChats = (data?.topChats ?? []).slice(0, 8).map(c => ({ name: c.chatName || shortChat(c.chatId), count: c.messageCount }));
const hasData = timeSeries.length > 0 || byType.length > 0 || topChats.length > 0;

return (
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -817,7 +817,7 @@ export interface MessageStats {
timeSeries: MessageTimeSeriesPoint[];
byType: Record<string, number>;
bySession: Array<{ sessionId: string; name: string; sent: number; received: number }>;
topChats: Array<{ chatId: string; messageCount: number }>;
topChats: Array<{ chatId: string; chatName?: string | null; messageCount: number }>;
}

export const statsApi = {
Expand Down
28 changes: 28 additions & 0 deletions src/database/migrations/1781900000000-AddMessageChatName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

/**
* Adds `chatName` column to the `messages` table. This stores the human-readable name for the
* chat (contact pushName, group name, etc) at the time the message was received/sent, enabling
* stats endpoints to display readable names instead of raw JIDs.
*
* Hand-authored because `synchronize` is off for the `data` connection on PostgreSQL (and optional
* on SQLite via DATABASE_SYNCHRONIZE=false). Idempotent: checks for column existence first.
*/
export class AddMessageChatName1781900000000 implements MigrationInterface {
name = 'AddMessageChatName1781900000000';

public async up(queryRunner: QueryRunner): Promise<void> {
const table = await queryRunner.getTable('messages');
const col = table?.findColumnByName('chatName');
if (col) return; // already added by synchronize or a previous run

await queryRunner.query(`ALTER TABLE "messages" ADD COLUMN "chatName" varchar NULL`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
const table = await queryRunner.getTable('messages');
const col = table?.findColumnByName('chatName');
if (!col) return;
await queryRunner.query(`ALTER TABLE "messages" DROP COLUMN "chatName"`);
}
}
4 changes: 4 additions & 0 deletions src/modules/message/entities/message.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ export class Message {
@Column()
chatId: string;

/** Human-readable name for the chat (contact pushName, group name, etc). Populated on save when available — null for legacy rows. */
@Column({ nullable: true })
chatName?: string;

@Column()
from: string;

Expand Down
3 changes: 3 additions & 0 deletions src/modules/session/session.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -691,10 +691,13 @@ export class SessionService implements OnModuleDestroy, OnModuleInit, OnApplicat
metadata.call = incoming.call;
}

const chatName = incoming.contact?.pushName ?? incoming.contact?.name ?? undefined;

const dbMessage = this.messageRepository.create({
sessionId: id,
waMessageId: incoming.id,
chatId: incoming.chatId,
chatName,
from: incoming.from,
to: incoming.to,
body: incoming.body,
Expand Down
12 changes: 8 additions & 4 deletions src/modules/stats/stats.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,13 @@ export interface MessageStats {
timeSeries: TimeSeriesPoint[];
byType: Record<string, number>;
bySession: Array<{ sessionId: string; name: string; sent: number; received: number }>;
topChats: Array<{ chatId: string; messageCount: number }>;
topChats: Array<{ chatId: string; chatName: string | null; messageCount: number }>;
}

export interface SessionStats {
session: { id: string; name: string; status: string };
messages: { sent: number; received: number; today: number; failed: number };
topChats: Array<{ chatId: string; count: number; lastActive: string }>;
topChats: Array<{ chatId: string; chatName: string | null; count: number; lastActive: string }>;
hourlyActivity: Array<{ hour: number; sent: number; received: number }>;
}

Expand Down Expand Up @@ -208,20 +208,22 @@ export class StatsService {
.createQueryBuilder('m')
.select('m.chatId', 'chatId')
.addSelect('COUNT(*)', 'messageCount')
.addSelect('MAX(m.chatName)', 'chatName')
.where('m.createdAt >= :since', { since })
.groupBy('m.chatId')
// Order by the aggregate expression, not the "messageCount" alias: Postgres folds an unquoted
// ORDER BY messageCount to lowercase and 42703s against the quoted alias (SQLite tolerated it).
.orderBy('COUNT(*)', 'DESC')
.limit(10)
.getRawMany<{ chatId: string; messageCount: string }>();
.getRawMany<{ chatId: string; messageCount: string; chatName: string | null }>();

return {
timeSeries,
byType,
bySession,
topChats: topChats.map(c => ({
chatId: c.chatId,
chatName: c.chatName ?? null,
messageCount: parseInt(c.messageCount),
})),
};
Expand Down Expand Up @@ -265,11 +267,12 @@ export class StatsService {
.select('m.chatId', 'chatId')
.addSelect('COUNT(*)', 'count')
.addSelect(maxCreatedAtSql(this.dataDbType), 'lastActive')
.addSelect('MAX(m.chatName)', 'chatName')
.where('m.sessionId = :sessionId', { sessionId })
.groupBy('m.chatId')
.orderBy('count', 'DESC')
.limit(10)
.getRawMany<{ chatId: string; count: string; lastActive: string }>();
.getRawMany<{ chatId: string; count: string; lastActive: string; chatName: string | null }>();

// Hourly activity (last 24h)
const hourlyActivity = await this.getHourlyActivity(sessionId);
Expand All @@ -279,6 +282,7 @@ export class StatsService {
messages: { sent, received, today: todayCount, failed },
topChats: topChats.map(c => ({
chatId: c.chatId,
chatName: c.chatName ?? null,
count: parseInt(c.count),
lastActive: c.lastActive,
})),
Expand Down