Files
gitmost/apps/server/src/database/repos/ai-chat/ai-chat.repo.ts
claude_code 7ce1a24f82 feat(ai-chat): show creation time and origin document in chat list
Each chat row in the AI-chat history now shows a dimmed second line with
how long ago the chat was created and the document it was created in
("N ago / <document>", or "No document" when started outside a page).

Server:
- New migration: nullable ai_chats.page_id (FK pages.id, ON DELETE SET NULL).
- Capture the origin page at chat creation from the client-supplied openPage,
  but validate it first: it must be a real page in the same workspace that the
  user may read (PageAccessService.validateCanView), else null. This keeps the
  "openPage.id is attacker-controllable but harmless" invariant - preventing a
  cross-workspace/cross-space page-title leak and a post-hijack FK crash.
- findByCreator left-joins pages (scoped by workspace, defense-in-depth) and
  returns pageTitle.

Client:
- IAiChat gains pageId/pageTitle; ConversationList renders a ChatMetaLine
  (useTimeAgo + origin document) as a dimmed second line.
- Add i18n key "No document" (en-US, ru-RU).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:16:26 +03:00

124 lines
4.0 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
import {
AiChat,
InsertableAiChat,
UpdatableAiChat,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
@Injectable()
export class AiChatRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async findById(id: string, workspaceId: string): Promise<AiChat | undefined> {
return this.db
.selectFrom('aiChats')
.selectAll('aiChats')
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.executeTakeFirst();
}
async findByCreator(
creatorId: string,
workspaceId: string,
pagination: PaginationOptions,
) {
// Left-join the bound role for the badge (emoji + name). Joined, not
// denormalized — the chat list is not a hot path. A soft-deleted role
// resolves to NULL so the badge disappears, matching the stream's behavior.
// A DISABLED role (enabled=false) is likewise excluded: resolveRoleForRequest
// downgrades such a chat to the universal assistant, so the badge must not
// advertise a role that is not actually applied.
const query = this.db
.selectFrom('aiChats')
.leftJoin('aiAgentRoles', (join) =>
join
.onRef('aiAgentRoles.id', '=', 'aiChats.roleId')
.on('aiAgentRoles.deletedAt', 'is', null)
.on('aiAgentRoles.enabled', '=', true),
)
// Left-join the origin page for its title (provenance shown in the list).
// Scoped to the chat's workspace as defense-in-depth so a page id can only
// ever surface a same-workspace title. No deletedAt filter: a soft-deleted
// page keeps showing its historical title; a hard-deleted page already
// nulls aiChats.pageId via the FK.
.leftJoin('pages', (join) =>
join
.onRef('pages.id', '=', 'aiChats.pageId')
.onRef('pages.workspaceId', '=', 'aiChats.workspaceId'),
)
.selectAll('aiChats')
.select([
'aiAgentRoles.name as roleName',
'aiAgentRoles.emoji as roleEmoji',
'pages.title as pageTitle',
])
.where('aiChats.creatorId', '=', creatorId)
.where('aiChats.workspaceId', '=', workspaceId)
.where('aiChats.deletedAt', 'is', null);
return executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
// Qualify to aiChats — the join introduces an aiAgentRoles.createdAt/id
// that would otherwise make the ORDER BY / cursor comparison ambiguous.
{ expression: 'aiChats.createdAt', direction: 'desc' },
{ expression: 'aiChats.id', direction: 'desc' },
],
parseCursor: (cursor) => ({
createdAt: new Date(cursor.createdAt),
id: cursor.id,
}),
});
}
async insert(
insertable: InsertableAiChat,
trx?: KyselyTransaction,
): Promise<AiChat> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('aiChats')
.values(insertable)
.returningAll()
.executeTakeFirst();
}
async update(
id: string,
updatable: UpdatableAiChat,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('aiChats')
.set({ ...updatable, updatedAt: new Date() })
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.execute();
}
async softDelete(
id: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('aiChats')
.set({ deletedAt: new Date() })
.where('id', '=', id)
.where('workspaceId', '=', workspaceId)
.execute();
}
}