diff --git a/apps/cli/src/http/routes/web/sessions.test.ts b/apps/cli/src/http/routes/web/sessions.test.ts new file mode 100644 index 00000000..e0e16c23 --- /dev/null +++ b/apps/cli/src/http/routes/web/sessions.test.ts @@ -0,0 +1,34 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import Fastify from 'fastify' +import type { SessionRuntimeAdapter } from '@openchatlab/node-runtime' +import { registerSessionRoutes } from './sessions' + +function createMissingSessionAdapter(): SessionRuntimeAdapter { + return { + listSessionIds: () => [], + openReadonly: () => null, + openWritable: () => null, + closeSession: () => {}, + getDbPath: (sessionId) => `/tmp/${sessionId}.db`, + deleteSessionFile: () => false, + ensureReadonly: () => { + throw Object.assign(new Error('Session not found'), { statusCode: 404 }) + }, + ensureWritable: () => { + throw Object.assign(new Error('Session not found'), { statusCode: 404 }) + }, + } +} + +describe('CLI Web session routes', () => { + it('returns 404 when requesting a missing session by id', async () => { + const app = Fastify() + registerSessionRoutes(app, createMissingSessionAdapter()) + + const response = await app.inject({ method: 'GET', url: '/_web/sessions/missing' }) + await app.close() + + assert.equal(response.statusCode, 404) + }) +}) diff --git a/apps/cli/src/http/routes/web/sessions.ts b/apps/cli/src/http/routes/web/sessions.ts index b7f4340b..65d324ce 100644 --- a/apps/cli/src/http/routes/web/sessions.ts +++ b/apps/cli/src/http/routes/web/sessions.ts @@ -7,7 +7,11 @@ export function registerSessionRoutes(server: FastifyInstance, adapter: SessionR }) server.get<{ Params: { id: string } }>('/_web/sessions/:id', async (request) => { - return sessionService.getAnalysisSession(adapter, request.params.id) + const session = sessionService.getAnalysisSession(adapter, request.params.id) + if (!session) { + throw Object.assign(new Error(`Session not found: ${request.params.id}`), { statusCode: 404 }) + } + return session }) server.delete<{ Params: { id: string } }>('/_web/sessions/:id', async (request, reply) => { diff --git a/packages/core/src/query/__tests__/member-queries.test.ts b/packages/core/src/query/__tests__/member-queries.test.ts index ca808150..68d45ab7 100644 --- a/packages/core/src/query/__tests__/member-queries.test.ts +++ b/packages/core/src/query/__tests__/member-queries.test.ts @@ -11,7 +11,7 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' -import { getMembersWithAliases, getMembersPaginated } from '../message-queries' +import { getMemberNameHistory, getMembersWithAliases, getMembersPaginated } from '../message-queries' import type { DatabaseAdapter, PreparedStatement } from '../../interfaces' // ==================== Mock helpers ==================== @@ -157,6 +157,39 @@ function createMockDb(opts: { hasAliases?: boolean; hasAvatar?: boolean } = {}): } } +function createNameHistoryDb(opts: { + hasHistoryTable: boolean + historyRows?: Record[] +}): DatabaseAdapter { + return { + prepare(sql: string): PreparedStatement { + return { + get(...params: unknown[]) { + if (sql.includes('sqlite_master') && params.includes('member_name_history')) { + return { cnt: opts.hasHistoryTable ? 1 : 0 } + } + return undefined + }, + all() { + if (sql.includes('FROM member_name_history')) { + return opts.historyRows ?? [] + } + if (sql.includes('FROM message')) { + return [ + { accountName: 'Alice', groupNickname: 'A', startTs: 1000, endTs: 1500 }, + { accountName: 'Alice Chen', groupNickname: null, startTs: 2000, endTs: 2500 }, + ] + } + return [] + }, + run() { + return { changes: 0, lastInsertRowid: 0 } + }, + } + }, + } as unknown as DatabaseAdapter +} + // ==================== getMembersWithAliases ==================== describe('getMembersWithAliases', () => { @@ -198,6 +231,30 @@ describe('getMembersWithAliases', () => { }) }) +// ==================== getMemberNameHistory ==================== + +describe('getMemberNameHistory', () => { + it('derives history from message rows when member_name_history table is absent', () => { + const result = getMemberNameHistory(createNameHistoryDb({ hasHistoryTable: false }), 1) + + assert.deepEqual(result, [ + { nameType: 'account_name', name: 'Alice', startTs: 1000, endTs: 1500 }, + { nameType: 'group_nickname', name: 'A', startTs: 1000, endTs: 1500 }, + { nameType: 'account_name', name: 'Alice Chen', startTs: 2000, endTs: 2500 }, + ]) + }) + + it('derives history from message rows when member_name_history table is empty', () => { + const result = getMemberNameHistory(createNameHistoryDb({ hasHistoryTable: true, historyRows: [] }), 1) + + assert.deepEqual(result, [ + { nameType: 'account_name', name: 'Alice', startTs: 1000, endTs: 1500 }, + { nameType: 'group_nickname', name: 'A', startTs: 1000, endTs: 1500 }, + { nameType: 'account_name', name: 'Alice Chen', startTs: 2000, endTs: 2500 }, + ]) + }) +}) + // ==================== getMembersPaginated ==================== describe('getMembersPaginated', () => { diff --git a/packages/core/src/query/message-queries.ts b/packages/core/src/query/message-queries.ts index d5c59f43..d3404edc 100644 --- a/packages/core/src/query/message-queries.ts +++ b/packages/core/src/query/message-queries.ts @@ -453,6 +453,38 @@ export interface MemberNameHistoryEntry { endTs: number | null } +function getMemberNameHistoryFromMessages(db: DatabaseAdapter, memberId: number): MemberNameHistoryEntry[] { + const rows = db + .prepare( + `SELECT + sender_account_name as accountName, + sender_group_nickname as groupNickname, + MIN(ts) as startTs, + MAX(ts) as endTs + FROM message + WHERE sender_id = ? + GROUP BY sender_account_name, sender_group_nickname + ORDER BY startTs` + ) + .all(memberId) as unknown as Array<{ + accountName: string | null + groupNickname: string | null + startTs: number + endTs: number | null + }> + + const history: MemberNameHistoryEntry[] = [] + for (const row of rows) { + if (row.accountName) { + history.push({ nameType: 'account_name', name: row.accountName, startTs: row.startTs, endTs: row.endTs }) + } + if (row.groupNickname) { + history.push({ nameType: 'group_nickname', name: row.groupNickname, startTs: row.startTs, endTs: row.endTs }) + } + } + return history +} + export interface MemberWithAliases { id: number platformId: string @@ -646,14 +678,16 @@ export function getConversationBetween( * Get name change history for a member */ export function getMemberNameHistory(db: DatabaseAdapter, memberId: number): MemberNameHistoryEntry[] { - if (!hasTable(db, 'member_name_history')) return [] + if (!hasTable(db, 'member_name_history')) return getMemberNameHistoryFromMessages(db, memberId) - return db + const history = db .prepare( `SELECT name_type as nameType, name, start_ts as startTs, end_ts as endTs FROM member_name_history WHERE member_id = ? ORDER BY start_ts DESC` ) .all(memberId) as unknown as MemberNameHistoryEntry[] + + return history.length > 0 ? history : getMemberNameHistoryFromMessages(db, memberId) } /** diff --git a/packages/node-runtime/src/services/member-service.ts b/packages/node-runtime/src/services/member-service.ts index 00eb913d..5a76c162 100644 --- a/packages/node-runtime/src/services/member-service.ts +++ b/packages/node-runtime/src/services/member-service.ts @@ -96,8 +96,8 @@ export function deleteMember(adapter: SessionRuntimeAdapter, sessionId: string, } /** - * Get member name change history from the member_name_history table. - * Uses core's getMemberNameHistory (not the web.ts message-derived version). + * Get member name change history. + * Core prefers member_name_history and falls back to message-derived names for legacy sessions. */ export function getMemberNameHistory(adapter: SessionRuntimeAdapter, sessionId: string, memberId: number) { const db = adapter.ensureReadonly(sessionId)