LibreChat/packages/data-schemas/src/methods/chatProject.ts
Marco Beretta 38057713ce
fix: enforce ephemeral retention on chat project membership changes
Project membership writes bypassed forced retention: assigning a chat to a
project, removing it, or deleting the project rewrote the conversation row
(chatProjectId) without setting isTemporary/expiredAt. On a deployment that
switched to ephemeral mode, touching an older permanent chat this way left it
visible and non-expiring.

Load the interface config on the assign and delete project routes and cascade
forced retention through the touched conversation(s): assign/remove caps the
single conversation, its messages, and its shares; delete caps every member
chat before unassigning. Extract the bucketed tag cascade into a shared helper
so the project cascade reuses the same conversion/backfill/cap logic, and add a
shared resolveForcedRetentionDate helper. All paths are a no-op outside forced
retention, so non-ephemeral behavior is unchanged.
2026-07-03 15:03:09 +02:00

535 lines
16 KiB
TypeScript

import { isForcedTemporaryRetention } from 'librechat-data-provider';
import type { FilterQuery, Model, SortOrder, Types } from 'mongoose';
import type {
AppConfig,
IChatProject,
IChatProjectDocument,
IConversation,
IMessage,
ISharedLink,
} from '~/types';
import {
buildRetentionVisibilityFilter,
cascadeForcedConversationRetention,
cascadeForcedRetentionByProject,
resolveForcedRetentionDate,
} from '~/utils/retention';
import { isValidObjectIdString } from '~/utils/objectId';
import { escapeRegExp } from '~/utils/string';
import logger from '~/config/winston';
export type ChatProjectSortBy = 'name' | 'createdAt' | 'lastConversationAt';
export type ChatProjectSortDirection = 'asc' | 'desc';
export type CreateChatProjectInput = {
name: string;
description?: string | null;
};
export type UpdateChatProjectInput = Partial<CreateChatProjectInput>;
export type ListChatProjectsOptions = {
cursor?: string | null;
limit?: number;
sortBy?: ChatProjectSortBy;
sortDirection?: ChatProjectSortDirection;
search?: string;
};
export type ListChatProjectsResult = {
projects: IChatProject[];
nextCursor: string | null;
};
export type DeleteChatProjectResult = {
deletedCount: number;
modifiedCount: number;
};
export type AssignConversationToProjectResult = {
conversation: IConversation;
previousProjectId: string | null;
projectId: string | null;
};
export interface ChatProjectMethods {
createChatProject(user: string, input: CreateChatProjectInput): Promise<IChatProject>;
getChatProject(user: string, projectId: string): Promise<IChatProject | null>;
listChatProjects(
user: string,
options?: ListChatProjectsOptions,
): Promise<ListChatProjectsResult>;
updateChatProject(
user: string,
projectId: string,
input: UpdateChatProjectInput,
): Promise<IChatProject | null>;
deleteChatProject(
user: string,
projectId: string,
interfaceConfig?: AppConfig['interfaceConfig'],
): Promise<DeleteChatProjectResult>;
assignConversationToProject(
user: string,
conversationId: string,
projectId: string | null,
interfaceConfig?: AppConfig['interfaceConfig'],
): Promise<AssignConversationToProjectResult | null>;
refreshChatProjectStats(user: string, projectId: string): Promise<IChatProject | null>;
}
type ProjectCursor = {
primary: string | null;
id: string;
};
type ProjectLean = IChatProject & { _id: Types.ObjectId };
const VALID_SORT_FIELDS = new Set<ChatProjectSortBy>(['name', 'createdAt', 'lastConversationAt']);
function normalizeSortBy(sortBy?: string): ChatProjectSortBy {
return VALID_SORT_FIELDS.has(sortBy as ChatProjectSortBy)
? (sortBy as ChatProjectSortBy)
: 'lastConversationAt';
}
function normalizeSortDirection(sortDirection?: string): ChatProjectSortDirection {
return sortDirection === 'asc' ? 'asc' : 'desc';
}
function normalizeLimit(limit?: number): number {
if (!Number.isFinite(limit) || !limit) {
return 25;
}
return Math.min(Math.max(Math.floor(limit), 1), 100);
}
function sanitizeProjectInput(input: CreateChatProjectInput): CreateChatProjectInput {
return {
name: input.name.trim().slice(0, 100),
description: input.description?.trim().slice(0, 1000) ?? '',
};
}
function parseCursor(cursor?: string | null): ProjectCursor | null {
if (!cursor) {
return null;
}
try {
const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString()) as ProjectCursor;
if (!decoded.id || !isValidObjectIdString(decoded.id)) {
return null;
}
return {
primary: decoded.primary ?? null,
id: decoded.id,
};
} catch {
logger.warn('[listChatProjects] Invalid cursor format, starting from beginning');
return null;
}
}
function encodeCursor(project: ProjectLean, sortBy: ChatProjectSortBy): string {
let primary: string | null = null;
if (sortBy === 'name') {
primary = project.name;
} else {
const date = project[sortBy];
primary = date instanceof Date ? date.toISOString() : null;
}
return Buffer.from(JSON.stringify({ primary, id: project._id.toString() })).toString('base64');
}
function cursorPrimaryValue(
primary: string | null,
sortBy: ChatProjectSortBy,
): string | Date | null | undefined {
if (primary == null) {
return sortBy === 'lastConversationAt' ? null : undefined;
}
if (sortBy === 'name') {
return primary;
}
const date = new Date(primary);
return Number.isNaN(date.getTime()) ? undefined : date;
}
function createCursorFilter(
mongoose: typeof import('mongoose'),
cursor: ProjectCursor | null,
sortBy: ChatProjectSortBy,
sortDirection: ChatProjectSortDirection,
): FilterQuery<IChatProjectDocument> | null {
if (!cursor) {
return null;
}
const op = sortDirection === 'asc' ? '$gt' : '$lt';
const id = new mongoose.Types.ObjectId(cursor.id);
const primary = cursorPrimaryValue(cursor.primary, sortBy);
if (primary === undefined) {
logger.warn('[listChatProjects] Invalid cursor primary value, starting from beginning');
return null;
}
const branches: FilterQuery<IChatProjectDocument>[] = [
{ [sortBy]: { [op]: primary } },
{ [sortBy]: primary, _id: { [op]: id } },
];
/**
* Projects with no conversations have `lastConversationAt: null`, which sorts
* after all dated projects in descending order. A `$lt: <date>` predicate does
* not match null, so once the cursor moves past the dated projects we must
* include the null bucket explicitly, otherwise empty projects never paginate.
*/
if (sortBy === 'lastConversationAt' && sortDirection === 'desc' && primary instanceof Date) {
branches.push({ lastConversationAt: null } as FilterQuery<IChatProjectDocument>);
}
return { $or: branches } as FilterQuery<IChatProjectDocument>;
}
function visibleProjectConversationFilter(
user: string,
projectId: string,
): FilterQuery<IConversation> {
return {
$and: [
{ user, chatProjectId: projectId },
{ $or: [{ isArchived: false }, { isArchived: { $exists: false } }] },
buildRetentionVisibilityFilter<IConversation>(),
],
} as FilterQuery<IConversation>;
}
export async function refreshChatProjectStatsForUser(
mongoose: typeof import('mongoose'),
user: string,
projectId: string,
): Promise<IChatProject | null> {
if (!isValidObjectIdString(projectId)) {
return null;
}
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const projectFilter = { _id: new mongoose.Types.ObjectId(projectId), user };
const conversationFilter = visibleProjectConversationFilter(user, projectId);
const [conversationCount, latestConversation] = await Promise.all([
Conversation.countDocuments(conversationFilter),
Conversation.findOne(conversationFilter)
.select('conversationId updatedAt createdAt')
.sort({ updatedAt: -1, _id: -1 })
.lean<IConversation>(),
]);
return await ChatProject.findOneAndUpdate(
projectFilter,
{
$set: {
conversationCount,
lastConversationAt: latestConversation?.updatedAt ?? latestConversation?.createdAt ?? null,
lastConversationId: latestConversation?.conversationId ?? null,
},
},
{ new: true },
).lean<IChatProject>();
}
export async function updateChatProjectLastConversationForUser(
mongoose: typeof import('mongoose'),
user: string,
projectId: string,
conversation: Pick<IConversation, 'conversationId' | 'createdAt' | 'updatedAt'>,
incrementCount = false,
): Promise<void> {
if (!isValidObjectIdString(projectId) || !conversation.conversationId) {
return;
}
const lastConversationAt = conversation.updatedAt ?? conversation.createdAt ?? new Date();
const update: Record<string, unknown> = {
$set: {
lastConversationAt,
lastConversationId: conversation.conversationId,
},
};
if (incrementCount) {
update.$inc = { conversationCount: 1 };
}
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
await ChatProject.updateOne({ _id: new mongoose.Types.ObjectId(projectId), user }, update);
}
export function createChatProjectMethods(mongoose: typeof import('mongoose')): ChatProjectMethods {
/**
* Converts a project's conversations to the forced (ephemeral) window when the deployment runs
* in ephemeral mode. Assigning, removing, or bulk-unassigning a chat rewrites its row without
* setting `isTemporary`/`expiredAt`, so a permanent chat organized after the install switched
* to ephemeral would otherwise stay visible and never expire. A no-op outside forced retention.
*/
function forceProjectConversationRetention(
user: string,
chatProjectId: string,
interfaceConfig?: AppConfig['interfaceConfig'],
): Promise<void> {
if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) {
return Promise.resolve();
}
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const Message = mongoose.models.Message as Model<IMessage>;
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
return cascadeForcedRetentionByProject(
Conversation,
Message,
SharedLink,
user,
chatProjectId,
resolveForcedRetentionDate(interfaceConfig),
);
}
function forceConversationRetention(
user: string,
conversationId: string,
interfaceConfig?: AppConfig['interfaceConfig'],
): Promise<void> {
if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) {
return Promise.resolve();
}
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const Message = mongoose.models.Message as Model<IMessage>;
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
return cascadeForcedConversationRetention(
Conversation,
Message,
SharedLink,
user,
conversationId,
resolveForcedRetentionDate(interfaceConfig),
);
}
async function createChatProject(
user: string,
input: CreateChatProjectInput,
): Promise<IChatProject> {
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
const sanitized = sanitizeProjectInput(input);
if (!sanitized.name) {
throw new Error('Project name is required');
}
const project = await ChatProject.create({
...sanitized,
user,
conversationCount: 0,
lastConversationAt: null,
lastConversationId: null,
});
return project.toObject() as IChatProject;
}
async function getChatProject(user: string, projectId: string): Promise<IChatProject | null> {
if (!isValidObjectIdString(projectId)) {
return null;
}
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
return await ChatProject.findOne({
_id: new mongoose.Types.ObjectId(projectId),
user,
}).lean<IChatProject>();
}
async function listChatProjects(
user: string,
options: ListChatProjectsOptions = {},
): Promise<ListChatProjectsResult> {
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
const limit = normalizeLimit(options.limit);
const sortBy = normalizeSortBy(options.sortBy);
const sortDirection = normalizeSortDirection(options.sortDirection);
const sortOrder: SortOrder = sortDirection === 'asc' ? 1 : -1;
const filters: FilterQuery<IChatProjectDocument>[] = [{ user }];
if (options.search?.trim()) {
filters.push({ name: { $regex: escapeRegExp(options.search.trim()), $options: 'i' } });
}
const cursorFilter = createCursorFilter(
mongoose,
parseCursor(options.cursor),
sortBy,
sortDirection,
);
if (cursorFilter) {
filters.push(cursorFilter);
}
const query =
filters.length === 1 ? filters[0] : ({ $and: filters } as FilterQuery<IChatProjectDocument>);
const projects = await ChatProject.find(query)
.sort({ [sortBy]: sortOrder, _id: sortOrder })
.limit(limit + 1)
.lean<ProjectLean[]>();
let nextCursor: string | null = null;
if (projects.length > limit) {
projects.pop();
const lastProject = projects[projects.length - 1];
if (lastProject) {
nextCursor = encodeCursor(lastProject, sortBy);
}
}
return { projects, nextCursor };
}
async function updateChatProject(
user: string,
projectId: string,
input: UpdateChatProjectInput,
): Promise<IChatProject | null> {
if (!isValidObjectIdString(projectId)) {
return null;
}
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
const update: Partial<Pick<IChatProject, 'name' | 'description'>> = {};
if (typeof input.name === 'string') {
const name = input.name.trim().slice(0, 100);
if (!name) {
throw new Error('Project name is required');
}
update.name = name;
}
if (input.description !== undefined) {
update.description = input.description?.trim().slice(0, 1000) ?? '';
}
return await ChatProject.findOneAndUpdate(
{ _id: new mongoose.Types.ObjectId(projectId), user },
{ $set: update },
{ new: true, runValidators: true },
).lean<IChatProject>();
}
async function deleteChatProject(
user: string,
projectId: string,
interfaceConfig?: AppConfig['interfaceConfig'],
): Promise<DeleteChatProjectResult> {
if (!isValidObjectIdString(projectId)) {
return { deletedCount: 0, modifiedCount: 0 };
}
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const projectFilter = { _id: new mongoose.Types.ObjectId(projectId), user };
const project = await ChatProject.findOne(projectFilter).select('_id').lean<IChatProject>();
if (!project) {
return { deletedCount: 0, modifiedCount: 0 };
}
await forceProjectConversationRetention(user, projectId, interfaceConfig);
const [conversationResult, deleteResult] = await Promise.all([
Conversation.updateMany(
{ user, chatProjectId: projectId },
{ $unset: { chatProjectId: '' } },
),
ChatProject.deleteOne(projectFilter),
]);
return {
deletedCount: deleteResult.deletedCount ?? 0,
modifiedCount: conversationResult.modifiedCount ?? 0,
};
}
async function assignConversationToProject(
user: string,
conversationId: string,
projectId: string | null,
interfaceConfig?: AppConfig['interfaceConfig'],
): Promise<AssignConversationToProjectResult | null> {
const ChatProject = mongoose.models.ChatProject as Model<IChatProjectDocument>;
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const conversation = await Conversation.findOne({ user, conversationId }).lean<IConversation>();
if (!conversation) {
return null;
}
const normalizedProjectId = projectId || null;
if (normalizedProjectId) {
if (!isValidObjectIdString(normalizedProjectId)) {
throw new Error('Project not found');
}
const project = await ChatProject.findOne({
_id: new mongoose.Types.ObjectId(normalizedProjectId),
user,
})
.select('_id')
.lean<IChatProject>();
if (!project) {
throw new Error('Project not found');
}
}
const previousProjectId = conversation.chatProjectId ?? null;
const update =
normalizedProjectId == null
? { $unset: { chatProjectId: '' } }
: { $set: { chatProjectId: normalizedProjectId } };
const updatedConversation = await Conversation.findOneAndUpdate(
{ user, conversationId },
update,
{ new: true },
).lean<IConversation>();
if (!updatedConversation) {
return null;
}
await forceConversationRetention(user, conversationId, interfaceConfig);
const projectIds = new Set(
[previousProjectId, normalizedProjectId].filter((id): id is string => Boolean(id)),
);
await Promise.all(
[...projectIds].map((id) => refreshChatProjectStatsForUser(mongoose, user, id)),
);
return {
conversation: updatedConversation,
previousProjectId,
projectId: normalizedProjectId,
};
}
async function refreshChatProjectStats(
user: string,
projectId: string,
): Promise<IChatProject | null> {
return await refreshChatProjectStatsForUser(mongoose, user, projectId);
}
return {
createChatProject,
getChatProject,
listChatProjects,
updateChatProject,
deleteChatProject,
assignConversationToProject,
refreshChatProjectStats,
};
}