snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

3217
packages/models/CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
import server from '@rocket.chat/jest-presets/server';
import type { Config } from 'jest';
export default {
preset: server.preset,
} satisfies Config;

View File

@@ -0,0 +1,43 @@
{
"name": "@rocket.chat/models",
"version": "2.5.1",
"private": true,
"main": "./dist/index.js",
"typings": "./dist/index.d.ts",
"files": [
"/dist"
],
"scripts": {
"build": "rm -rf dist && tsc",
"dev": "tsc --watch --preserveWatchOutput",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"test": "jest",
"testunit": "jest",
"typecheck": "tsc --noEmit",
"unit": "jest"
},
"dependencies": {
"@rocket.chat/core-typings": "workspace:^",
"@rocket.chat/model-typings": "workspace:~",
"@rocket.chat/random": "workspace:^",
"@rocket.chat/rest-typings": "workspace:^",
"@rocket.chat/sha256": "workspace:^",
"@rocket.chat/tools": "workspace:~",
"@rocket.chat/tracing": "workspace:^",
"date-fns": "~4.1.0",
"node-rsa": "^1.1.1"
},
"devDependencies": {
"@rocket.chat/jest-presets": "workspace:~",
"@rocket.chat/tsconfig": "workspace:*",
"@types/jest": "~30.0.0",
"@types/node-rsa": "^1.1.4",
"eslint": "~9.39.5",
"jest": "~30.2.0",
"typescript": "~5.9.3"
},
"volta": {
"extends": "../../package.json"
}
}

View File

@@ -0,0 +1,223 @@
import type { RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type {
DefaultFields,
DocumentWithProjection,
FindOptionsWithProjection,
FindPaginated,
IBaseModel,
InsertionModel,
DocumentWithDriverProjection,
FindOneAndUpdateOptionsWithProjection,
} from '@rocket.chat/model-typings';
import type {
BulkWriteOptions,
ChangeStream,
Collection,
DeleteOptions,
DeleteResult,
Document,
Filter,
FindCursor,
FindOptions,
InsertManyResult,
InsertOneOptions,
InsertOneResult,
UpdateFilter,
UpdateOptions,
UpdateResult,
WithId,
} from 'mongodb';
import { getCollectionName, UpdaterImpl } from '../index';
import type { Updater } from '../updater';
export class BaseDummy<
T extends { _id: string },
C extends DefaultFields<T> = undefined,
TDeleted extends RocketChatRecordDeleted<T> = RocketChatRecordDeleted<T>,
> implements IBaseModel<T, C, TDeleted> {
public readonly col: Collection<T>;
private collectionName: string;
constructor(protected name: string) {
this.collectionName = getCollectionName(name);
this.col = undefined as any;
}
public async createIndexes(): Promise<string[] | void> {
// nothing to do
}
public getUpdater(): Updater<T> {
return new UpdaterImpl<T>();
}
public updateFromUpdater(query: Filter<T>, updater: Updater<T>): Promise<UpdateResult> {
return this.updateOne(query, updater);
}
getCollectionName(): string {
return this.collectionName;
}
async findOneAndDelete(): Promise<WithId<T> | null> {
return null;
}
async findOneAndDeleteById(_id: T['_id']): Promise<WithId<T> | null> {
return null;
}
async findOneAndUpdate<
P extends Document = T,
O extends FindOneAndUpdateOptionsWithProjection = FindOneAndUpdateOptionsWithProjection,
>(): Promise<DocumentWithDriverProjection<P, O> | null> {
return null;
}
async findOneById<P extends Document = T, O extends FindOptionsWithProjection<P> = FindOptionsWithProjection<P>>(
_id: T['_id'],
_options?: O,
): Promise<DocumentWithProjection<P, O> | null> {
return null;
}
async findOne<P extends Document = T, O extends FindOptionsWithProjection<P> = FindOptionsWithProjection<P>>(
_query?: Filter<T> | T['_id'],
_options?: O,
): Promise<DocumentWithProjection<P, O> | null> {
return null;
}
find<P extends Document = T, O extends FindOptionsWithProjection<P> = FindOptionsWithProjection<P>>(
_query?: Filter<T>,
_options?: O,
): FindCursor<DocumentWithProjection<P, O>> {
return undefined as any;
}
findPaginated<P extends Document = T, O extends FindOptionsWithProjection<P> = FindOptionsWithProjection<P>>(
_query?: Filter<T>,
_options?: O,
): FindPaginated<FindCursor<DocumentWithProjection<P, O>>> {
return {
cursor: undefined as any,
totalCount: Promise.resolve(0),
};
}
async update(
filter: Filter<T>,
update: UpdateFilter<T> | Partial<T>,
options?: UpdateOptions & { multi?: true },
): Promise<UpdateResult | Document> {
return this.updateOne(filter, update, options);
}
async updateOne(_filter: Filter<T>, _update: UpdateFilter<T> | Partial<T>, _options?: UpdateOptions): Promise<UpdateResult> {
return {
acknowledged: true,
matchedCount: 0,
modifiedCount: 0,
upsertedCount: 0,
upsertedId: '' as any,
};
}
async updateMany(filter: Filter<T>, update: UpdateFilter<T> | Partial<T>, options?: UpdateOptions): Promise<Document | UpdateResult> {
return this.updateOne(filter, update, options);
}
async insertMany(_docs: InsertionModel<T>[], _options?: BulkWriteOptions): Promise<InsertManyResult<T>> {
return {
acknowledged: true,
insertedCount: 0,
insertedIds: {},
};
}
async insertOne(_doc: InsertionModel<T>, _options?: InsertOneOptions): Promise<InsertOneResult<T>> {
return {
acknowledged: true,
insertedId: '' as any,
};
}
async removeById(_id: T['_id']): Promise<DeleteResult> {
return {
acknowledged: true,
deletedCount: 0,
};
}
async removeByIds(_ids: T['_id'][]): Promise<DeleteResult> {
return {
acknowledged: true,
deletedCount: 0,
};
}
async deleteOne(filter: Filter<T>, options?: DeleteOptions & { bypassDocumentValidation?: boolean }): Promise<DeleteResult> {
return this.deleteMany(filter, options);
}
async deleteMany(_filter: Filter<T>, _options?: DeleteOptions): Promise<DeleteResult> {
return {
acknowledged: true,
deletedCount: 0,
};
}
// Trash
trashFind<P extends TDeleted>(
_query: Filter<TDeleted>,
_options?: FindOptions<P extends TDeleted ? TDeleted : P>,
): FindCursor<WithId<TDeleted>> | undefined {
return undefined;
}
trashFindOneById(_id: TDeleted['_id']): Promise<TDeleted | null>;
trashFindOneById<P extends Document>(_id: TDeleted['_id'], options: FindOptions<P extends TDeleted ? TDeleted : P>): Promise<P | null>;
async trashFindOneById<P extends TDeleted>(
_id: TDeleted['_id'],
_options?: FindOptions<P extends TDeleted ? TDeleted : P>,
): Promise<WithId<RocketChatRecordDeleted<P> | TDeleted> | null> {
return null;
}
trashFindDeletedAfter(deletedAt: Date): FindCursor<WithId<TDeleted>>;
trashFindDeletedAfter<P extends Document = TDeleted>(
_deletedAt: Date,
_query?: Filter<TDeleted>,
_options?: FindOptions<P extends TDeleted ? TDeleted : P>,
): FindCursor<WithId<TDeleted>> {
return undefined as any;
}
trashFindPaginatedDeletedAfter<P extends Document = TDeleted>(
_deletedAt: Date,
_query?: Filter<TDeleted>,
_options?: FindOptions<P extends TDeleted ? TDeleted : P>,
): FindPaginated<FindCursor<WithId<TDeleted>>> {
return {
cursor: undefined as any,
totalCount: Promise.resolve(0),
};
}
watch(_pipeline?: object[]): ChangeStream<T> {
return undefined as any;
}
async countDocuments(): Promise<number> {
return 0;
}
async estimatedDocumentCount(): Promise<number> {
return 0;
}
}

View File

@@ -0,0 +1,39 @@
import type { IReadReceipt } from '@rocket.chat/core-typings';
import type { IReadReceiptsModel } from '@rocket.chat/model-typings';
import type { FindCursor, DeleteResult } from 'mongodb';
import { BaseDummy } from './BaseDummy';
export class ReadReceiptsDummy extends BaseDummy<IReadReceipt> implements IReadReceiptsModel {
constructor() {
super('read_receipts');
}
findByMessageId(_messageId: string): FindCursor<IReadReceipt> {
return this.find({});
}
removeByUserId(_userId: string): Promise<DeleteResult> {
return this.deleteMany({});
}
removeByRoomId(_roomId: string): Promise<DeleteResult> {
return this.deleteMany({});
}
removeByRoomIds(_roomIds: string[]): Promise<DeleteResult> {
return this.deleteMany({});
}
removeByMessageId(_messageId: string): Promise<DeleteResult> {
return this.deleteMany({});
}
removeByMessageIds(_messageIds: string[]): Promise<DeleteResult> {
return this.deleteMany({});
}
findOlderThan(_date: Date): FindCursor<IReadReceipt> {
return this.find({});
}
}

View File

@@ -0,0 +1 @@
export * from './omnichannel';

View File

@@ -0,0 +1,51 @@
import { UserStatus } from '@rocket.chat/core-typings';
import type { IUser } from '@rocket.chat/core-typings';
import type { Filter } from 'mongodb';
/**
* Builds the query to find online and available agents for livechat auto-assignment.
* * According to product rules, the primary conditions for auto-assignment are:
* - User must have the 'livechat-agent' role.
* - Livechat service status (`statusLivechat`) must be 'available'.
* - User's global status must NOT be 'offline' Exceptions: Bots are exempt from this rule, and this check is skipped entirely if the `acceptChatsWithNoAgents` setting is enabled (allowing offline human agents to be assigned).
* - If the "Accept new omnichannel requests when the agent is idle" (aka `Livechat_enabled_when_agent_idle`) setting is OFF,
* then the statusConnection must NOT be 'away'.
*/
export const queryStatusAgentOnline = (
extraFilters = {},
isLivechatEnabledWhenAgentIdle?: boolean,
acceptChatsWithNoAgents?: boolean,
): Filter<IUser> => ({
statusLivechat: 'available',
roles: 'livechat-agent',
// ignore deactivated users
active: true,
...(!acceptChatsWithNoAgents && {
$or: [
{ roles: 'bot' },
{
status: {
$exists: true,
$ne: UserStatus.OFFLINE,
},
},
],
}),
...extraFilters,
...(isLivechatEnabledWhenAgentIdle === false && {
statusConnection: { $ne: 'away' },
}),
});
export const queryAvailableAgentsForSelection = (
extraFilters = {},
isLivechatEnabledWhenAgentIdle?: boolean,
acceptChatsWithNoAgents?: boolean,
): Filter<IUser> => ({
...queryStatusAgentOnline(extraFilters, isLivechatEnabledWhenAgentIdle, acceptChatsWithNoAgents),
$and: [
{
$or: [{ agentLocked: { $exists: false } }, { agentLockedAt: { $lt: new Date(Date.now() - 5000) } }],
},
],
});

View File

@@ -0,0 +1 @@
export * from './agentStatus';

View File

@@ -0,0 +1,250 @@
import type { ILivechatDepartmentAgents, ILivechatInquiryRecord, ISubscription, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type {
IAnalyticsModel,
IAvatarsModel,
IBannersDismissModel,
IBannersModel,
ICannedResponseModel,
ICredentialTokensModel,
ICustomSoundsModel,
ICustomUserStatusModel,
IEmailInboxModel,
IEmailMessageHistoryModel,
IEmojiCustomModel,
IExportOperationsModel,
IFederationKeysModel,
IInstanceStatusModel,
IIntegrationHistoryModel,
IIntegrationsModel,
IInvitesModel,
IImportDataModel,
ILivechatAgentActivityModel,
ILivechatBusinessHoursModel,
ILivechatContactsModel,
ILivechatCustomFieldModel,
ILivechatDepartmentAgentsModel,
ILivechatDepartmentModel,
ILivechatInquiryModel,
ILivechatPriorityModel,
ILivechatRoomsModel,
ILivechatTagModel,
ILivechatTriggerModel,
ILivechatUnitModel,
ILivechatUnitMonitorsModel,
ILivechatVisitorsModel,
ILoginServiceConfigurationModel,
IMessagesModel,
INotificationQueueModel,
INpsModel,
INpsVoteModel,
IOAuthAppsModel,
IOAuthAuthCodesModel,
IOAuthAccessTokensModel,
ILoginCodesModel,
IOAuthRefreshTokensModel,
IOEmbedCacheModel,
IPushTokenModel,
IPermissionsModel,
IReadReceiptsModel,
IMessageReadsModel,
IReportsModel,
IRolesModel,
IRoomsModel,
IServerEventsModel,
ISessionsModel,
ISettingsModel,
ISmarshHistoryModel,
IStatisticsModel,
ISubscriptionsModel,
ITeamMemberModel,
ITeamModel,
IUploadsModel,
IUserDataFilesModel,
IUsersSessionsModel,
IUsersModel,
IVideoConferenceModel,
IWebdavAccountsModel,
ICalendarEventModel,
IOmnichannelServiceLevelAgreementsModel,
IAppsModel,
IAppsPersistenceModel,
IAppLogsModel,
IImportsModel,
IAuditLogModel,
ICronHistoryModel,
IMigrationsModel,
IModerationReportsModel,
IWorkspaceCredentialsModel,
IMediaCallsModel,
IMediaCallNegotiationsModel,
ICallHistoryModel,
IAbacAttributesModel,
ITwoFactorChallengesModel,
ISamlUsedAssertionsModel,
} from '@rocket.chat/model-typings';
import type { Collection, Db } from 'mongodb';
import {
TeamMemberRaw,
MessagesRaw,
LivechatInquiryRaw,
LivechatDepartmentAgentsRaw,
PermissionsRaw,
LoginServiceConfigurationRaw,
InstanceStatusRaw,
IntegrationHistoryRaw,
IntegrationsRaw,
EmailInboxRaw,
LivechatRoomsRaw,
LivechatPriorityRaw,
UploadsRaw,
LivechatVisitorsRaw,
RolesRaw,
RoomsRaw,
SubscriptionsRaw,
TeamRaw,
UsersRaw,
UsersSessionsRaw,
AbacAttributesRaw,
ServerEventsRaw,
SamlUsedAssertionsRaw,
CronHistoryRaw,
} from './modelClasses';
import { proxify, registerModel } from './proxify';
const prefix = 'rocketchat_';
export function getCollectionName(name: string): string {
return `${prefix}${name}`;
}
export * from './modelClasses';
export * from './dummy/ReadReceipts';
export * from './helpers';
export { registerModel } from './proxify';
export { type Updater, UpdaterImpl } from './updater';
export { readSecondaryPreferred } from './readSecondaryPreferred';
export const Apps = proxify<IAppsModel>('IAppsModel');
export const AppsPersistence = proxify<IAppsPersistenceModel>('IAppsPersistenceModel');
export const AppLogs = proxify<IAppLogsModel>('IAppLogsModel');
export const Analytics = proxify<IAnalyticsModel>('IAnalyticsModel');
export const Avatars = proxify<IAvatarsModel>('IAvatarsModel');
export const BannersDismiss = proxify<IBannersDismissModel>('IBannersDismissModel');
export const Banners = proxify<IBannersModel>('IBannersModel');
export const CallHistory = proxify<ICallHistoryModel>('ICallHistoryModel');
export const CannedResponse = proxify<ICannedResponseModel>('ICannedResponseModel');
export const CredentialTokens = proxify<ICredentialTokensModel>('ICredentialTokensModel');
export const CustomSounds = proxify<ICustomSoundsModel>('ICustomSoundsModel');
export const CustomUserStatus = proxify<ICustomUserStatusModel>('ICustomUserStatusModel');
export const EmailInbox = proxify<IEmailInboxModel>('IEmailInboxModel');
export const EmailMessageHistory = proxify<IEmailMessageHistoryModel>('IEmailMessageHistoryModel');
export const EmojiCustom = proxify<IEmojiCustomModel>('IEmojiCustomModel');
export const ExportOperations = proxify<IExportOperationsModel>('IExportOperationsModel');
export const FederationKeys = proxify<IFederationKeysModel>('IFederationKeysModel');
export const ImportData = proxify<IImportDataModel>('IImportDataModel');
export const Imports = proxify<IImportsModel>('IImportsModel');
export const InstanceStatus = proxify<IInstanceStatusModel>('IInstanceStatusModel');
export const IntegrationHistory = proxify<IIntegrationHistoryModel>('IIntegrationHistoryModel');
export const Integrations = proxify<IIntegrationsModel>('IIntegrationsModel');
export const Invites = proxify<IInvitesModel>('IInvitesModel');
export const LivechatAgentActivity = proxify<ILivechatAgentActivityModel>('ILivechatAgentActivityModel');
export const LivechatBusinessHours = proxify<ILivechatBusinessHoursModel>('ILivechatBusinessHoursModel');
export const LivechatContacts = proxify<ILivechatContactsModel>('ILivechatContactsModel');
export const LivechatCustomField = proxify<ILivechatCustomFieldModel>('ILivechatCustomFieldModel');
export const LivechatDepartmentAgents = proxify<ILivechatDepartmentAgentsModel>('ILivechatDepartmentAgentsModel');
export const LivechatDepartment = proxify<ILivechatDepartmentModel>('ILivechatDepartmentModel');
export const LivechatInquiry = proxify<ILivechatInquiryModel>('ILivechatInquiryModel');
export const LivechatPriority = proxify<ILivechatPriorityModel>('ILivechatPriorityModel');
export const LivechatRooms = proxify<ILivechatRoomsModel>('ILivechatRoomsModel');
export const LivechatTag = proxify<ILivechatTagModel>('ILivechatTagModel');
export const LivechatTrigger = proxify<ILivechatTriggerModel>('ILivechatTriggerModel');
export const LivechatVisitors = proxify<ILivechatVisitorsModel>('ILivechatVisitorsModel');
export const LivechatUnit = proxify<ILivechatUnitModel>('ILivechatUnitModel');
export const LivechatUnitMonitors = proxify<ILivechatUnitMonitorsModel>('ILivechatUnitMonitorsModel');
export const LoginServiceConfiguration = proxify<ILoginServiceConfigurationModel>('ILoginServiceConfigurationModel');
export const Messages = proxify<IMessagesModel>('IMessagesModel');
export const MediaCalls = proxify<IMediaCallsModel>('IMediaCallsModel');
export const MediaCallNegotiations = proxify<IMediaCallNegotiationsModel>('IMediaCallNegotiationsModel');
export const NotificationQueue = proxify<INotificationQueueModel>('INotificationQueueModel');
export const Nps = proxify<INpsModel>('INpsModel');
export const NpsVote = proxify<INpsVoteModel>('INpsVoteModel');
export const OAuthApps = proxify<IOAuthAppsModel>('IOAuthAppsModel');
export const OAuthAuthCodes = proxify<IOAuthAuthCodesModel>('IOAuthAuthCodesModel');
export const OAuthAccessTokens = proxify<IOAuthAccessTokensModel>('IOAuthAccessTokensModel');
export const LoginCodes = proxify<ILoginCodesModel>('ILoginCodesModel');
export const OAuthRefreshTokens = proxify<IOAuthRefreshTokensModel>('IOAuthRefreshTokensModel');
export const OEmbedCache = proxify<IOEmbedCacheModel>('IOEmbedCacheModel');
export const PushToken = proxify<IPushTokenModel>('IPushTokenModel');
export const Permissions = proxify<IPermissionsModel>('IPermissionsModel');
export const ReadReceipts = proxify<IReadReceiptsModel>('IReadReceiptsModel');
export const ReadReceiptsArchive = proxify<IReadReceiptsModel>('IReadReceiptsArchiveModel');
export const MessageReads = proxify<IMessageReadsModel>('IMessageReadsModel');
export const Reports = proxify<IReportsModel>('IReportsModel');
export const Roles = proxify<IRolesModel>('IRolesModel');
export const Rooms = proxify<IRoomsModel>('IRoomsModel');
export const ServerEvents = proxify<IServerEventsModel>('IServerEventsModel');
export const Sessions = proxify<ISessionsModel>('ISessionsModel');
export const Settings = proxify<ISettingsModel>('ISettingsModel');
export const SmarshHistory = proxify<ISmarshHistoryModel>('ISmarshHistoryModel');
export const Statistics = proxify<IStatisticsModel>('IStatisticsModel');
export const Subscriptions = proxify<ISubscriptionsModel>('ISubscriptionsModel');
export const TeamMember = proxify<ITeamMemberModel>('ITeamMemberModel');
export const Team = proxify<ITeamModel>('ITeamModel');
export const Users = proxify<IUsersModel>('IUsersModel');
export const Uploads = proxify<IUploadsModel>('IUploadsModel');
export const UserDataFiles = proxify<IUserDataFilesModel>('IUserDataFilesModel');
export const UsersSessions = proxify<IUsersSessionsModel>('IUsersSessionsModel');
export const VideoConference = proxify<IVideoConferenceModel>('IVideoConferenceModel');
export const WebdavAccounts = proxify<IWebdavAccountsModel>('IWebdavAccountsModel');
export const CalendarEvent = proxify<ICalendarEventModel>('ICalendarEventModel');
export const OmnichannelServiceLevelAgreements = proxify<IOmnichannelServiceLevelAgreementsModel>(
'IOmnichannelServiceLevelAgreementsModel',
);
export const AuditLog = proxify<IAuditLogModel>('IAuditLogModel');
export const CronHistory = proxify<ICronHistoryModel>('ICronHistoryModel');
export const Migrations = proxify<IMigrationsModel>('IMigrationsModel');
export const ModerationReports = proxify<IModerationReportsModel>('IModerationReportsModel');
export const WorkspaceCredentials = proxify<IWorkspaceCredentialsModel>('IWorkspaceCredentialsModel');
export const AbacAttributes = proxify<IAbacAttributesModel>('IAbacAttributesModel');
export const TwoFactorChallenges = proxify<ITwoFactorChallengesModel>('ITwoFactorChallengesModel');
export const SamlUsedAssertions = proxify<ISamlUsedAssertionsModel>('ISamlUsedAssertionsModel');
export function registerServiceModels(db: Db, trash?: Collection<RocketChatRecordDeleted<any>>): void {
registerModel('IUsersSessionsModel', () => new UsersSessionsRaw(db));
registerModel('IUsersModel', () => new UsersRaw(db));
registerModel('IRolesModel', () => new RolesRaw(db));
registerModel('IRoomsModel', () => new RoomsRaw(db));
registerModel('ISubscriptionsModel', () => new SubscriptionsRaw(db, trash as Collection<RocketChatRecordDeleted<ISubscription>>));
registerModel('ITeamModel', () => new TeamRaw(db));
registerModel('ITeamMemberModel', () => new TeamMemberRaw(db));
registerModel('IMessagesModel', () => new MessagesRaw(db));
registerModel(
'ILivechatInquiryModel',
() => new LivechatInquiryRaw(db, trash as Collection<RocketChatRecordDeleted<ILivechatInquiryRecord>>),
);
registerModel(
'ILivechatDepartmentAgentsModel',
() => new LivechatDepartmentAgentsRaw(db, trash as Collection<RocketChatRecordDeleted<ILivechatDepartmentAgents>>),
);
registerModel('IPermissionsModel', () => new PermissionsRaw(db));
registerModel('ILoginServiceConfigurationModel', () => new LoginServiceConfigurationRaw(db));
registerModel('IInstanceStatusModel', () => new InstanceStatusRaw(db));
registerModel('IIntegrationHistoryModel', () => new IntegrationHistoryRaw(db));
registerModel('IIntegrationsModel', () => new IntegrationsRaw(db));
registerModel('IEmailInboxModel', () => new EmailInboxRaw(db));
registerModel('ILivechatPriorityModel', new LivechatPriorityRaw(db));
registerModel('ILivechatRoomsModel', () => new LivechatRoomsRaw(db));
registerModel('IUploadsModel', () => new UploadsRaw(db));
registerModel('ILivechatVisitorsModel', () => new LivechatVisitorsRaw(db));
registerModel('IAbacAttributesModel', () => new AbacAttributesRaw(db));
registerModel('IServerEventsModel', () => new ServerEventsRaw(db));
registerModel('ISamlUsedAssertionsModel', () => new SamlUsedAssertionsRaw(db));
registerModel('ICronHistoryModel', () => new CronHistoryRaw(db));
}

View File

@@ -0,0 +1,76 @@
export * from './models/BaseRaw';
export * from './models/AbacAttributes';
export * from './models/Analytics';
export * from './models/Apps';
export * from './models/AppsPersistence';
export * from './models/AppLogsModel';
export * from './models/Avatars';
export * from './models/Banners';
export * from './models/BannersDismiss';
export * from './models/CalendarEvent';
export * from './models/CustomSounds';
export * from './models/CustomUserStatus';
export * from './models/EmailInbox';
export * from './models/EmailMessageHistory';
export * from './models/EmojiCustom';
export * from './models/ExportOperations';
export * from './models/FederationKeys';
export * from './models/ImportData';
export * from './models/Imports';
export * from './models/InstanceStatus';
export * from './models/IntegrationHistory';
export * from './models/Integrations';
export * from './models/Invites';
export * from './models/LivechatAgentActivity';
export * from './models/LivechatBusinessHours';
export * from './models/LivechatContacts';
export * from './models/LivechatCustomField';
export * from './models/LivechatDepartment';
export * from './models/LivechatDepartmentAgents';
export * from './models/LivechatInquiry';
export * from './models/LivechatPriority';
export * from './models/LivechatRooms';
export * from './models/LivechatTrigger';
export * from './models/LivechatVisitors';
export * from './models/LoginServiceConfiguration';
export * from './models/Messages';
export * from './models/NotificationQueue';
export * from './models/Nps';
export * from './models/NpsVote';
export * from './models/OAuthAccessTokens';
export * from './models/OAuthApps';
export * from './models/OAuthAuthCodes';
export * from './models/LoginCodes';
export * from './models/OAuthRefreshTokens';
export * from './models/OEmbedCache';
export * from './models/Permissions';
export * from './models/PushToken';
export * from './models/Reports';
export * from './models/Roles';
export * from './models/Rooms';
export * from './models/SamlUsedAssertions';
export * from './models/ServerEvents';
export * from './models/Sessions';
export * from './models/Settings';
export * from './models/SmarshHistory';
export * from './models/Statistics';
export * from './models/Subscriptions';
export * from './models/Team';
export * from './models/TeamMember';
export * from './models/Uploads';
export * from './models/UserDataFiles';
export * from './models/Users';
export * from './models/UsersSessions';
export * from './models/VideoConference';
export * from './models/WebdavAccounts';
export * from './models/CredentialTokens';
export * from './models/MessageReads';
export * from './models/CronHistoryModel';
export * from './models/Migrations';
export * from './models/ModerationReports';
export * from './models/MediaCalls';
export * from './models/MediaCallNegotiations';
export * from './models/WorkspaceCredentials';
export * from './models/Trash';
export * from './models/CallHistory';
export * from './models/TwoFactorChallenges';

View File

@@ -0,0 +1,39 @@
import type { IAbacAttribute } from '@rocket.chat/core-typings';
import type { Db, FindOptions, IndexDescription } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class AbacAttributesRaw extends BaseRaw<IAbacAttribute> {
constructor(db: Db) {
super(db, 'abac_attributes');
}
protected override modelIndexes(): IndexDescription[] {
return [{ key: { key: 1 }, unique: true }, { key: { values: 1 } }];
}
findOneByKey(key: string, options: FindOptions<IAbacAttribute> = {}): Promise<IAbacAttribute | null> {
return this.findOne({ key }, options);
}
async countTotalValues(): Promise<number> {
const [result] = await this.col
.aggregate<{ totalValues: number }>([
{
$group: {
_id: null,
totalValues: {
$sum: {
$size: {
$ifNull: ['$values', []],
},
},
},
},
},
])
.toArray();
return result?.totalValues ?? 0;
}
}

View File

@@ -0,0 +1,331 @@
import type { IAnalytics, IRoom } from '@rocket.chat/core-typings';
import type { IAnalyticsModel, IChannelsWithNumberOfMessagesBetweenDate } from '@rocket.chat/model-typings';
import { Random } from '@rocket.chat/random';
import type { AggregationCursor, Db, IndexDescription, FindOptions, UpdateResult, Document, Collection } from 'mongodb';
import { BaseRaw } from './BaseRaw';
import { readSecondaryPreferred } from '../readSecondaryPreferred';
export class AnalyticsRaw extends BaseRaw<IAnalytics> implements IAnalyticsModel {
constructor(db: Db) {
super(db, 'analytics', undefined, {
collection: { readPreference: readSecondaryPreferred(db) },
});
}
protected override modelIndexes(): IndexDescription[] {
return [
{ key: { date: 1 } },
{ key: { 'room._id': 1, 'date': 1 }, unique: true, partialFilterExpression: { type: 'rooms' } },
{ key: { 'room.t': 1, 'date': 1 }, partialFilterExpression: { type: 'messages' } },
];
}
saveMessageSent({ room, date }: { room: IRoom; date: IAnalytics['date'] }): Promise<Document | UpdateResult> {
return this.updateMany(
{ date, 'room._id': room._id, 'type': 'messages' },
{
$set: {
room: {
_id: room._id,
name: room.fname || room.name,
t: room.t,
usernames: room.usernames || [],
},
},
$setOnInsert: {
_id: Random.id(),
date,
type: 'messages' as const,
},
$inc: { messages: 1 },
},
{ upsert: true },
);
}
saveUserData({ date }: { date: IAnalytics['date'] }): Promise<Document | UpdateResult> {
return this.updateMany(
{ date, type: 'users' },
{
$setOnInsert: {
_id: Random.id(),
date,
type: 'users' as const,
},
$inc: { users: 1 },
},
{ upsert: true },
);
}
saveMessageDeleted({ room, date }: { room: { _id: string }; date: IAnalytics['date'] }): Promise<Document | UpdateResult> {
return this.updateMany(
{ date, 'room._id': room._id },
{
$inc: { messages: -1 },
},
);
}
getMessagesSentTotalByDate({
start,
end,
options = {},
}: {
start: IAnalytics['date'];
end: IAnalytics['date'];
options?: { sort?: FindOptions<IAnalytics>['sort']; count?: number };
}): AggregationCursor<{
_id: IAnalytics['date'];
messages: number;
}> {
return this.col.aggregate<{
_id: IAnalytics['date'];
messages: number;
}>(
[
{
$match: {
type: 'messages',
date: { $gte: start, $lte: end },
},
},
{
$group: {
_id: '$date',
messages: { $sum: '$messages' },
},
},
...(options.sort ? [{ $sort: options.sort }] : []),
...(options.count ? [{ $limit: options.count }] : []),
],
{ readPreference: readSecondaryPreferred() },
);
}
getMessagesOrigin({ start, end }: { start: IAnalytics['date']; end: IAnalytics['date'] }): AggregationCursor<{
t: IRoom['t'];
messages: number;
}> {
const params = [
{
$match: {
type: 'messages',
date: { $gte: start, $lte: end },
},
},
{
$group: {
_id: { t: '$room.t' },
messages: { $sum: '$messages' },
},
},
{
$project: {
_id: 0,
t: '$_id.t',
messages: 1,
},
},
];
return this.col.aggregate(params, { readPreference: readSecondaryPreferred() });
}
getMostPopularChannelsByMessagesSentQuantity({
start,
end,
options = {},
}: {
start: IAnalytics['date'];
end: IAnalytics['date'];
options?: { sort?: FindOptions<IAnalytics>['sort']; count?: number };
}): AggregationCursor<{
t: IRoom['t'];
name: string;
messages: number;
usernames: string[];
}> {
return this.col.aggregate(
[
{
$match: {
type: 'messages',
date: { $gte: start, $lte: end },
},
},
{
$group: {
_id: { t: '$room.t', name: '$room.name', usernames: '$room.usernames' },
messages: { $sum: '$messages' },
},
},
{
$project: {
_id: 0,
t: '$_id.t',
name: '$_id.name',
usernames: '$_id.usernames',
messages: 1,
},
},
...(options.sort ? [{ $sort: options.sort }] : []),
...(options.count ? [{ $limit: options.count }] : []),
],
{ readPreference: readSecondaryPreferred() },
);
}
getTotalOfRegisteredUsersByDate({
start,
end,
options = {},
}: {
start: IAnalytics['date'];
end: IAnalytics['date'];
options?: { sort?: FindOptions<IAnalytics>['sort']; count?: number };
}): AggregationCursor<{
_id: IAnalytics['date'];
users: number;
}> {
return this.col.aggregate<{
_id: IAnalytics['date'];
users: number;
}>(
[
{
$match: {
type: 'users',
date: { $gte: start, $lte: end },
},
},
{
$group: {
_id: '$date',
users: { $sum: '$users' },
},
},
...(options.sort ? [{ $sort: options.sort }] : []),
...(options.count ? [{ $limit: options.count }] : []),
],
{ readPreference: readSecondaryPreferred() },
);
}
findOneByTypeBeforeDate({ type, date }: { type: IAnalytics['type']; date: IAnalytics['date'] }): Promise<IAnalytics | null> {
return this.findOne({ type, date: { $lte: date } });
}
getRoomsWithNumberOfMessagesBetweenDateQuery({
types,
start,
end,
startOfLastWeek,
endOfLastWeek,
options,
}: {
types: Array<IRoom['t']>;
start: number;
end: number;
startOfLastWeek: number;
endOfLastWeek: number;
options?: any;
}) {
const typeAndDateMatch = {
$match: {
'type': 'messages',
'room.t': { $in: types },
'date': { $gte: startOfLastWeek, $lte: end },
},
};
const roomsGroup = {
$group: {
_id: '$room._id',
room: { $first: '$room' },
messages: { $sum: { $cond: [{ $gte: ['$date', start] }, '$messages', 0] } },
lastWeekMessages: { $sum: { $cond: [{ $lte: ['$date', endOfLastWeek] }, '$messages', 0] } },
},
};
const lookup = {
$lookup: {
from: 'rocketchat_room',
localField: '_id',
foreignField: '_id',
as: 'room',
},
};
const roomsUnwind = {
$unwind: {
path: '$room',
preserveNullAndEmptyArrays: false,
},
};
const project = {
$project: {
_id: 0,
room: {
_id: '$room._id',
name: { $ifNull: ['$room.name', '$room.fname'] },
ts: '$room.ts',
t: '$room.t',
_updatedAt: '$room._updatedAt',
usernames: '$room.usernames',
},
messages: '$messages',
lastWeekMessages: '$lastWeekMessages',
diffFromLastWeek: { $subtract: ['$messages', '$lastWeekMessages'] },
},
};
const sort = { $sort: options?.sort || { messages: -1 } };
const sortAndPaginationParams: Exclude<Parameters<Collection<IRoom>['aggregate']>[0], undefined> = [sort];
if (options?.offset) {
sortAndPaginationParams.push({ $skip: options.offset });
}
if (options?.count) {
sortAndPaginationParams.push({ $limit: options.count });
}
const facet = {
$facet: {
channels: [...sortAndPaginationParams],
total: [{ $count: 'total' }],
},
};
const totalUnwind = { $unwind: '$total' };
const totalProject = {
$project: {
channels: '$channels',
total: '$total.total',
},
};
const params: Exclude<Parameters<Collection<IRoom>['aggregate']>[0], undefined> = [
typeAndDateMatch,
roomsGroup,
lookup,
roomsUnwind,
project,
facet,
totalUnwind,
totalProject,
];
return params;
}
findRoomsByTypesWithNumberOfMessagesBetweenDate(params: {
types: Array<IRoom['t']>;
start: number;
end: number;
startOfLastWeek: number;
endOfLastWeek: number;
options?: any;
}): AggregationCursor<{ channels: IChannelsWithNumberOfMessagesBetweenDate[]; total: number }> {
const aggregationParams = this.getRoomsWithNumberOfMessagesBetweenDateQuery(params);
return this.col.aggregate<{ channels: IChannelsWithNumberOfMessagesBetweenDate[]; total: number }>(aggregationParams, {
allowDiskUse: true,
readPreference: readSecondaryPreferred(),
});
}
}

View File

@@ -0,0 +1,58 @@
import type { IAppLogsModel } from '@rocket.chat/model-typings';
import type { Db, IndexDescription } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class AppsLogsModel extends BaseRaw<any> implements IAppLogsModel {
constructor(db: Db) {
super(db, 'apps_logs', undefined);
}
protected override modelIndexes(): IndexDescription[] {
return [
// This index is used to expire logs after 30 days
{
key: {
_updatedAt: 1,
},
expireAfterSeconds: 60 * 60 * 24 * 30,
},
// Index for specific queries from the logs screen (most common)
{
key: {
'appId': 1,
'_updatedAt': -1,
'entries.severity': 1,
'instanceId': 1,
'method': 1,
},
name: 'appId_indexed_query_v2',
},
// Index for queries on general logs endpoint
{
key: {
'_updatedAt': -1,
'entries.severity': 1,
'instanceId': 1,
'method': 1,
},
name: 'general_logs_index_v2',
},
];
}
async getDistinctFieldsForFilters(appId: string): Promise<{ instanceIds: string[]; methods: string[] }> {
/**
* The following aggregation uses the 'appId_indexed_query_v2' index to retrieve the distinct values
* The execution is fully covered by the index, meaning the database should not need to scan any documents.
*/
const [{ instanceIds, methods }] = await this.col
.aggregate([
{ $match: { appId } },
{ $group: { _id: null, instanceIds: { $addToSet: '$instanceId' }, methods: { $addToSet: '$method' } } },
])
.toArray();
return { instanceIds, methods };
}
}

View File

@@ -0,0 +1,10 @@
import type { IAppsModel } from '@rocket.chat/model-typings';
import type { Db } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class AppsModel extends BaseRaw<any> implements IAppsModel {
constructor(db: Db) {
super(db, 'apps');
}
}

View File

@@ -0,0 +1,26 @@
import type { IAppsPersistenceModel } from '@rocket.chat/model-typings';
import type { Db, DeleteResult, Filter, IndexDescription } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class AppsPersistenceModel extends BaseRaw<any> implements IAppsPersistenceModel {
constructor(db: Db) {
super(db, 'apps_persistence');
}
protected override modelIndexes(): IndexDescription[] {
return [
{
key: {
appId: 1,
associations: 1,
},
},
];
}
// Bypass trash collection
remove(query: Filter<any>): Promise<DeleteResult> {
return this.col.deleteMany(query);
}
}

View File

@@ -0,0 +1,27 @@
import type { IAvatar, RocketChatRecordDeleted, IUser } from '@rocket.chat/core-typings';
import type { IAvatarsModel } from '@rocket.chat/model-typings';
import type { Collection, Db, IndexDescription, FindOptions } from 'mongodb';
import { BaseUploadModelRaw } from './BaseUploadModel';
export class AvatarsRaw extends BaseUploadModelRaw implements IAvatarsModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IAvatar>>) {
super(db, 'avatars', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [
...super.modelIndexes(),
{ key: { userId: 1 }, sparse: true },
{ key: { etag: 1 }, sparse: true }, // avatars are queried by etag (specially for federation)
];
}
findOneByUserId(userId: IUser['_id'], options?: FindOptions<IAvatar>) {
return this.findOne({ userId }, options);
}
findOneByETag(etag: string, options?: FindOptions<IAvatar>): Promise<IAvatar | null> {
return this.findOne({ etag }, options);
}
}

View File

@@ -0,0 +1,83 @@
import type { IBanner, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import { BannerPlatform } from '@rocket.chat/core-typings';
import type { IBannersModel, DocumentWithProjection, FindOptionsWithProjection } from '@rocket.chat/model-typings';
import type { Collection, FindCursor, Db, IndexDescription, InsertOneResult, UpdateResult, Document } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class BannersRaw extends BaseRaw<IBanner> implements IBannersModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IBanner>>) {
super(db, 'banner', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [{ key: { platform: 1, startAt: 1, expireAt: 1 } }, { key: { platform: 1, startAt: 1, expireAt: 1, active: 1 } }];
}
create(doc: IBanner): Promise<InsertOneResult<IBanner>> {
const invalidPlatform = doc.platform?.some((platform) => !Object.values(BannerPlatform).includes(platform));
if (invalidPlatform) {
throw new Error('Invalid platform');
}
if (doc.startAt > doc.expireAt) {
throw new Error('Start date cannot be later than expire date');
}
if (doc.expireAt < new Date()) {
throw new Error('Cannot create banner already expired');
}
return this.insertOne({
active: true,
...doc,
});
}
findActiveByRoleOrId<T extends Document = IBanner, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
roles: string[],
platform: BannerPlatform,
bannerId?: string,
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
const today = new Date();
const query = {
...(bannerId && { _id: bannerId }),
platform,
startAt: { $lte: today },
expireAt: { $gte: today },
active: { $ne: false },
$or: [{ roles: { $in: roles } }, { roles: { $exists: false } }],
};
return this.find<T, O>(query, options);
}
disable(bannerId: string): Promise<UpdateResult> {
return this.updateOne({ _id: bannerId, active: { $ne: false } }, { $set: { active: false, inactivedAt: new Date() } });
}
createOrUpdate(banner: IBanner): Promise<UpdateResult> {
const invalidPlatform = banner.platform?.some((platform) => !Object.values(BannerPlatform).includes(platform));
if (invalidPlatform) {
throw new Error('Invalid platform');
}
if (banner.startAt > banner.expireAt) {
throw new Error('Start date cannot be later than expire date');
}
if (banner.expireAt < new Date()) {
throw new Error('Cannot create banner already expired');
}
const { _id: bannerId, ...doc } = banner;
return this.updateOne({ _id: bannerId }, { $set: { active: true, ...doc } }, { upsert: true });
}
findByIds(bannerIds: string[]): FindCursor<IBanner> {
return this.find({ _id: { $in: bannerIds } });
}
}

View File

@@ -0,0 +1,38 @@
import type { IBannerDismiss, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IBannersDismissModel } from '@rocket.chat/model-typings';
import type { Collection, FindCursor, Db, IndexDescription, Document, FindOptions } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class BannersDismissRaw extends BaseRaw<IBannerDismiss> implements IBannersDismissModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IBannerDismiss>>) {
super(db, 'banner_dismiss', trash);
}
override modelIndexes(): IndexDescription[] {
return [{ key: { userId: 1, bannerId: 1 } }];
}
findByUserIdAndBannerId(userId: string, bannerIds: string[]): FindCursor<IBannerDismiss>;
findByUserIdAndBannerId(userId: string, bannerIds: string[], options: FindOptions<IBannerDismiss>): FindCursor<IBannerDismiss>;
findByUserIdAndBannerId<P extends Document>(
userId: string,
bannerIds: string[],
options: FindOptions<P extends IBannerDismiss ? IBannerDismiss : P>,
): FindCursor<P>;
findByUserIdAndBannerId<P extends Document>(
userId: string,
bannerIds: string[],
options?: FindOptions<IBannerDismiss> | FindOptions<P extends IBannerDismiss ? IBannerDismiss : P>,
): FindCursor<P> | FindCursor<IBannerDismiss> {
const query = {
userId,
bannerId: { $in: bannerIds },
};
return options ? this.col.find(query, options) : this.col.find(query);
}
}

View File

@@ -0,0 +1,52 @@
import type { Collection, Db, FindOptions } from 'mongodb';
import { BaseRaw } from './BaseRaw';
// `BaseRaw` imports `..`, whose barrel pulls in every model and cycles back here.
jest.mock('..', () => ({
getCollectionName: (name: string) => name,
UpdaterImpl: class {},
}));
const find = jest.fn();
class TestModel extends BaseRaw<{ _id: string; name: string; password: string }> {
constructor() {
super({ collection: () => ({ find }) } as unknown as Db, 'test');
}
}
const projectionSentToDriver = (projection: Record<string, unknown>): unknown => {
new TestModel().find({}, { projection } as FindOptions<{ _id: string; name: string; password: string }>);
return find.mock.calls.at(-1)?.[1]?.projection;
};
describe('doNotMixInclusionAndExclusionFields', () => {
beforeEach(() => find.mockReset().mockReturnValue({} as unknown as Collection<any>));
it('should keep an inclusion-only projection untouched', () => {
expect(projectionSentToDriver({ name: 1 })).toEqual({ name: 1 });
expect(projectionSentToDriver({ name: true })).toEqual({ name: true });
});
it('should keep an exclusion-only projection untouched, whichever notation is used', () => {
expect(projectionSentToDriver({ password: 0 })).toEqual({ password: 0 });
expect(projectionSentToDriver({ password: false })).toEqual({ password: false });
expect(projectionSentToDriver({ password: 0, name: false })).toEqual({ password: 0, name: false });
});
it('should drop the exclusions from a mixed projection, whichever notation is used', () => {
expect(projectionSentToDriver({ name: 1, password: 0 })).toEqual({ name: 1 });
expect(projectionSentToDriver({ name: 1, password: false })).toEqual({ name: 1 });
expect(projectionSentToDriver({ name: true, password: false })).toEqual({ name: true });
expect(projectionSentToDriver({ name: 1, _id: false })).toEqual({ name: 1 });
});
it('should not mutate the projection owned by the caller', () => {
const options = { projection: { name: 1, password: false } } as unknown as FindOptions<{ _id: string; name: string; password: string }>;
new TestModel().find({}, options);
expect(options.projection).toEqual({ name: 1, password: false });
});
});

View File

@@ -0,0 +1,561 @@
import type { RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type {
IBaseModel,
DefaultFields,
ResultFields,
FindPaginated,
InsertionModel,
DocumentWithProjection,
FindOptionsWithProjection,
DocumentWithDriverProjection,
FindOneAndUpdateOptionsWithProjection,
} from '@rocket.chat/model-typings';
import { traceInstanceMethods } from '@rocket.chat/tracing';
import { ObjectId } from 'mongodb';
import type {
BulkWriteOptions,
ChangeStream,
Collection,
CollectionOptions,
Db,
Filter,
FindOneAndUpdateOptions,
IndexDescription,
InsertOneOptions,
OptionalUnlessRequiredId,
UpdateFilter,
WithId,
UpdateOptions,
Document,
FindOptions,
FindCursor,
UpdateResult,
InsertManyResult,
InsertOneResult,
DeleteResult,
DeleteOptions,
FindOneAndDeleteOptions,
CountDocumentsOptions,
ClientSession,
} from 'mongodb';
import { getCollectionName, UpdaterImpl } from '..';
import type { Updater } from '../updater';
import { setUpdatedAt } from './setUpdatedAt';
const warnFields =
process.env.NODE_ENV !== 'production' || process.env.SHOW_WARNINGS === 'true'
? (...rest: any): void => {
console.warn(...rest, new Error().stack);
}
: new Function();
type ModelOptions = {
preventSetUpdatedAt?: boolean;
collectionNameResolver?: (name: string) => string;
collection?: CollectionOptions;
};
export abstract class BaseRaw<
T extends { _id: string },
C extends DefaultFields<T> = undefined,
TDeleted extends RocketChatRecordDeleted<T> = RocketChatRecordDeleted<T>,
> implements IBaseModel<T, C, TDeleted> {
protected defaultFields: C | undefined;
public readonly col: Collection<T>;
private preventSetUpdatedAt: boolean;
/**
* Collection name to store data.
*/
private collectionName: string;
/**
* @param db MongoDB instance
* @param name Name of the model without any prefix. Used by trash records to set the `__collection__` field.
* @param trash Trash collection instance
* @param options Model options
*/
constructor(
private db: Db,
protected name: string,
protected trash?: Collection<TDeleted>,
options?: ModelOptions,
) {
this.collectionName = options?.collectionNameResolver ? options.collectionNameResolver(name) : getCollectionName(name);
this.col = this.db.collection(this.collectionName, options?.collection || {});
void this.createIndexes();
this.preventSetUpdatedAt = options?.preventSetUpdatedAt ?? false;
return traceInstanceMethods(this);
}
private pendingIndexes: Promise<void> | undefined;
public async createIndexes() {
const indexes = this.modelIndexes();
if (indexes?.length) {
if (this.pendingIndexes) {
await this.pendingIndexes;
}
this.pendingIndexes = this.col.createIndexes(indexes).catch((e) => {
console.warn(`Some indexes for collection '${this.collectionName}' could not be created:\n\t${e.message}`);
}) as unknown as Promise<void>;
void this.pendingIndexes.finally(() => {
this.pendingIndexes = undefined;
});
return this.pendingIndexes;
}
}
protected modelIndexes(): IndexDescription[] | undefined {
return undefined;
}
getCollectionName(): string {
return this.collectionName;
}
public getUpdater(): Updater<T> {
return new UpdaterImpl<T>();
}
public updateFromUpdater(query: Filter<T>, updater: Updater<T>, options: UpdateOptions = {}): Promise<UpdateResult> {
const updateFilter = updater.getUpdateFilter();
return this.updateOne(query, updateFilter, options).catch((e) => {
console.warn(e, updateFilter);
return Promise.reject(e);
});
}
private doNotMixInclusionAndExclusionFields(options: FindOptions<T> = {}): FindOptions<T> {
const optionsDef = this.ensureDefaultFields(options);
if (optionsDef?.projection === undefined) {
return optionsDef;
}
const projection: Record<string, any> = optionsDef?.projection;
const entries = Object.entries(projection);
const inclusionEntries = entries.filter(([, value]) => value !== 0 && value !== false);
const isMixed = inclusionEntries.length > 0 && inclusionEntries.length < entries.length;
return {
...optionsDef,
// a mixed projection gets its exclusions dropped into a new object; `projection` may be owned by the caller
projection: isMixed ? Object.fromEntries(inclusionEntries) : projection,
};
}
private ensureDefaultFields<P extends Document>(options: FindOptions<P>): FindOptions<P>;
private ensureDefaultFields<P extends Document>(
options?: FindOptions<P> & { fields?: FindOptions<P>['projection'] },
): FindOptions<P> | FindOptions<T> | undefined {
if (options?.fields) {
warnFields("Using 'fields' in models is deprecated.", options);
}
if (this.defaultFields === undefined) {
return options;
}
const { fields: deprecatedFields, projection, ...rest } = options || {};
const fields = { ...deprecatedFields, ...projection };
return {
projection: this.defaultFields,
...(fields && Object.values(fields).length && { projection: fields }),
...rest,
};
}
/*
* Every finder below asserts its result. `this.col` is a `Collection<T>` and yields `WithId<T>`,
* while the signatures return `DocumentWithProjection<P, O>` — a conditional type TS cannot reduce
* while `P` and `O` are still type parameters, so it cannot verify the assignment either way.
* Assert to the declared type rather than `any`, so a Promise/FindCursor mix-up still fails to compile.
* The cursor cases need the `unknown` hop because `FindCursor` is invariant in its element type.
*/
public findOneAndUpdate<P extends Document = T, O extends FindOneAndUpdateOptionsWithProjection = FindOneAndUpdateOptionsWithProjection>(
query: Filter<T>,
update: UpdateFilter<T> | T,
options?: O,
): Promise<DocumentWithDriverProjection<P, O> | null> {
this.setUpdatedAt(update);
if (options?.upsert && !('_id' in update || (update.$set && '_id' in update.$set)) && !('_id' in query)) {
update.$setOnInsert = {
...(update.$setOnInsert || {}),
_id: new ObjectId().toHexString(),
} as Partial<T> & { _id: string };
}
const result = this.col.findOneAndUpdate(query, update, (options || {}) as FindOneAndUpdateOptions);
return result as Promise<DocumentWithDriverProjection<P, O> | null>;
}
async findOneById<P extends Document = T, O extends FindOptionsWithProjection<P> = FindOptionsWithProjection<P>>(
_id: T['_id'],
options?: O,
): Promise<DocumentWithProjection<P, O> | null> {
return this.findOne<P, O>({ _id } as Filter<T>, options);
}
async findOne<P extends Document = T, O extends FindOptionsWithProjection<P> = FindOptionsWithProjection<P>>(
query: Filter<T> | T['_id'] = {},
options?: O,
): Promise<DocumentWithProjection<P, O> | null> {
const q: Filter<T> = typeof query === 'string' ? ({ _id: query } as Filter<T>) : query;
const optionsDef = this.doNotMixInclusionAndExclusionFields(options);
if (optionsDef) {
return this.col.findOne(q, optionsDef) as Promise<DocumentWithProjection<P, O> | null>;
}
return this.col.findOne(q) as Promise<DocumentWithProjection<P, O> | null>;
}
find<P extends Document = T, O extends FindOptionsWithProjection<P> = FindOptionsWithProjection<P>>(
query: Filter<T> = {},
options?: O,
): FindCursor<DocumentWithProjection<P, O>> {
const optionsDef = this.doNotMixInclusionAndExclusionFields(options);
return this.col.find(query, optionsDef) as unknown as FindCursor<DocumentWithProjection<P, O>>;
}
findPaginated<P extends Document = T, O extends FindOptionsWithProjection<P> = FindOptionsWithProjection<P>>(
query: Filter<T> = {},
options?: O,
): FindPaginated<FindCursor<DocumentWithProjection<P, O>>> {
const optionsDef = this.doNotMixInclusionAndExclusionFields(options);
const cursor = optionsDef ? this.col.find(query, optionsDef) : this.col.find(query);
const totalCount = this.col.countDocuments(query);
return {
cursor: cursor as unknown as FindCursor<DocumentWithProjection<P, O>>,
totalCount,
};
}
/**
* @deprecated use {@link updateOne} or {@link updateAny} instead
*/
update(
filter: Filter<T>,
update: UpdateFilter<T> | Partial<T>,
options?: UpdateOptions & { multi?: true },
): Promise<UpdateResult | Document> {
const operation = options?.multi ? 'updateMany' : 'updateOne';
return this[operation](filter, update, options);
}
updateOne(filter: Filter<T>, update: UpdateFilter<T>, options?: UpdateOptions): Promise<UpdateResult> {
this.setUpdatedAt(update);
if (options) {
if (options.upsert && !('_id' in update || (update.$set && '_id' in update.$set)) && !('_id' in filter)) {
update.$setOnInsert = {
...(update.$setOnInsert || {}),
_id: new ObjectId().toHexString(),
} as Partial<T> & { _id: string };
}
return this.col.updateOne(filter, update, options);
}
return this.col.updateOne(filter, update);
}
updateMany(filter: Filter<T>, update: UpdateFilter<T> | Partial<T>, options?: UpdateOptions): Promise<Document | UpdateResult> {
this.setUpdatedAt(update);
if (options) {
return this.col.updateMany(filter, update, options);
}
return this.col.updateMany(filter, update);
}
insertMany(docs: InsertionModel<T>[], options?: BulkWriteOptions): Promise<InsertManyResult<T>> {
docs = docs.map((doc) => {
if (!doc._id || typeof doc._id !== 'string') {
const oid = new ObjectId();
doc = { ...doc, _id: oid.toHexString() } as InsertionModel<T>;
}
this.setUpdatedAt(doc);
return doc;
});
// TODO reavaluate following type casting
return this.col.insertMany(docs as unknown as OptionalUnlessRequiredId<T>[], options || {});
}
insertOne(doc: InsertionModel<T>, options?: InsertOneOptions): Promise<InsertOneResult<T>> {
if (!doc._id || typeof doc._id !== 'string') {
const oid = new ObjectId();
doc = { _id: oid.toHexString(), ...doc };
}
this.setUpdatedAt(doc);
// TODO reavaluate following type casting
return this.col.insertOne(doc as unknown as OptionalUnlessRequiredId<T>, options || {});
}
removeById(_id: T['_id'], options?: { session?: ClientSession }): Promise<DeleteResult> {
return this.deleteOne({ _id } as Filter<T>, { session: options?.session });
}
removeByIds(ids: T['_id'][]): Promise<DeleteResult> {
return this.deleteMany({ _id: { $in: ids } } as unknown as Filter<T>);
}
async deleteOne(filter: Filter<T>, options?: DeleteOptions & { bypassDocumentValidation?: boolean }): Promise<DeleteResult> {
if (!this.trash) {
if (options) {
return this.col.deleteOne(filter, options);
}
return this.col.deleteOne(filter);
}
const doc = await this.findOne(filter);
if (doc) {
const { _id, ...record } = doc;
const trash: TDeleted = {
...record,
_deletedAt: new Date(),
__collection__: this.name,
} as unknown as TDeleted;
// since the operation is not atomic, we need to make sure that the record is not already deleted/inserted
await this.trash?.updateOne(
{ _id } as Filter<TDeleted>,
{ $set: trash },
{
upsert: true,
},
);
}
if (options) {
return this.col.deleteOne(filter, options);
}
return this.col.deleteOne(filter);
}
/**
* No projection narrowing: whether the model archives to a trash collection is a runtime detail
* (a constructor argument), and the trash path has to read the whole document to archive it, so
* it returns every field regardless of the projection. Narrowing here would claim a filtering
* that only happens for models without a trash collection.
*/
async findOneAndDelete(filter: Filter<T>, options?: FindOneAndDeleteOptions): Promise<WithId<T> | null> {
if (!this.trash) {
return this.col.findOneAndDelete(filter, options || {});
}
const doc = await this.col.findOne(filter);
if (!doc) {
return null;
}
const { _id, ...record } = doc;
const trash: TDeleted = {
...record,
_deletedAt: new Date(),
__collection__: this.name,
} as unknown as TDeleted;
await this.trash?.updateOne(
{ _id } as Filter<TDeleted>,
{ $set: trash },
{
upsert: true,
},
);
try {
await this.col.deleteOne({ _id } as Filter<T>);
} catch (e) {
await this.trash?.deleteOne({ _id } as Filter<TDeleted>);
throw e;
}
return doc;
}
findOneAndDeleteById(_id: T['_id'], options?: FindOneAndDeleteOptions): Promise<WithId<T> | null> {
return this.findOneAndDelete({ _id } as Filter<T>, options);
}
async deleteMany(filter: Filter<T>, options?: DeleteOptions & { onTrash?: (record: ResultFields<T, C>) => void }): Promise<DeleteResult> {
if (!this.trash) {
if (options) {
return this.col.deleteMany(filter, options);
}
return this.col.deleteMany(filter);
}
const cursor = this.find<ResultFields<T, C>>(filter, { session: options?.session });
const ids: T['_id'][] = [];
for await (const doc of cursor) {
const { _id, ...record } = doc as T;
const trash: TDeleted = {
...record,
_deletedAt: new Date(),
__collection__: this.name,
} as unknown as TDeleted;
ids.push(_id);
// since the operation is not atomic, we need to make sure that the record is not already deleted/inserted
await this.trash?.updateOne(
{ _id } as Filter<TDeleted>,
{ $set: trash },
{
upsert: true,
session: options?.session,
},
);
void options?.onTrash?.(doc);
}
if (options) {
return this.col.deleteMany({ _id: { $in: ids } } as unknown as Filter<T>, options);
}
return this.col.deleteMany({ _id: { $in: ids } } as unknown as Filter<T>);
}
// Trash
trashFind<P extends TDeleted>(
query: Filter<TDeleted>,
options?: FindOptions<P extends TDeleted ? TDeleted : P>,
): FindCursor<WithId<TDeleted>> | undefined {
if (!this.trash) {
return undefined;
}
if (options) {
return this.trash.find(
{
__collection__: this.name,
...query,
},
options,
);
}
return this.trash.find({
__collection__: this.name,
...query,
});
}
trashFindOneById(_id: TDeleted['_id']): Promise<TDeleted | null>;
trashFindOneById<P extends Document>(_id: TDeleted['_id'], options: FindOptions<P extends TDeleted ? TDeleted : P>): Promise<P | null>;
async trashFindOneById<P extends TDeleted>(
_id: TDeleted['_id'],
options?: FindOptions<P extends TDeleted ? TDeleted : P>,
): Promise<WithId<P | TDeleted> | null> {
const query = {
_id,
__collection__: this.name,
} as Filter<P | TDeleted>;
if (!this.trash) {
return null;
}
if (options) {
return (this.trash as Collection<P | TDeleted>).findOne(query, options);
}
return (this.trash as Collection<P | TDeleted>).findOne(query);
}
private setUpdatedAt(record: UpdateFilter<T> | InsertionModel<T>): void {
if (this.preventSetUpdatedAt) {
return;
}
setUpdatedAt(record);
}
trashFindDeletedAfter(deletedAt: Date): FindCursor<WithId<TDeleted>>;
trashFindDeletedAfter<P extends Document = TDeleted>(
deletedAt: Date,
query?: Filter<TDeleted>,
options?: FindOptions<P extends TDeleted ? TDeleted : P>,
): FindCursor<WithId<TDeleted>> {
const q = {
__collection__: this.name,
_deletedAt: {
$gt: deletedAt,
},
...query,
} as Filter<TDeleted>;
if (!this.trash) {
throw new Error('Trash is not enabled for this collection');
}
if (options) {
return this.trash.find(q, options);
}
return this.trash.find(q);
}
trashFindPaginatedDeletedAfter<P extends Document = TDeleted>(
deletedAt: Date,
query?: Filter<TDeleted>,
options?: FindOptions<P extends TDeleted ? TDeleted : P>,
): FindPaginated<FindCursor<WithId<TDeleted>>> {
const q: Filter<TDeleted> = {
__collection__: this.name,
_deletedAt: {
$gt: deletedAt,
},
...query,
} as Filter<TDeleted>;
if (!this.trash) {
throw new Error('Trash is not enabled for this collection');
}
const cursor = options ? this.trash.find(q, options) : this.trash.find(q);
const totalCount = this.trash.countDocuments(q);
return {
cursor,
totalCount,
};
}
watch(pipeline?: object[]): ChangeStream<T> {
return this.col.watch(pipeline);
}
countDocuments(query: Filter<T>, options?: CountDocumentsOptions): Promise<number> {
if (options) {
return this.col.countDocuments(query, options);
}
return this.col.countDocuments(query);
}
estimatedDocumentCount(): Promise<number> {
return this.col.estimatedDocumentCount();
}
}

View File

@@ -0,0 +1,158 @@
import type { EncryptedContent, IUpload } from '@rocket.chat/core-typings';
import type { IBaseUploadsModel, DocumentWithProjection, FindOptionsWithProjection } from '@rocket.chat/model-typings';
import type {
DeleteResult,
IndexDescription,
UpdateResult,
Document,
InsertOneResult,
WithId,
Filter,
FindCursor,
ClientSession,
} from 'mongodb';
import { BaseRaw } from './BaseRaw';
type T = IUpload;
export abstract class BaseUploadModelRaw extends BaseRaw<T> implements IBaseUploadsModel<T> {
protected override modelIndexes(): IndexDescription[] {
return [
{ key: { name: 1 }, sparse: true },
{ key: { rid: 1 }, sparse: true },
{ key: { expiresAt: 1 }, sparse: true },
];
}
async insertFileInit(userId: string, store: string, file: { name: string }, extra: object = {}): Promise<InsertOneResult<WithId<T>>> {
const fileData: Partial<T> = {
userId,
store,
complete: false,
uploading: true,
progress: 0,
extension: file.name.split('.').pop(),
uploadedAt: new Date(),
...file,
...extra,
};
if (fileData.type) {
fileData.typeGroup = fileData.type.split('/').shift();
}
return this.insertOne(fileData);
}
updateFileComplete(fileId: string, userId: string, file: object): Promise<Document | UpdateResult> | undefined {
if (!fileId) {
return;
}
const filter = {
_id: fileId,
userId,
};
const update: Filter<T> = {
$set: {
complete: true,
uploading: false,
progress: 1,
},
};
update.$set = Object.assign(file, update.$set);
if (update.$set.type) {
update.$set.typeGroup = update.$set.type.split('/').shift();
}
return this.updateOne(filter, update);
}
confirmTemporaryFile(fileId: string, userId: string): Promise<Document | UpdateResult> | undefined {
if (!fileId) {
return;
}
const filter = {
_id: fileId,
userId,
};
const update: Filter<T> = {
$unset: {
expiresAt: 1,
},
};
return this.updateOne(filter, update);
}
findByIds<T_ extends Document = T, O extends FindOptionsWithProjection<T_> = FindOptionsWithProjection<T_>>(
_ids: string[],
options?: O,
): FindCursor<DocumentWithProjection<T_, O>> {
const query = {
_id: {
$in: _ids,
},
};
return this.find<T_, O>(query, options);
}
async findOneByName(name: string, options?: { session?: ClientSession }): Promise<T | null> {
return this.findOne<T>({ name }, { session: options?.session });
}
async findOneByRoomId(rid: string): Promise<T | null> {
return this.findOne({ rid });
}
findExpiredTemporaryFiles<T_ extends Document = T, O extends FindOptionsWithProjection<T_> = FindOptionsWithProjection<T_>>(
options?: O,
): FindCursor<DocumentWithProjection<T_, O>> {
return this.find<T_, O>(
{
expiresAt: {
$lte: new Date(),
},
},
options,
);
}
async updateFileNameById(fileId: string, name: string): Promise<Document | UpdateResult> {
const filter = { _id: fileId };
const update = {
$set: {
name,
},
};
return this.updateOne(filter, update);
}
async deleteFile(fileId: string, options?: { session?: ClientSession }): Promise<DeleteResult> {
return this.deleteOne({ _id: fileId }, { session: options?.session });
}
async findOneByIdAndUserIdAndRoomId<T_ extends Document = T, O extends FindOptionsWithProjection<T_> = FindOptionsWithProjection<T_>>(
fileId: string,
userId: string,
rid: string,
options?: O,
): Promise<DocumentWithProjection<T_, O> | null> {
return this.findOne<T_, O>({ _id: fileId, userId, rid }, options);
}
async updateFileMetadata(
fileId: string,
userId: string,
metadata: { name?: string; description?: string; typeGroup?: string; content?: EncryptedContent },
): Promise<UpdateResult | null> {
return this.updateOne({ _id: fileId, userId }, { $set: metadata });
}
}

View File

@@ -0,0 +1,189 @@
import type { ICalendarEvent, IUser, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { ICalendarEventModel } from '@rocket.chat/model-typings';
import type { FindCursor, IndexDescription, Collection, Db, UpdateResult } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class CalendarEventRaw extends BaseRaw<ICalendarEvent> implements ICalendarEventModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<ICalendarEvent>>) {
super(db, 'calendar_event', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [
{
key: { startTime: -1, uid: 1, externalId: 1 },
},
{
key: { reminderTime: -1, notificationSent: 1 },
},
];
}
public async findOneByExternalIdAndUserId(
externalId: Required<ICalendarEvent>['externalId'],
uid: ICalendarEvent['uid'],
): Promise<ICalendarEvent | null> {
return this.findOne({
externalId,
uid,
});
}
public findByUserIdAndDate(uid: IUser['_id'], date: Date): FindCursor<ICalendarEvent> {
const startTime = new Date(date.toISOString());
startTime.setHours(0, 0, 0, 0);
const finalTime = new Date(date.valueOf());
finalTime.setDate(finalTime.getDate() + 1);
return this.find(
{
uid,
startTime: { $gte: startTime, $lt: finalTime },
},
{
sort: { startTime: 1 },
},
);
}
public async updateEvent(
eventId: ICalendarEvent['_id'],
{ subject, description, startTime, endTime, meetingUrl, reminderMinutesBeforeStart, reminderTime, busy }: Partial<ICalendarEvent>,
): Promise<UpdateResult> {
return this.updateOne(
{ _id: eventId },
{
$set: {
...(subject !== undefined ? { subject } : {}),
...(description !== undefined ? { description } : {}),
...(startTime ? { startTime } : {}),
...(endTime && { endTime }),
...(meetingUrl !== undefined ? { meetingUrl } : {}),
...(reminderMinutesBeforeStart ? { reminderMinutesBeforeStart } : {}),
...(reminderTime ? { reminderTime } : {}),
...(typeof busy === 'boolean' && { busy }),
},
},
);
}
public async findNextNotificationDate(): Promise<Date | null> {
const nextEvent = await this.findOne<Pick<ICalendarEvent, 'reminderTime'>>(
{
reminderTime: {
$gt: new Date(),
},
notificationSent: false,
},
{
sort: {
reminderTime: 1,
},
projection: {
reminderTime: 1,
},
},
);
return nextEvent?.reminderTime || null;
}
public findEventsToNotify(notificationTime: Date, minutes: number): FindCursor<ICalendarEvent> {
// Find all the events between notificationTime and +minutes that have not been notified yet
const maxDate = new Date(notificationTime.toISOString());
maxDate.setMinutes(maxDate.getMinutes() + minutes);
return this.find(
{
reminderTime: {
$gte: notificationTime,
$lt: maxDate,
},
notificationSent: false,
},
{
sort: {
reminderTime: 1,
},
},
);
}
public async flagNotificationSent(eventId: ICalendarEvent['_id']): Promise<UpdateResult> {
return this.updateOne(
{
_id: eventId,
},
{
$set: {
notificationSent: true,
},
},
);
}
public findOverlappingEvents(
eventId: ICalendarEvent['_id'],
uid: IUser['_id'],
startTime: Date,
endTime: Date,
): FindCursor<ICalendarEvent> {
return this.find({
_id: { $ne: eventId }, // Exclude current event
uid,
busy: { $ne: false },
$or: [
// Event starts during our event
{ startTime: { $gte: startTime, $lt: endTime } },
// Event ends during our event
{ endTime: { $gt: startTime, $lte: endTime } },
// Event completely contains our event
{ startTime: { $lte: startTime }, endTime: { $gte: endTime } },
],
});
}
public async findNextFutureEvent(startTime: Date): Promise<Pick<ICalendarEvent, '_id' | 'startTime'> | null> {
return this.findOne(
{
startTime: { $gte: startTime },
busy: { $ne: false },
endTime: { $exists: true },
},
{
sort: { startTime: 1 },
projection: {
startTime: 1,
},
},
);
}
public findEventsStartingNow({
now,
offset = 1000,
}: {
now: Date;
offset?: number;
}): FindCursor<Pick<ICalendarEvent, '_id' | 'uid' | 'startTime' | 'endTime'>> {
return this.find(
{
startTime: {
$gte: new Date(now.getTime() - offset),
$lt: new Date(now.getTime() + offset),
},
busy: { $ne: false },
},
{
projection: {
_id: 1,
uid: 1,
startTime: 1,
endTime: 1,
},
},
);
}
}

View File

@@ -0,0 +1,92 @@
import type { CallHistoryItem, IRegisterUser, IUser } from '@rocket.chat/core-typings';
import type { FindPaginated, ICallHistoryModel, DocumentWithProjection, FindOptionsWithProjection } from '@rocket.chat/model-typings';
import { escapeRegExp } from '@rocket.chat/tools';
import type { Db, Filter, FindCursor, IndexDescription, Document } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class CallHistoryRaw extends BaseRaw<CallHistoryItem> implements ICallHistoryModel {
constructor(db: Db) {
super(db, 'call_history');
}
protected override modelIndexes(): IndexDescription[] {
return [{ key: { uid: 1, callId: 1 }, unique: true }, { key: { uid: 1, ts: -1 } }];
}
async findOneByIdAndUid<T extends Document = CallHistoryItem, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
_id: CallHistoryItem['_id'],
uid: CallHistoryItem['uid'],
options?: O,
): Promise<DocumentWithProjection<T, O> | null> {
return this.findOne<T, O>({ _id, uid }, options);
}
async findOneByCallIdAndUid<T extends Document = CallHistoryItem, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
callId: CallHistoryItem['callId'],
uid: CallHistoryItem['uid'],
options?: O,
): Promise<DocumentWithProjection<T, O> | null> {
return this.findOne<T, O>({ callId, uid }, options);
}
public async updateUserReferences(
userId: IRegisterUser['_id'],
username: IRegisterUser['username'],
name?: IRegisterUser['name'],
): Promise<void> {
await this.updateMany(
{
contactId: userId,
},
{
$set: {
contactUsername: username,
...(name && { contactName: name }),
},
},
);
}
public findAllByUserIdAndSearchFilters<
T extends Document = CallHistoryItem,
O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>,
>(
uid: IUser['_id'],
filters: {
type?: CallHistoryItem['type'];
searchTerm?: string;
direction?: CallHistoryItem['direction'];
inStates?: CallHistoryItem['state'][];
},
options?: O,
): FindPaginated<FindCursor<DocumentWithProjection<T, O>>> {
const { type, direction, inStates, searchTerm } = filters;
const textSearch = searchTerm ? { $regex: escapeRegExp(searchTerm), $options: 'i' } : null;
const query: Filter<CallHistoryItem> = {
uid,
...(type && { type }),
...(direction && { direction }),
...(inStates?.length && { state: { $in: inStates } }),
...(textSearch && {
$or: [
{
contactName: textSearch,
},
{
external: false,
contactUsername: textSearch,
},
{
external: true,
contactExtension: textSearch,
},
],
}),
};
return this.findPaginated<T, O>(query, options);
}
}

View File

@@ -0,0 +1,44 @@
import type { ICredentialToken, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { ICredentialTokensModel, InsertionModel } from '@rocket.chat/model-typings';
import type { Collection, Db, IndexDescription } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class CredentialTokensRaw extends BaseRaw<ICredentialToken> implements ICredentialTokensModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<ICredentialToken>>) {
super(db, 'credential_tokens', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [{ key: { expireAt: 1 }, sparse: true, expireAfterSeconds: 0 }];
}
async create(_id: string, userInfo: ICredentialToken['userInfo']): Promise<void> {
const validForMilliseconds = 60000; // Valid for 60 seconds
const token: InsertionModel<ICredentialToken> = {
_id,
userInfo,
expireAt: new Date(Date.now() + validForMilliseconds),
};
await this.insertOne(token);
}
findOneNotExpiredById(_id: string): Promise<ICredentialToken | null> {
const query = {
_id,
expireAt: { $gt: new Date() },
};
return this.findOne(query);
}
removeNotExpiredById(_id: string): Promise<ICredentialToken | null> {
const query = {
_id,
expireAt: { $gt: new Date() },
};
return this.findOneAndDelete(query);
}
}

View File

@@ -0,0 +1,15 @@
import type { ICronHistoryItem } from '@rocket.chat/core-typings';
import type { ICronHistoryModel } from '@rocket.chat/model-typings';
import type { Db, IndexDescription } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class CronHistoryRaw extends BaseRaw<ICronHistoryItem> implements ICronHistoryModel {
constructor(db: Db) {
super(db, 'cron_history');
}
protected override modelIndexes(): IndexDescription[] {
return [{ key: { intendedAt: 1, name: 1 }, unique: true }];
}
}

View File

@@ -0,0 +1,51 @@
import type { ICustomSound, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { ICustomSoundsModel, DocumentWithProjection, FindOptionsWithProjection } from '@rocket.chat/model-typings';
import type { Collection, FindCursor, Db, IndexDescription, InsertOneResult, UpdateResult, WithId, Document } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class CustomSoundsRaw extends BaseRaw<ICustomSound> implements ICustomSoundsModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<ICustomSound>>) {
super(db, 'custom_sounds', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [{ key: { name: 1 } }];
}
// find
findByName<T extends Document = ICustomSound, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
name: string,
exceptId?: string,
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
const query = {
name,
...(exceptId && { _id: { $nin: [exceptId] } }),
};
return this.find<T, O>(query, options);
}
findOneByName<T extends Document = ICustomSound, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
name: string,
exceptId?: string,
options?: O,
): Promise<DocumentWithProjection<T, O> | null> {
const query = {
name,
...(exceptId && { _id: { $nin: [exceptId] } }),
};
return this.findOne<T, O>(query, options);
}
// INSERT
create(data: Omit<ICustomSound, '_id' | '_updatedAt'>): Promise<InsertOneResult<WithId<ICustomSound>>> {
return this.insertOne(data);
}
updateById(_id: string, data: Partial<Omit<ICustomSound, '_id'>>): Promise<UpdateResult> {
return this.updateOne({ _id }, { $set: data });
}
}

View File

@@ -0,0 +1,86 @@
import type { ICustomUserStatus, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { ICustomUserStatusModel, InsertionModel, DocumentWithProjection, FindOptionsWithProjection } from '@rocket.chat/model-typings';
import type { Collection, FindCursor, Db, IndexDescription, InsertOneResult, UpdateResult, WithId, Document } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class CustomUserStatusRaw extends BaseRaw<ICustomUserStatus> implements ICustomUserStatusModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<ICustomUserStatus>>) {
super(db, 'custom_user_status', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [{ key: { name: 1 } }];
}
async findOneByName<T extends Document = ICustomUserStatus, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
name: string,
options?: O,
): Promise<DocumentWithProjection<T, O> | null> {
return options ? this.findOne<T, O>({ name }, options) : this.findOne<T, O>({ name });
}
findOneByNameExceptId<T extends Document = ICustomUserStatus, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
name: string,
except: string,
options?: O,
): Promise<DocumentWithProjection<T, O> | null> {
const query = {
_id: { $nin: [except] },
name,
};
return this.findOne<T, O>(query, options);
}
// find
findByName<T extends Document = ICustomUserStatus, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
name: string,
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
const query = {
name,
};
return this.find<T, O>(query, options);
}
findByNameExceptId<T extends Document = ICustomUserStatus, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
name: string,
except: string,
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
const query = {
_id: { $nin: [except] },
name,
};
return this.find<T, O>(query, options);
}
// update
setName(_id: string, name: string): Promise<UpdateResult> {
const update = {
$set: {
name,
},
};
return this.updateOne({ _id }, update);
}
setStatusType(_id: string, statusType: string): Promise<UpdateResult> {
const update = {
$set: {
statusType,
},
};
return this.updateOne({ _id }, update);
}
// INSERT
create(data: InsertionModel<ICustomUserStatus>): Promise<InsertOneResult<WithId<ICustomUserStatus>>> {
return this.insertOne(data);
}
}

View File

@@ -0,0 +1,39 @@
import type { IEmailInbox, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IEmailInboxModel } from '@rocket.chat/model-typings';
import type { Collection, Db, FindCursor, IndexDescription, InsertOneResult, UpdateFilter, WithId } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class EmailInboxRaw extends BaseRaw<IEmailInbox> implements IEmailInboxModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IEmailInbox>>) {
super(db, 'email_inbox', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [{ key: { email: 1 }, unique: true }];
}
async setDisabledById(id: IEmailInbox['_id']): Promise<null | WithId<IEmailInbox>> {
return this.findOneAndUpdate({ _id: id, active: true }, { $set: { active: false } }, { returnDocument: 'after' });
}
async create(emailInbox: IEmailInbox): Promise<InsertOneResult<IEmailInbox>> {
return this.insertOne(emailInbox);
}
async updateById(id: IEmailInbox['_id'], data: UpdateFilter<IEmailInbox>): Promise<null | WithId<Pick<IEmailInbox, '_id'>>> {
// findOneAndUpdate doesn't accept generics, so we had to type cast
return this.findOneAndUpdate({ _id: id }, data, {
returnDocument: 'after',
projection: { _id: 1 },
});
}
findActive(): FindCursor<IEmailInbox> {
return this.find({ active: true });
}
async findByEmail(email: IEmailInbox['email']): Promise<IEmailInbox | null> {
return this.findOne({ email });
}
}

View File

@@ -0,0 +1,23 @@
import type { IEmailMessageHistory, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IEmailMessageHistoryModel, InsertionModel } from '@rocket.chat/model-typings';
import type { Collection, Db, InsertOneResult, WithId, IndexDescription } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class EmailMessageHistoryRaw extends BaseRaw<IEmailMessageHistory> implements IEmailMessageHistoryModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IEmailMessageHistory>>) {
super(db, 'email_message_history', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [{ key: { createdAt: 1 }, expireAfterSeconds: 60 * 60 * 24 }];
}
async create({ _id, email }: InsertionModel<IEmailMessageHistory>): Promise<InsertOneResult<WithId<IEmailMessageHistory>>> {
return this.insertOne({
_id,
email,
createdAt: new Date(),
});
}
}

View File

@@ -0,0 +1,121 @@
import type { IEmojiCustom, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IEmojiCustomModel, InsertionModel, DocumentWithProjection, FindOptionsWithProjection } from '@rocket.chat/model-typings';
import type { Collection, Filter, FindCursor, Db, IndexDescription, InsertOneResult, UpdateResult, WithId, Document } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class EmojiCustomRaw extends BaseRaw<IEmojiCustom> implements IEmojiCustomModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IEmojiCustom>>) {
super(db, 'custom_emoji', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [{ key: { name: 1 } }, { key: { aliases: 1 } }, { key: { extension: 1 } }];
}
// find
findByNameOrAlias<T extends Document = IEmojiCustom, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
emojiName: string,
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
let name = emojiName;
if (typeof emojiName === 'string') {
name = emojiName.replaceAll(':', '');
}
const query = {
$or: [{ name }, { aliases: name }],
};
return this.find<T, O>(query, options);
}
findOneByNamesOrAliases<T extends Document = IEmojiCustom, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
names: string[],
exceptId?: string,
options?: O,
): Promise<DocumentWithProjection<T, O> | null> {
const query: Filter<IEmojiCustom> = {
...(exceptId && { _id: { $nin: [exceptId] } }),
$or: [{ name: { $in: names } }, { aliases: { $in: names } }],
};
return this.findOne<T, O>(query, options);
}
findByNameOrAliasExceptID<T extends Document = IEmojiCustom, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
name: string,
except: string,
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
const query = {
_id: { $nin: [except] },
$or: [{ name }, { aliases: name }],
};
return this.find<T, O>(query, options);
}
// update
setName(_id: string, name: string): Promise<UpdateResult> {
const update = {
$set: {
name,
},
};
return this.updateOne({ _id }, update);
}
setAliases(_id: string, aliases: string[]): Promise<UpdateResult> {
const update = {
$set: {
aliases,
},
};
return this.updateOne({ _id }, update);
}
setExtension(_id: string, extension: string): Promise<UpdateResult> {
const update = {
$set: {
extension,
},
};
return this.updateOne({ _id }, update);
}
setETagByName(name: string, etag: string): Promise<UpdateResult> {
const update = {
$set: {
etag,
},
};
return this.updateOne({ name }, update);
}
// INSERT
create(data: InsertionModel<IEmojiCustom>): Promise<InsertOneResult<WithId<IEmojiCustom>>> {
return this.insertOne(data);
}
countByNameOrAlias(name: string): Promise<number> {
const query = {
$or: [{ name }, { aliases: name }],
};
return this.countDocuments(query);
}
// TODO: convert name: string to branded type using to enforce validation also replace this type cross the models/apis
findOneByName<T extends Document = IEmojiCustom, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
name: string,
options?: O,
): Promise<DocumentWithProjection<T, O> | null> {
return this.findOne<T, O>({ name }, options);
}
}

View File

@@ -0,0 +1,70 @@
import type { IExportOperation, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IExportOperationsModel } from '@rocket.chat/model-typings';
import type { Collection, Db, IndexDescription, UpdateResult } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class ExportOperationsRaw extends BaseRaw<IExportOperation> implements IExportOperationsModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IExportOperation>>) {
super(db, 'export_operations', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [{ key: { userId: 1 } }, { key: { status: 1 } }];
}
findOnePending(): Promise<IExportOperation | null> {
const query = {
status: { $nin: ['completed', 'skipped'] },
};
return this.findOne(query);
}
async create(data: IExportOperation): Promise<string> {
const result = await this.insertOne({
...data,
createdAt: new Date(),
});
return result.insertedId;
}
findLastOperationByUser(userId: string, fullExport = false): Promise<IExportOperation | null> {
const query = {
userId,
fullExport,
};
return this.findOne(query, { sort: { createdAt: -1 } });
}
countAllPendingBeforeMyRequest(requestDay: Date): Promise<number> {
const query = {
status: { $nin: ['completed', 'skipped'] },
createdAt: { $lt: requestDay },
};
return this.countDocuments(query);
}
updateOperation(data: IExportOperation): Promise<UpdateResult> {
const update = {
$set: {
roomList: data.roomList,
status: data.status,
fileList: data.fileList,
generatedFile: data.generatedFile,
fileId: data.fileId,
userNameTable: data.userNameTable,
userData: data.userData,
generatedUserFile: data.generatedUserFile,
generatedAvatar: data.generatedAvatar,
exportPath: data.exportPath,
assetsPath: data.assetsPath,
},
};
return this.updateOne({ _id: data._id }, update);
}
}

View File

@@ -0,0 +1,68 @@
import type { FederationKey, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IFederationKeysModel } from '@rocket.chat/model-typings';
import type { Db, Collection } from 'mongodb';
import NodeRSA from 'node-rsa';
import { BaseRaw } from './BaseRaw';
export class FederationKeysRaw extends BaseRaw<FederationKey> implements IFederationKeysModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<FederationKey>>) {
super(db, 'federation_keys', trash);
}
async getKey(type: FederationKey['type']): Promise<string | null> {
const keyResource = await this.findOne({ type });
if (!keyResource) {
return null;
}
return keyResource.key;
}
loadKey(keyData: NodeRSA.Key, type: FederationKey['type']): NodeRSA {
return new NodeRSA(keyData, `pkcs8-${type}-pem`);
}
async generateKeys(): Promise<{
privateKey: '' | NodeRSA | null;
publicKey: '' | NodeRSA | null;
}> {
const key = new NodeRSA({ b: 2048 });
key.generateKeyPair();
await this.deleteMany({});
await this.insertOne({
type: 'private',
key: key.exportKey('pkcs8-private-pem').replace(/\n|\r/g, ''),
});
await this.insertOne({
type: 'public',
key: key.exportKey('pkcs8-public-pem').replace(/\n|\r/g, ''),
});
return {
privateKey: await this.getPrivateKey(),
publicKey: await this.getPublicKey(),
};
}
async getPrivateKey(): Promise<'' | NodeRSA | null> {
const keyData = await this.getKey('private');
return keyData && this.loadKey(keyData, 'private');
}
async getPublicKey(): Promise<'' | NodeRSA | null> {
const keyData = await this.getKey('public');
return keyData && this.loadKey(keyData, 'public');
}
getPublicKeyString(): Promise<string | null> {
return this.getKey('public');
}
}

View File

@@ -0,0 +1,109 @@
import type {
IImportChannelRecord,
IImportRecord,
IImportUserRecord,
IImportContactRecord,
RocketChatRecordDeleted,
} from '@rocket.chat/core-typings';
import type { IImportDataModel } from '@rocket.chat/model-typings';
import type { Collection, Db, IndexDescription } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class ImportDataRaw extends BaseRaw<IImportRecord> implements IImportDataModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IImportRecord>>) {
super(db, 'import_data', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [{ key: { dataType: 1 } }];
}
getAllUsersForSelection(): Promise<Array<IImportUserRecord>> {
return this.find<IImportUserRecord>(
{
dataType: 'user',
},
{
projection: {
'data.importIds': 1,
'data.username': 1,
'data.emails': 1,
'data.deleted': 1,
'data.type': 1,
},
},
).toArray();
}
getAllChannelsForSelection(): Promise<Array<IImportChannelRecord>> {
return this.find<IImportChannelRecord>(
{
'dataType': 'channel',
'data.t': {
$ne: 'd',
},
},
{
projection: {
'data.importIds': 1,
'data.name': 1,
'data.archived': 1,
'data.t': 1,
},
},
).toArray();
}
getAllContactsForSelection(): Promise<IImportContactRecord[]> {
return this.find<IImportContactRecord>(
{
dataType: 'contact',
},
{
projection: {
'data.importIds': 1,
'data.name': 1,
'data.phones': 1,
'data.emails': 1,
},
},
).toArray();
}
async checkIfDirectMessagesExists(): Promise<boolean> {
return (
(await this.countDocuments({
'dataType': 'channel',
'data.t': 'd',
})) > 0
);
}
async countMessages(): Promise<number> {
return this.countDocuments({ dataType: 'message' });
}
async findChannelImportIdByNameOrImportId(channelIdentifier: string): Promise<string | undefined> {
const channel = await this.findOne<IImportChannelRecord>(
{
dataType: 'channel',
$or: [
{
'data.name': channelIdentifier,
},
{
'data.importIds': channelIdentifier,
},
],
},
{
projection: {
'data.importIds': 1,
},
},
);
return channel?.data?.importIds?.shift();
}
}

View File

@@ -0,0 +1,52 @@
import type { IImport } from '@rocket.chat/core-typings';
import type { IImportsModel } from '@rocket.chat/model-typings';
import type { Db, Document, UpdateResult, IndexDescription } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class ImportsModel extends BaseRaw<IImport> implements IImportsModel {
constructor(db: Db) {
super(db, 'import');
}
protected override modelIndexes(): IndexDescription[] {
return [{ key: { ts: -1 } }, { key: { valid: 1 } }];
}
async findLastImport(): Promise<IImport | undefined> {
const imports = await this.find({}, { sort: { ts: -1 }, limit: 1 }).toArray();
return imports.shift();
}
invalidateAllOperations(): Promise<UpdateResult | Document> {
return this.updateMany({ valid: { $ne: false } }, { $set: { valid: false } });
}
invalidateOperationsExceptId(id: string): Promise<UpdateResult | Document> {
return this.updateMany({ valid: { $ne: false }, _id: { $ne: id } }, { $set: { valid: false } });
}
async increaseTotalCount(id: string, recordType: 'users' | 'channels' | 'messages', increaseBy = 1): Promise<UpdateResult> {
return this.updateOne(
{ _id: id },
{
$inc: {
'count.total': increaseBy,
[`count.${recordType}`]: increaseBy,
},
},
);
}
async setOperationStatus(id: string, status: IImport['status']): Promise<UpdateResult> {
return this.updateOne(
{ _id: id },
{
$set: {
status,
},
},
);
}
}

View File

@@ -0,0 +1,60 @@
import type { IInstanceStatus } from '@rocket.chat/core-typings';
import type { IInstanceStatusModel } from '@rocket.chat/model-typings';
import type { Db, UpdateResult, DeleteResult, ChangeStream } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class InstanceStatusRaw extends BaseRaw<IInstanceStatus> implements IInstanceStatusModel {
constructor(db: Db) {
super(db, 'instances', undefined, {
preventSetUpdatedAt: true,
collectionNameResolver(name) {
return name;
},
});
}
watchActiveInstances(): ChangeStream<IInstanceStatus> {
return this.col.watch();
}
async removeInstanceById(_id: IInstanceStatus['_id']): Promise<DeleteResult> {
return this.deleteOne({ _id });
}
async setDocumentHeartbeat(documentId: string): Promise<UpdateResult> {
return this.updateOne({ _id: documentId }, { $currentDate: { _updatedAt: true } });
}
async upsertInstance(instance: Partial<IInstanceStatus>): Promise<IInstanceStatus | null> {
return this.findOneAndUpdate(
{
_id: instance._id,
},
{
$set: instance,
$currentDate: {
_createdAt: true,
_updatedAt: true,
},
},
{
upsert: true,
returnDocument: 'after',
},
);
}
async updateConnections(_id: IInstanceStatus['_id'], conns: number) {
return this.updateOne(
{
_id,
},
{
$set: {
'extraInformation.conns': conns,
},
},
);
}
}

View File

@@ -0,0 +1,38 @@
import type { IIntegrationHistory } from '@rocket.chat/core-typings';
import type { IIntegrationHistoryModel } from '@rocket.chat/model-typings';
import type { Db, IndexDescription, InsertOneResult, FindOneAndUpdateOptions } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class IntegrationHistoryRaw extends BaseRaw<IIntegrationHistory> implements IIntegrationHistoryModel {
constructor(db: Db) {
super(db, 'integration_history');
}
protected override modelIndexes(): IndexDescription[] {
return [
{ key: { 'integration._id': 1, 'integration._createdBy._id': 1 } },
{ key: { _updatedAt: 1 }, expireAfterSeconds: 30 * 24 * 60 * 60 },
];
}
removeByIntegrationId(integrationId: string): ReturnType<BaseRaw<IIntegrationHistory>['deleteMany']> {
return this.deleteMany({ 'integration._id': integrationId });
}
findOneByIntegrationIdAndHistoryId(integrationId: string, historyId: string): Promise<IIntegrationHistory | null> {
return this.findOne({ 'integration._id': integrationId, '_id': historyId });
}
async create(integrationHistory: IIntegrationHistory): Promise<InsertOneResult<IIntegrationHistory>> {
return this.insertOne({ ...integrationHistory, _createdAt: new Date() });
}
async updateById(
_id: IIntegrationHistory['_id'],
data: Partial<IIntegrationHistory>,
options?: FindOneAndUpdateOptions,
): Promise<IIntegrationHistory | null> {
return this.findOneAndUpdate({ _id }, { $set: data }, { returnDocument: 'after', ...options });
}
}

View File

@@ -0,0 +1,123 @@
import type { IIntegration, IUser, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type {
IBaseModel,
IIntegrationsModel,
IntegrationsStatistics,
DocumentWithProjection,
FindOptionsWithProjection,
} from '@rocket.chat/model-typings';
import type { AggregateOptions, Collection, Db, FindCursor, IndexDescription, Document } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class IntegrationsRaw extends BaseRaw<IIntegration> implements IIntegrationsModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IIntegration>>) {
super(db, 'integrations', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [{ key: { type: 1 } }];
}
findOneByUrl(url: string): Promise<IIntegration | null> {
return this.findOne({ url });
}
updateRoomName(oldRoomName: string, newRoomName: string): ReturnType<IBaseModel<IIntegration>['updateMany']> {
const hashedOldRoomName = `#${oldRoomName}`;
const hashedNewRoomName = `#${newRoomName}`;
return this.updateMany(
{
channel: hashedOldRoomName,
},
{
$set: {
'channel.$': hashedNewRoomName,
},
},
);
}
findOneByIdAndCreatedByIfExists({
_id,
createdBy,
}: {
_id: IIntegration['_id'];
createdBy?: IUser['_id'];
}): Promise<IIntegration | null> {
return this.findOne({
_id,
...(createdBy && { '_createdBy._id': createdBy }),
});
}
removeByIdAndCreatedByIfExists({ _id, createdBy }: { _id: IIntegration['_id']; createdBy?: IUser['_id'] }): Promise<IIntegration | null> {
return this.findOneAndDelete({
_id,
...(createdBy && { '_createdBy._id': createdBy }),
});
}
disableByUserId(userId: IIntegration['userId']): ReturnType<IBaseModel<IIntegration>['updateMany']> {
return this.updateMany({ userId }, { $set: { enabled: false } });
}
findByUserId(userId: IIntegration['userId']): FindCursor<Pick<IIntegration, '_id'>> {
return this.find({ userId }, { projection: { _id: 1 } });
}
findByChannels(channels: IIntegration['channel']): FindCursor<IIntegration> {
return this.find({ channel: { $in: channels } });
}
findOneByIdAndToken<P extends Document = IIntegration, O extends FindOptionsWithProjection<P> = FindOptionsWithProjection<P>>(
id: IIntegration['_id'],
token: string,
options?: O,
): Promise<DocumentWithProjection<P, O> | null> {
return this.findOne<P, O>({ _id: id, token }, options);
}
async getStatistics(options?: AggregateOptions): Promise<IntegrationsStatistics> {
const results = await this.col
.aggregate<{ _id: IIntegration['type']; total: number; active: number; scriptEnabled: number }>(
[
{
$group: {
_id: '$type',
total: { $sum: 1 },
active: { $sum: { $cond: ['$enabled', 1, 0] } },
scriptEnabled: { $sum: { $cond: ['$scriptEnabled', 1, 0] } },
},
},
],
options,
)
.toArray();
const statistics: IntegrationsStatistics = {
totalIntegrations: 0,
totalIncoming: 0,
totalIncomingActive: 0,
totalOutgoing: 0,
totalOutgoingActive: 0,
totalWithScriptEnabled: 0,
};
for (const { _id, total, active, scriptEnabled } of results) {
statistics.totalIntegrations += total;
statistics.totalWithScriptEnabled += scriptEnabled;
if (_id === 'webhook-incoming') {
statistics.totalIncoming += total;
statistics.totalIncomingActive += active;
} else if (_id === 'webhook-outgoing') {
statistics.totalOutgoing += total;
statistics.totalOutgoingActive += active;
}
}
return statistics;
}
}

View File

@@ -0,0 +1,39 @@
import type { IInvite, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IInvitesModel } from '@rocket.chat/model-typings';
import type { Collection, Db, UpdateResult } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class InvitesRaw extends BaseRaw<IInvite> implements IInvitesModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IInvite>>) {
super(db, 'invites', trash);
}
findOneByUserRoomMaxUsesAndExpiration(userId: string, rid: string, maxUses: number, daysToExpire: number): Promise<IInvite | null> {
return this.findOne({
rid,
userId,
days: daysToExpire,
maxUses,
...(daysToExpire > 0 ? { expires: { $gt: new Date() } } : {}),
...(maxUses > 0 ? { uses: { $lt: maxUses } } : {}),
});
}
increaseUsageById(_id: string, uses = 1): Promise<UpdateResult> {
return this.updateOne(
{ _id },
{
$inc: {
uses,
},
},
);
}
async countUses(): Promise<number> {
const [result] = await this.col.aggregate<{ totalUses: number }>([{ $group: { _id: null, totalUses: { $sum: '$uses' } } }]).toArray();
return result?.totalUses || 0;
}
}

View File

@@ -0,0 +1,238 @@
import type { ILivechatAgentActivity, IServiceHistory, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { ILivechatAgentActivityModel } from '@rocket.chat/model-typings';
import { parseISO, format } from 'date-fns';
import type { AggregationCursor, Collection, Document, FindCursor, Db, WithId, IndexDescription, UpdateResult } from 'mongodb';
import { BaseRaw } from './BaseRaw';
import { readSecondaryPreferred } from '../readSecondaryPreferred';
export class LivechatAgentActivityRaw extends BaseRaw<ILivechatAgentActivity> implements ILivechatAgentActivityModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<ILivechatAgentActivity>>) {
super(db, 'livechat_agent_activity', trash);
}
override modelIndexes(): IndexDescription[] {
return [{ key: { date: 1 } }, { key: { agentId: 1, date: 1 }, unique: true }];
}
findOneByAgendIdAndDate(agentId: string, date: ILivechatAgentActivity['date']): Promise<ILivechatAgentActivity | null> {
return this.findOne({ agentId, date });
}
async createOrUpdate(
data: Partial<Pick<ILivechatAgentActivity, 'date' | 'agentId' | 'lastStartedAt'>> = {},
): Promise<null | WithId<ILivechatAgentActivity>> {
const { date, agentId, lastStartedAt } = data;
if (!date || !agentId) {
return null;
}
return this.findOneAndUpdate(
{ agentId, date },
{
$unset: {
lastStoppedAt: 1,
},
$set: {
lastStartedAt: lastStartedAt || new Date(),
},
$setOnInsert: {
date,
agentId,
},
},
{ upsert: true },
);
}
updateLastStoppedAt({
agentId,
date,
lastStoppedAt,
availableTime,
}: Pick<ILivechatAgentActivity, 'date' | 'agentId' | 'lastStoppedAt' | 'availableTime'>): Promise<UpdateResult | Document> {
const query = {
agentId,
date,
};
const update = {
$inc: { availableTime },
$set: {
lastStoppedAt,
},
};
return this.updateMany(query, update);
}
updateServiceHistory({
agentId,
date,
serviceHistory,
}: Pick<ILivechatAgentActivity, 'date' | 'agentId'> & { serviceHistory: IServiceHistory }): Promise<UpdateResult | Document> {
const query = {
agentId,
date,
};
const update = {
$addToSet: {
serviceHistory,
},
};
return this.updateMany(query, update);
}
findOpenSessions(): FindCursor<ILivechatAgentActivity> {
const query = {
lastStoppedAt: { $exists: false },
};
return this.find(query);
}
findAllAverageAvailableServiceTime({ date, departmentId }: { date: Date; departmentId?: string }): Promise<
{
averageAvailableServiceTimeInSeconds: number;
}[]
> {
const match = { $match: { date } };
const lookup = {
$lookup: {
from: 'rocketchat_livechat_department_agents',
localField: 'agentId',
foreignField: 'agentId',
as: 'departments',
},
};
const unwind = {
$unwind: {
path: '$departments',
preserveNullAndEmptyArrays: true,
},
};
const departmentsMatch = {
$match: {
'departments.departmentId': departmentId,
},
};
const sumAvailableTimeWithCurrentTime = {
$sum: [{ $divide: [{ $subtract: [new Date(), '$lastStartedAt'] }, 1000] }, '$availableTime'],
};
const group = {
$group: {
_id: null,
allAvailableTimeInSeconds: {
$sum: {
$cond: [{ $ifNull: ['$lastStoppedAt', false] }, '$availableTime', sumAvailableTimeWithCurrentTime],
},
},
rooms: { $sum: 1 },
},
};
const project = {
$project: {
averageAvailableServiceTimeInSeconds: {
$trunc: {
$cond: [{ $eq: ['$rooms', 0] }, 0, { $divide: ['$allAvailableTimeInSeconds', '$rooms'] }],
},
},
},
};
const params = [match] as object[];
if (departmentId && (departmentId !== 'undefined' || departmentId !== undefined)) {
params.push(lookup);
params.push(unwind);
params.push(departmentsMatch);
}
params.push(group);
params.push(project);
return this.col
.aggregate<{
averageAvailableServiceTimeInSeconds: number;
}>(params, { readPreference: readSecondaryPreferred() })
.toArray();
}
findAvailableServiceTimeHistory(p: {
start: string;
end: string;
fullReport: boolean;
onlyCount: true;
options?: { sort?: Record<string, number>; offset?: number; count?: number };
}): AggregationCursor<{ total: number }>;
findAvailableServiceTimeHistory(p: {
start: string;
end: string;
fullReport: boolean;
onlyCount?: false;
options?: { sort?: Record<string, number>; offset?: number; count?: number };
}): AggregationCursor<ILivechatAgentActivity>;
findAvailableServiceTimeHistory({
start,
end,
fullReport,
onlyCount = false,
options = {},
}: {
start: string;
end: string;
fullReport: boolean;
onlyCount?: boolean;
options?: { sort?: Record<string, number>; offset?: number; count?: number };
}): AggregationCursor<ILivechatAgentActivity> | AggregationCursor<{ total: number }> {
const match = {
$match: {
date: {
$gte: parseInt(format(parseISO(start), 'yyyyMMdd')),
$lte: parseInt(format(parseISO(end), 'yyyyMMdd')),
},
},
};
const lookup = {
$lookup: {
from: 'users',
localField: 'agentId',
foreignField: '_id',
as: 'user',
},
};
const unwind = {
$unwind: {
path: '$user',
},
};
const group = {
$group: {
_id: { _id: '$user._id', username: '$user.username' },
serviceHistory: { $first: '$serviceHistory' },
availableTimeInSeconds: { $sum: '$availableTime' },
},
};
const project = {
$project: {
_id: 0,
username: '$_id.username',
availableTimeInSeconds: 1,
...(fullReport && { serviceHistory: 1 }),
},
};
const sort = { $sort: options.sort || { username: 1 } };
const params = [match, lookup, unwind, group, project, sort] as object[];
if (onlyCount) {
params.push({ $count: 'total' });
return this.col.aggregate<{ total: number }>(params);
}
if (options.offset) {
params.push({ $skip: options.offset });
}
if (options.count) {
params.push({ $limit: options.count });
}
return this.col.aggregate<ILivechatAgentActivity>(params, { allowDiskUse: true, readPreference: readSecondaryPreferred() });
}
}

View File

@@ -0,0 +1,179 @@
import type { ILivechatBusinessHour, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings';
import type { ILivechatBusinessHoursModel, DocumentWithProjection, FindOptionsWithProjection } from '@rocket.chat/model-typings';
import type { Collection, Db, Document } from 'mongodb';
import { BaseRaw } from './BaseRaw';
interface IWorkHoursCronJobsItem {
day: string;
times: string[];
}
export interface IWorkHoursCronJobsWrapper {
start: IWorkHoursCronJobsItem[];
finish: IWorkHoursCronJobsItem[];
}
export class LivechatBusinessHoursRaw extends BaseRaw<ILivechatBusinessHour> implements ILivechatBusinessHoursModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<ILivechatBusinessHour>>) {
super(db, 'livechat_business_hours', trash);
}
findOneDefaultBusinessHour<
P extends Document = ILivechatBusinessHour,
O extends FindOptionsWithProjection<P> = FindOptionsWithProjection<P>,
>(options?: O): Promise<DocumentWithProjection<P, O> | null> {
return this.findOne<P, O>({ type: LivechatBusinessHourTypes.DEFAULT }, options);
}
findActiveAndOpenBusinessHoursByDay<
T extends Document = ILivechatBusinessHour,
O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>,
>(day: string, options?: O): Promise<DocumentWithProjection<T, O>[]> {
return this.find<T, O>(
{
active: true,
workHours: {
$elemMatch: {
$or: [{ 'start.cron.dayOfWeek': day }, { 'finish.cron.dayOfWeek': day }],
open: true,
},
},
},
options,
).toArray();
}
findActiveBusinessHours<
T extends Document = ILivechatBusinessHour,
O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>,
>(options?: O): Promise<DocumentWithProjection<T, O>[]> {
return this.find<T, O>(
{
active: true,
},
options,
).toArray();
}
findDefaultActiveAndOpenBusinessHoursByDay(day: string, options?: any): Promise<ILivechatBusinessHour[]> {
return this.find(
{
type: LivechatBusinessHourTypes.DEFAULT,
active: true,
workHours: {
$elemMatch: {
$or: [{ 'start.cron.dayOfWeek': day }, { 'finish.cron.dayOfWeek': day }],
open: true,
},
},
},
options,
).toArray();
}
override async insertOne(data: Omit<ILivechatBusinessHour, '_id' | '_updatedAt'>): Promise<any> {
return super.insertOne({
...data,
ts: new Date(),
});
}
findHoursToScheduleJobs(): Promise<IWorkHoursCronJobsWrapper[]> {
return this.col
.aggregate([
{
$facet: {
start: [
{ $match: { active: true } },
{ $project: { _id: 0, workHours: 1 } },
{ $unwind: { path: '$workHours' } },
{ $match: { 'workHours.open': true } },
{
$group: {
_id: { day: '$workHours.start.cron.dayOfWeek' },
times: { $addToSet: '$workHours.start.cron.time' },
},
},
{
$project: {
_id: 0,
day: '$_id.day',
times: 1,
},
},
],
finish: [
{ $match: { active: true } },
{ $project: { _id: 0, workHours: 1 } },
{ $unwind: { path: '$workHours' } },
{ $match: { 'workHours.open': true } },
{
$group: {
_id: { day: '$workHours.finish.cron.dayOfWeek' },
times: { $addToSet: '$workHours.finish.cron.time' },
},
},
{
$project: {
_id: 0,
day: '$_id.day',
times: 1,
},
},
],
},
},
])
.toArray() as any;
}
async findActiveBusinessHoursToOpen(
day: string,
start: string,
type?: LivechatBusinessHourTypes,
options?: any,
): Promise<ILivechatBusinessHour[]> {
const query: Record<string, any> = {
active: true,
workHours: {
$elemMatch: {
'start.cron.dayOfWeek': day,
'start.cron.time': start,
'open': true,
},
},
};
if (type) {
query.type = type;
}
return this.col.find(query, options).toArray();
}
async findActiveBusinessHoursToClose(
day: string,
finish: string,
type?: LivechatBusinessHourTypes,
options?: any,
): Promise<ILivechatBusinessHour[]> {
const query: Record<string, any> = {
active: true,
workHours: {
$elemMatch: {
'finish.cron.dayOfWeek': day,
'finish.cron.time': finish,
'open': true,
},
},
};
if (type) {
query.type = type;
}
return this.col.find(query, options).toArray();
}
disableBusinessHour(businessHourId: string): Promise<any> {
return this.updateOne({ _id: businessHourId }, { $set: { active: false } });
}
}

View File

@@ -0,0 +1,444 @@
import type {
AtLeast,
ILivechatContact,
ILivechatContactChannel,
ILivechatContactVisitorAssociation,
ILivechatVisitor,
RocketChatRecordDeleted,
} from '@rocket.chat/core-typings';
import type {
FindPaginated,
ILivechatContactsModel,
InsertionModel,
Updater,
DocumentWithProjection,
FindOptionsWithProjection,
} from '@rocket.chat/model-typings';
import { escapeRegExp } from '@rocket.chat/tools';
import type {
Document,
Collection,
Db,
RootFilterOperators,
Filter,
FindCursor,
IndexDescription,
UpdateResult,
UpdateFilter,
UpdateOptions,
FindOneAndUpdateOptions,
AggregationCursor,
} from 'mongodb';
import { BaseRaw } from './BaseRaw';
import { readSecondaryPreferred } from '../readSecondaryPreferred';
export class LivechatContactsRaw extends BaseRaw<ILivechatContact> implements ILivechatContactsModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<ILivechatContact>>) {
super(db, 'livechat_contact', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [
{
key: { name: 1 },
unique: false,
name: 'name_insensitive',
collation: { locale: 'en', strength: 2, caseLevel: false },
},
{
key: { 'emails.address': 1 },
unique: false,
name: 'emails_insensitive',
partialFilterExpression: { emails: { $exists: true } },
collation: { locale: 'en', strength: 2, caseLevel: false },
},
{
key: { 'phones.phoneNumber': 1 },
partialFilterExpression: { phones: { $exists: true } },
unique: false,
},
{
key: {
'channels.visitor.visitorId': 1,
'channels.visitor.source.type': 1,
'channels.visitor.source.id': 1,
},
name: 'visitorAssociation',
unique: false,
},
{
key: {
'channels.field': 1,
'channels.value': 1,
'channels.verified': 1,
},
partialFilterExpression: { 'channels.verified': true },
name: 'verificationKey',
unique: false,
},
{
key: {
preRegistration: 1,
},
sparse: true,
unique: false,
},
{
key: { activity: 1 },
sparse: true,
},
{
key: { channels: 1 },
unique: false,
},
{
key: { 'channels.blocked': 1 },
sparse: true,
},
{
key: { 'channels.verified': 1 },
sparse: true,
},
{
key: { unknown: 1 },
unique: false,
},
];
}
async insertContact(
data: InsertionModel<Omit<ILivechatContact, 'createdAt'>> & { createdAt?: ILivechatContact['createdAt'] },
): Promise<ILivechatContact['_id']> {
const result = await this.insertOne({
createdAt: new Date(),
...data,
preRegistration: !data.channels.length,
});
return result.insertedId;
}
async patchContact(
contactId: string,
changes: {
set?: Partial<ILivechatContact>;
unset?: Partial<Record<keyof ILivechatContact, '' | 1>>;
},
options?: FindOneAndUpdateOptions,
) {
const { set = {}, unset = {} } = changes;
const $set = {
...set,
unknown: false,
...(set.channels && { preRegistration: !set.channels.length }),
};
return this.findOneAndUpdate(
{ _id: contactId, enabled: { $ne: false } },
{
$set,
...(Object.keys(unset).length > 0 && { $unset: unset }),
},
{ returnDocument: 'after', ...options },
);
}
updateById(contactId: string, update: UpdateFilter<ILivechatContact>, options?: UpdateOptions): Promise<Document | UpdateResult> {
return this.updateOne({ _id: contactId, enabled: { $ne: false } }, update, options);
}
findPaginatedContacts<T extends Document = ILivechatContact, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
search: { searchText?: string; unknown?: boolean },
options?: O,
): FindPaginated<FindCursor<DocumentWithProjection<T, O>>> {
const { searchText, unknown = false } = search;
const searchRegex = escapeRegExp(searchText || '');
const match: Filter<ILivechatContact & RootFilterOperators<ILivechatContact>> = {
$or: [
{ name: { $regex: searchRegex, $options: 'i' } },
{ 'emails.address': { $regex: searchRegex, $options: 'i' } },
{ 'phones.phoneNumber': { $regex: searchRegex, $options: 'i' } },
],
unknown,
enabled: { $ne: false },
};
// safe to merge into `O`: only `O['projection']` feeds the return type, and it survives the spread
return this.findPaginated<T, O>({ ...match }, {
allowDiskUse: true,
...options,
} as unknown as O);
}
async findContactMatchingVisitor(visitor: AtLeast<ILivechatVisitor, 'visitorEmails' | 'phone'>): Promise<ILivechatContact | null> {
const emails = visitor.visitorEmails?.map(({ address }) => address).filter((email) => Boolean(email)) || [];
const phoneNumbers = visitor.phone?.map(({ phoneNumber }) => phoneNumber).filter((phone) => Boolean(phone)) || [];
if (!emails?.length && !phoneNumbers?.length) {
return null;
}
const query = {
$and: [
{
$or: [
...emails?.map((email) => ({ 'emails.address': email })),
...phoneNumbers?.map((phone) => ({ 'phones.phoneNumber': phone })),
],
},
{
preRegistration: true,
},
],
};
return this.findOne(query);
}
async findContactByEmailAndContactManager(email: string): Promise<Pick<ILivechatContact, 'contactManager'> | null> {
return this.findOne(
{ emails: { $elemMatch: { address: email } }, contactManager: { $exists: true } },
{ projection: { contactManager: 1 } },
);
}
private makeQueryForVisitor(
visitor: ILivechatContactVisitorAssociation,
extraFilters?: Filter<Required<ILivechatContact>['channels'][number]>,
): Filter<ILivechatContact> {
return {
channels: {
$elemMatch: {
'visitor.visitorId': visitor.visitorId,
'visitor.source.type': visitor.source.type,
...(visitor.source.id ? { 'visitor.source.id': visitor.source.id } : {}),
...extraFilters,
},
},
};
}
async findOneByVisitor<T extends Document = ILivechatContact, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
visitor: ILivechatContactVisitorAssociation,
options?: O,
): Promise<DocumentWithProjection<T, O> | null> {
return this.findOne<T, O>(this.makeQueryForVisitor(visitor), options);
}
async addChannel(contactId: string, channel: ILivechatContactChannel): Promise<void> {
await this.updateOne({ _id: contactId, enabled: { $ne: false } }, { $push: { channels: channel }, $set: { preRegistration: false } });
}
async updateLastChatById(
contactId: string,
visitor: ILivechatContactVisitorAssociation,
lastChat: ILivechatContact['lastChat'],
): Promise<UpdateResult> {
return this.updateOne(
{
...this.makeQueryForVisitor(visitor),
_id: contactId,
},
{ $set: { lastChat, 'channels.$.lastChat': lastChat } },
);
}
async isChannelBlocked(visitor: ILivechatContactVisitorAssociation): Promise<boolean> {
return Boolean(await this.findOne(this.makeQueryForVisitor(visitor, { blocked: true }), { projection: { _id: 1 } }));
}
setChannelBlockStatus(visitor: ILivechatContactVisitorAssociation, blocked: boolean): Promise<UpdateResult> {
return this.updateOne(this.makeQueryForVisitor(visitor), { $set: { 'channels.$.blocked': blocked } });
}
setChannelVerifiedStatus(visitor: ILivechatContactVisitorAssociation, verified: boolean): Promise<UpdateResult> {
return this.updateOne(this.makeQueryForVisitor(visitor), {
$set: {
'channels.$.verified': verified,
...(verified && { 'channels.$.verifiedAt': new Date() }),
},
});
}
setVerifiedUpdateQuery(verified: boolean, contactUpdater: Updater<ILivechatContact>): Updater<ILivechatContact> {
if (verified) {
contactUpdater.set('channels.$.verifiedAt', new Date());
}
return contactUpdater.set('channels.$.verified', verified);
}
setFieldAndValueUpdateQuery(field: string, value: string, contactUpdater: Updater<ILivechatContact>): Updater<ILivechatContact> {
contactUpdater.set('channels.$.field', field);
return contactUpdater.set('channels.$.value', value);
}
updateFromUpdaterByAssociation(
visitor: ILivechatContactVisitorAssociation,
contactUpdater: Updater<ILivechatContact>,
options: UpdateOptions = {},
): Promise<UpdateResult> {
return this.updateFromUpdater(this.makeQueryForVisitor(visitor), contactUpdater, options);
}
async findSimilarVerifiedContacts<
T extends Document = ILivechatContact,
O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>,
>(
{ field, value }: Pick<ILivechatContactChannel, 'field' | 'value'>,
originalContactId: string,
options?: O,
): Promise<DocumentWithProjection<T, O>[]> {
return this.find<T, O>(
{
channels: {
$elemMatch: {
field,
value,
verified: true,
},
},
_id: { $ne: originalContactId },
},
options,
).toArray();
}
findAllByVisitorId(visitorId: string): FindCursor<ILivechatContact> {
return this.find({
'channels.visitor.visitorId': visitorId,
});
}
async findOneEnabledById<P extends Document = ILivechatContact, O extends FindOptionsWithProjection<P> = FindOptionsWithProjection<P>>(
_id: ILivechatContact['_id'],
options?: O,
): Promise<DocumentWithProjection<P, O> | null> {
return this.findOne<P, O>({ _id, enabled: { $ne: false } }, options);
}
disableByVisitorId(visitorId: string): Promise<UpdateResult | Document> {
return this.updateOne(
{ 'channels.visitor.visitorId': visitorId },
{
$set: { enabled: false },
$unset: {
emails: 1,
customFields: 1,
lastChat: 1,
channels: 1,
name: 1,
phones: 1,
},
},
);
}
disableByContactId(contactId: string): Promise<UpdateResult> {
return this.updateOne(
{ _id: contactId },
{
$set: { enabled: false },
$unset: {
emails: 1,
customFields: 1,
lastChat: 1,
channels: 1,
name: 1,
phones: 1,
conflictingFields: 1,
},
},
);
}
async addEmail(contactId: string, email: string): Promise<ILivechatContact | null> {
const updatedContact = await this.findOneAndUpdate({ _id: contactId }, { $addToSet: { emails: { address: email } } });
return updatedContact;
}
isContactActiveOnPeriod(visitor: ILivechatContactVisitorAssociation, period: string): Promise<number> {
const query = {
...this.makeQueryForVisitor(visitor),
activity: period,
};
return this.countDocuments(query);
}
markContactActiveForPeriod(visitor: ILivechatContactVisitorAssociation, period: string): Promise<UpdateResult> {
const update = {
$push: {
activity: {
$each: [period],
$slice: -12,
},
},
};
return this.updateOne(this.makeQueryForVisitor(visitor), update);
}
countContactsOnPeriod(period: string): Promise<number> {
return this.countDocuments({
activity: period,
});
}
countByContactInfo({ contactId, email, phone }: { contactId?: string; email?: string; phone?: string }): Promise<number> {
const filter = {
...(email && { 'emails.address': email }),
...(phone && { 'phones.phoneNumber': phone }),
...(contactId && { _id: contactId }),
};
return this.countDocuments(filter);
}
countUnknown(): Promise<number> {
return this.countDocuments({ unknown: true }, { readPreference: readSecondaryPreferred() });
}
countBlocked(): Promise<number> {
return this.countDocuments({ 'channels.blocked': true }, { readPreference: readSecondaryPreferred() });
}
countFullyBlocked(): Promise<number> {
return this.countDocuments(
{
'channels.blocked': true,
'channels': { $not: { $elemMatch: { $or: [{ blocked: false }, { blocked: { $exists: false } }] } } },
},
{ readPreference: readSecondaryPreferred() },
);
}
countVerified(): Promise<number> {
return this.countDocuments({ 'channels.verified': true }, { readPreference: readSecondaryPreferred() });
}
countContactsWithoutChannels(): Promise<number> {
return this.countDocuments({ channels: { $size: 0 } }, { readPreference: readSecondaryPreferred() });
}
getStatistics(): AggregationCursor<{ totalConflicts: number; avgChannelsPerContact: number }> {
return this.col.aggregate<{ totalConflicts: number; avgChannelsPerContact: number }>(
[
{
$group: {
_id: null,
totalConflicts: {
$sum: { $size: { $cond: [{ $isArray: '$conflictingFields' }, '$conflictingFields', []] } },
},
avgChannelsPerContact: {
$avg: { $size: { $cond: [{ $isArray: '$channels' }, '$channels', []] } },
},
},
},
],
{ allowDiskUse: true, readPreference: readSecondaryPreferred() },
);
}
}

View File

@@ -0,0 +1,78 @@
import type { ILivechatCustomField, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { ILivechatCustomFieldModel, DocumentWithProjection, FindOptionsWithProjection } from '@rocket.chat/model-typings';
import type { Db, Collection, IndexDescription, FindCursor, Document } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class LivechatCustomFieldRaw extends BaseRaw<ILivechatCustomField> implements ILivechatCustomFieldModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<ILivechatCustomField>>) {
super(db, 'livechat_custom_field', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [{ key: { scope: 1 } }];
}
findByScope<T extends Document = ILivechatCustomField, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
scope: ILivechatCustomField['scope'],
options?: O,
includeHidden = true,
): FindCursor<DocumentWithProjection<T, O>> {
return this.find<T, O>({ scope, ...(includeHidden === true ? {} : { visibility: { $ne: 'hidden' } }) }, options);
}
findMatchingCustomFields<
T extends Document = ILivechatCustomField,
O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>,
>(scope: ILivechatCustomField['scope'], searchable = true, options?: O): FindCursor<DocumentWithProjection<T, O>> {
const query = {
scope,
searchable,
};
return this.find<T, O>(query, options);
}
findMatchingCustomFieldsByIds<
T extends Document = ILivechatCustomField,
O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>,
>(
ids: ILivechatCustomField['_id'][],
scope: ILivechatCustomField['scope'],
searchable = true,
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
const query = {
_id: { $in: ids },
scope,
searchable,
};
return this.find<T, O>(query, options);
}
async createOrUpdateCustomField(
_id: string | null,
field: string,
label: ILivechatCustomField['label'],
scope: ILivechatCustomField['scope'],
visibility: ILivechatCustomField['visibility'],
extraData: any,
) {
const record = {
label,
scope,
visibility,
...extraData,
};
if (_id) {
await this.updateOne({ _id }, { $set: record });
} else {
record._id = field;
await this.insertOne(record);
}
return record;
}
}

View File

@@ -0,0 +1,440 @@
import type { ILivechatDepartment, LivechatDepartmentDTO, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { ILivechatDepartmentModel, DocumentWithProjection, FindOptionsWithProjection } from '@rocket.chat/model-typings';
import { escapeRegExp } from '@rocket.chat/tools';
import type { Collection, FindCursor, Db, Filter, FindOptions, UpdateResult, Document, IndexDescription, AggregationCursor } from 'mongodb';
import { LivechatDepartmentAgents, LivechatUnitMonitors } from '../index';
import { BaseRaw } from './BaseRaw';
export class LivechatDepartmentRaw extends BaseRaw<ILivechatDepartment> implements ILivechatDepartmentModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<ILivechatDepartment>>) {
super(db, 'livechat_department', trash);
}
removeParentAndAncestorById(_id: string): Promise<Document | UpdateResult> {
throw new Error('Method not implemented.');
}
protected override modelIndexes(): Array<IndexDescription> {
return [
{
key: {
name: 1,
},
},
{
key: {
businessHourId: 1,
},
sparse: true,
},
{
key: {
type: 1,
},
sparse: true,
},
{
key: {
numAgents: 1,
enabled: 1,
},
},
{
key: {
parentId: 1,
},
sparse: true,
},
{
key: {
ancestors: 1,
},
sparse: true,
},
{
key: {
archived: 1,
},
sparse: true,
},
];
}
countTotal(): Promise<number> {
return this.estimatedDocumentCount();
}
findInIds<T extends Document = ILivechatDepartment, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
departmentsIds: string[],
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
const query = { _id: { $in: departmentsIds } };
return this.find<T, O>(query, options);
}
findByNameRegexWithExceptionsAndConditions<
T extends Document = ILivechatDepartment,
O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>,
>(
searchTerm: string,
exceptions: string[] = [],
conditions: Filter<ILivechatDepartment> = {},
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
if (!Array.isArray(exceptions)) {
exceptions = [exceptions];
}
const nameRegex = new RegExp(`^${escapeRegExp(searchTerm).trim()}`, 'i');
const query = {
name: nameRegex,
_id: {
$nin: exceptions,
},
...conditions,
};
return this.find<T, O>(query, options);
}
findByBusinessHourId<T extends Document = ILivechatDepartment, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
businessHourId: string,
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
const query = { businessHourId };
return this.find<T, O>(query, options);
}
countByBusinessHourIdExcludingDepartmentId(businessHourId: string, departmentId: string): Promise<number> {
const query = { businessHourId, _id: { $ne: departmentId } };
return this.countDocuments(query);
}
findEnabledByBusinessHourId<
T extends Document = ILivechatDepartment,
O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>,
>(businessHourId: string, options?: O): FindCursor<DocumentWithProjection<T, O>> {
const query = { businessHourId, enabled: true };
return this.find<T, O>(query, options);
}
findActiveDepartmentsWithoutBusinessHour<
T extends Document = ILivechatDepartment,
O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>,
>(options?: O): FindCursor<DocumentWithProjection<T, O>> {
const query = {
enabled: true,
businessHourId: { $exists: false },
};
return this.find<T, O>(query, options);
}
findEnabledInIds<T extends Document = ILivechatDepartment, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
departmentsIds: string[],
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
const query = { _id: { $in: departmentsIds }, enabled: true };
return this.find<T, O>(query, options);
}
addBusinessHourToDepartmentsByIds(ids: string[] = [], businessHourId: string): Promise<Document | UpdateResult> {
const query = {
_id: { $in: ids },
};
const update = {
$set: {
businessHourId,
},
};
return this.updateMany(query, update);
}
removeBusinessHourFromDepartmentsByIdsAndBusinessHourId(ids: string[] = [], businessHourId: string): Promise<Document | UpdateResult> {
const query = {
_id: { $in: ids },
businessHourId,
};
const update = {
$unset: {
businessHourId: 1,
},
};
return this.updateMany(query, update);
}
removeBusinessHourFromDepartmentsByBusinessHourId(businessHourId: string): Promise<Document | UpdateResult> {
const query = {
businessHourId,
};
const update = {
$unset: {
businessHourId: 1,
},
};
return this.updateMany(query, update);
}
unarchiveDepartment(_id: string): Promise<UpdateResult> {
return this.updateOne({ _id }, { $set: { archived: false } });
}
archiveDepartment(_id: string): Promise<UpdateResult> {
return this.updateOne({ _id }, { $set: { archived: true, enabled: false } });
}
addDepartmentToUnit(_id: string, unitId: string, ancestors: string[]): Promise<Document | UpdateResult> {
return this.updateOne({ _id }, { $set: { parentId: unitId, ancestors } });
}
removeDepartmentFromUnit(_id: string): Promise<Document | UpdateResult> {
return this.updateOne({ _id }, { $unset: { parentId: 1, ancestors: 1 } });
}
async createOrUpdateDepartment(_id: string | null, data: LivechatDepartmentDTO & { type?: string }): Promise<ILivechatDepartment> {
const current = _id ? await this.findOneById(_id) : null;
const record = {
...data,
} as ILivechatDepartment;
if (_id) {
await this.updateOne({ _id }, { $set: record });
} else {
_id = (await this.insertOne(record)).insertedId;
}
if (current?.enabled !== data.enabled) {
await LivechatDepartmentAgents.setDepartmentEnabledByDepartmentId(_id, data.enabled);
}
const latestDept = await this.findOneById(_id);
if (!latestDept) {
throw new Error(`Department ${_id} not found`);
}
return latestDept;
}
unsetFallbackDepartmentByDepartmentId(departmentId: string): Promise<Document | UpdateResult> {
return this.updateMany({ fallbackDepartment: departmentId }, { $unset: { fallbackDepartment: 1 } });
}
removeDepartmentFromForwardListById(_departmentId: string): Promise<void> {
throw new Error('Method not implemented in Community Edition.');
}
updateById(_id: string, update: Partial<ILivechatDepartment>): Promise<Document | UpdateResult> {
return this.updateOne({ _id }, update);
}
updateNumAgentsById(_id: string, numAgents: number): Promise<Document | UpdateResult> {
return this.updateOne({ _id }, { $set: { numAgents } });
}
decreaseNumberOfAgentsByIds(_ids: string[]): Promise<Document | UpdateResult> {
return this.updateMany({ _id: { $in: _ids } }, { $inc: { numAgents: -1 } });
}
findEnabledWithAgents<T extends Document = ILivechatDepartment>(projection: FindOptions<T>['projection'] = {}): FindCursor<T> {
const query = {
numAgents: { $gt: 0 },
enabled: true,
};
return this.find<T>(query, projection && { projection });
}
findEnabledWithAgentsAndRegistration<T extends Document = ILivechatDepartment>(
projection: FindOptions<T>['projection'] = {},
): FindCursor<T> {
const query = {
numAgents: { $gt: 0 },
enabled: true,
showOnRegistration: true,
};
return this.find<T>(query, projection && { projection });
}
findOneEnabledWithAgentsAndRegistration<T extends Document = ILivechatDepartment>(
projection: FindOptions<T>['projection'] = {},
): Promise<T | null> {
const query = {
numAgents: { $gt: 0 },
enabled: true,
showOnRegistration: true,
};
return this.findOne<T>(query, projection && { projection });
}
findEnabledWithAgentsAndBusinessUnit<T extends Document = ILivechatDepartment>(
_: any,
projection?: FindOptions<T>['projection'],
): FindCursor<T> {
return this.findEnabledWithAgents(projection);
}
findOneByIdOrName<T extends Document = ILivechatDepartment, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
_idOrName: string,
options?: O,
): Promise<DocumentWithProjection<T, O> | null> {
const query = {
$or: [
{
_id: _idOrName,
},
{
name: _idOrName,
},
],
};
return this.findOne<T, O>(query, options);
}
findByUnitIds<T extends Document = ILivechatDepartment, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
unitIds: string[],
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
const query = {
parentId: {
$exists: true,
$in: unitIds,
},
};
return this.find<T, O>(query, options);
}
countDepartmentsInUnit(unitId: string): Promise<number> {
return this.countDocuments({ parentId: unitId });
}
findActiveByUnitIds<T extends Document = ILivechatDepartment, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
_unitIds: string[],
_options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
throw new Error('not-implemented');
}
findNotArchived<T extends Document = ILivechatDepartment, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
const query = { archived: { $ne: false } };
return this.find<T, O>(query, options);
}
getBusinessHoursWithDepartmentStatuses(): Promise<
{
_id: string;
validDepartments: string[];
invalidDepartments: string[];
}[]
> {
return this.col
.aggregate<{ _id: string; validDepartments: string[]; invalidDepartments: string[] }>([
{
$match: {
businessHourId: {
$exists: true,
},
},
},
{
$group: {
_id: '$businessHourId',
validDepartments: {
$push: {
$cond: {
if: {
$or: [
{
$eq: ['$enabled', true],
},
{
$ne: ['$archived', true],
},
],
},
then: '$_id',
else: '$$REMOVE',
},
},
},
invalidDepartments: {
$push: {
$cond: {
if: {
$or: [{ $eq: ['$enabled', false] }, { $eq: ['$archived', true] }],
},
then: '$_id',
else: '$$REMOVE',
},
},
},
},
},
])
.toArray();
}
checkIfMonitorIsMonitoringDepartmentById(monitorId: string, departmentId: string): Promise<boolean> {
const aggregation = [
{
$match: {
enabled: true,
_id: departmentId,
},
},
{
$lookup: {
from: LivechatUnitMonitors.getCollectionName(),
localField: 'parentId',
foreignField: 'unitId',
as: 'monitors',
pipeline: [
{
$match: {
monitorId,
},
},
],
},
},
{
$match: {
monitors: {
$exists: true,
$ne: [],
},
},
},
{
$project: {
_id: 1,
},
},
];
return this.col.aggregate(aggregation).hasNext();
}
countArchived(): Promise<number> {
return this.countDocuments({ archived: true });
}
findByParentId(_parentId: string, _options?: FindOptions<ILivechatDepartment>): FindCursor<ILivechatDepartment> {
throw new Error('Method not implemented in CE');
}
findAgentsByBusinessHourId(_businessHourId: string): AggregationCursor<{ agentIds: string[] }> {
throw new Error('Method not implemented in CE');
}
}

View File

@@ -0,0 +1,320 @@
import type { AvailableAgentsAggregation, ILivechatDepartmentAgents, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type {
FindPaginated,
ILivechatDepartmentAgentsModel,
DocumentWithProjection,
FindOptionsWithProjection,
} from '@rocket.chat/model-typings';
import type {
Collection,
FindCursor,
Db,
Filter,
Document,
UpdateResult,
DeleteResult,
IndexDescription,
SortDirection,
AggregationCursor,
} from 'mongodb';
import { Users } from '../index';
import { BaseRaw } from './BaseRaw';
export class LivechatDepartmentAgentsRaw extends BaseRaw<ILivechatDepartmentAgents> implements ILivechatDepartmentAgentsModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<ILivechatDepartmentAgents>>) {
super(db, 'livechat_department_agents', trash);
}
protected override modelIndexes(): Array<IndexDescription> {
return [
{
key: {
departmentId: 1,
},
},
{
key: {
departmentEnabled: 1,
},
},
{
key: {
agentId: 1,
},
},
{
key: {
username: 1,
},
},
];
}
findByAgentIds<T extends Document = ILivechatDepartmentAgents, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
agentIds: string[],
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
return this.find<T, O>({ agentId: { $in: agentIds } }, options);
}
findByAgentId<T extends Document = ILivechatDepartmentAgents, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
agentId: string,
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
return this.find<T, O>({ agentId }, options);
}
findAgentsByDepartmentId<
P extends Document = ILivechatDepartmentAgents,
O extends FindOptionsWithProjection<P> = FindOptionsWithProjection<P>,
>(departmentId: string, options?: O): FindPaginated<FindCursor<DocumentWithProjection<P, O>>> {
const query = { departmentId };
if (options === undefined) {
return this.findPaginated<P, O>(query);
}
return this.findPaginated<P, O>(query, options);
}
findByDepartmentIds(departmentIds: string[], options = {}): FindCursor<ILivechatDepartmentAgents> {
return this.find({ departmentId: { $in: departmentIds } }, options);
}
setDepartmentEnabledByDepartmentId(departmentId: string, departmentEnabled: boolean): Promise<Document | UpdateResult> {
return this.updateMany({ departmentId }, { $set: { departmentEnabled } });
}
removeByDepartmentId(departmentId: string): Promise<DeleteResult> {
return this.deleteMany({ departmentId });
}
findByDepartmentId<T extends Document = ILivechatDepartmentAgents, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
departmentId: string,
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
return this.find<T, O>({ departmentId }, options);
}
findOneByAgentIdAndDepartmentId<
T extends Document = ILivechatDepartmentAgents,
O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>,
>(agentId: string, departmentId: string, options?: O): Promise<DocumentWithProjection<T, O> | null> {
return this.findOne<T, O>({ agentId, departmentId }, options);
}
saveAgent(agent: {
agentId: string;
departmentId: string;
username: string;
departmentEnabled: boolean;
count: number;
order: number;
}): Promise<UpdateResult> {
return this.updateOne(
{
agentId: agent.agentId,
departmentId: agent.departmentId,
},
{
$set: {
username: agent.username,
departmentEnabled: agent.departmentEnabled,
count: parseInt(`${agent.count}`),
order: parseInt(`${agent.order}`),
},
},
{ upsert: true },
);
}
async removeByAgentId(agentId: string): Promise<DeleteResult> {
return this.deleteMany({ agentId });
}
async getNextAgentForDepartment(
departmentId: ILivechatDepartmentAgents['departmentId'],
isLivechatEnabledWhenAgentIdle?: boolean,
ignoreAgentId?: ILivechatDepartmentAgents['agentId'],
extraQuery?: Filter<AvailableAgentsAggregation>,
acceptChatsWithNoAgents?: boolean,
): Promise<Pick<ILivechatDepartmentAgents, '_id' | 'agentId' | 'departmentId' | 'username'> | null | undefined> {
const agents = await this.findByDepartmentId(departmentId).toArray();
if (agents.length === 0) {
return;
}
const onlineUsers = await Users.findOnlineUserFromList(
agents.map((agent) => agent.username),
isLivechatEnabledWhenAgentIdle,
acceptChatsWithNoAgents,
).toArray();
const onlineUsernames = onlineUsers.map((user) => user.username).filter(isStringValue);
// get fully booked agents, to ignore them from the query
const currentUnavailableAgents = (
await Users.getUnavailableAgents(departmentId, extraQuery, isLivechatEnabledWhenAgentIdle, acceptChatsWithNoAgents)
).map((u) => u.username);
const query: Filter<ILivechatDepartmentAgents> = {
departmentId,
username: {
$in: onlineUsernames,
$nin: currentUnavailableAgents,
},
...(ignoreAgentId && { agentId: { $ne: ignoreAgentId } }),
};
const update = {
$inc: {
count: 1,
},
};
const sort: { [k: string]: SortDirection } = {
count: 1,
order: 1,
username: 1,
};
const projection = {
_id: 1,
agentId: 1,
departmentId: 1,
username: 1,
} as const;
return this.findOneAndUpdate(query, update, { sort, projection, returnDocument: 'after' });
}
async countBotsForDepartment(departmentId: string): Promise<number> {
const agents = await this.findByDepartmentId(departmentId, { projection: { username: 1 } }).toArray();
if (agents.length === 0) {
return 0;
}
return Users.countBotAgents(agents.map((a) => a.username));
}
async getNextBotForDepartment(
departmentId: ILivechatDepartmentAgents['departmentId'],
ignoreAgentId?: ILivechatDepartmentAgents['agentId'],
): Promise<Pick<ILivechatDepartmentAgents, '_id' | 'agentId' | 'departmentId' | 'username'> | null | undefined> {
const agents = await this.findByDepartmentId(departmentId).toArray();
if (!agents.length) {
return;
}
const botUsernames = (await Users.findBotAgents(agents.map((a) => a.username)).toArray())
.map((user) => user.username)
.filter(isStringValue);
const query = {
departmentId,
username: {
$in: botUsernames,
},
...(ignoreAgentId && { agentId: { $ne: ignoreAgentId } }),
};
const update = {
$inc: {
count: 1,
},
};
const sort: { [k: string]: SortDirection } = {
count: 1,
order: 1,
username: 1,
};
const projection = {
_id: 1,
agentId: 1,
departmentId: 1,
username: 1,
} as const;
return this.findOneAndUpdate(query, update, { sort, projection, returnDocument: 'after' });
}
replaceUsernameOfAgentByUserId(userId: string, username: string): Promise<UpdateResult | Document> {
const query = { agentId: userId };
const update = {
$set: {
username,
},
};
return this.updateMany(query, update);
}
countByDepartmentId(departmentId: string): Promise<number> {
return this.countDocuments({ departmentId });
}
disableAgentsByDepartmentId(departmentId: string): Promise<UpdateResult | Document> {
return this.updateMany({ departmentId }, { $set: { departmentEnabled: false } });
}
enableAgentsByDepartmentId(departmentId: string): Promise<UpdateResult | Document> {
return this.updateMany({ departmentId }, { $set: { departmentEnabled: true } });
}
findAllAgentsConnectedToListOfDepartments(departmentIds: string[]): Promise<string[]> {
return this.col.distinct('agentId', { departmentId: { $in: departmentIds }, departmentEnabled: true });
}
findByAgentsAndDepartmentId<
T extends Document = ILivechatDepartmentAgents,
O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>,
>(
agentsIds: ILivechatDepartmentAgents['agentId'][],
departmentId: ILivechatDepartmentAgents['departmentId'],
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
return this.find<T, O>({ agentId: { $in: agentsIds }, departmentId }, options);
}
findDepartmentsOfAgent(agentId: string, enabled = false): AggregationCursor<ILivechatDepartmentAgents & { departmentName: string }> {
return this.col.aggregate<ILivechatDepartmentAgents & { departmentName: string }>([
{
$match: {
agentId,
...(enabled && { departmentEnabled: true }),
},
},
{
$lookup: {
from: 'rocketchat_livechat_department',
localField: 'departmentId',
foreignField: '_id',
as: 'department',
},
},
{ $unwind: '$department' },
{
$project: {
_id: '$_id',
agentId: '$agentId',
departmentId: '$departmentId',
departmentName: '$department.name',
username: '$username',
count: '$count',
order: '$order',
departmentEnabled: '$departmentEnabled',
_updatedAt: '$_updatedAt',
},
},
]);
}
}
const isStringValue = (value: any): value is string => typeof value === 'string';

View File

@@ -0,0 +1,471 @@
import type {
ILivechatInquiryRecord,
IMessage,
RocketChatRecordDeleted,
ILivechatPriority,
SelectedAgent,
} from '@rocket.chat/core-typings';
import { LivechatInquiryStatus } from '@rocket.chat/core-typings';
import type { ILivechatInquiryModel, DocumentWithProjection, FindOptionsWithProjection } from '@rocket.chat/model-typings';
import type {
Collection,
Db,
Document,
FindOptions,
UpdateResult,
Filter,
DeleteResult,
IndexDescription,
FindCursor,
UpdateFilter,
DeleteOptions,
AggregateOptions,
WithId,
} from 'mongodb';
import { BaseRaw } from './BaseRaw';
import { readSecondaryPreferred } from '../readSecondaryPreferred';
const { INQUIRY_LOCK_TIMEOUT = '10000' } = process.env;
export class LivechatInquiryRaw extends BaseRaw<ILivechatInquiryRecord> implements ILivechatInquiryModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<ILivechatInquiryRecord>>) {
super(db, 'livechat_inquiry', trash);
}
protected override modelIndexes(): Array<IndexDescription> {
return [
{
key: {
rid: 1,
},
},
{
key: {
name: 1,
},
},
{
key: {
message: 1,
},
},
{
key: {
ts: 1,
},
},
{
key: {
department: 1,
},
},
{
key: {
status: 1,
},
},
{
key: {
priorityId: 1,
priorityWeight: 1,
},
sparse: true,
},
{
key: {
priorityWeight: 1,
ts: 1,
},
partialFilterExpression: {
status: { $eq: LivechatInquiryStatus.QUEUED },
},
},
{
key: {
estimatedWaitingTimeQueue: 1,
ts: 1,
},
partialFilterExpression: {
status: { $eq: LivechatInquiryStatus.QUEUED },
},
},
{
key: {
'v.token': 1,
'status': 1,
},
},
{
key: {
locked: 1,
lockedAt: 1,
},
sparse: true,
},
{ key: { 'v._id': 1 } },
];
}
findOneByRoomId<T extends Document = ILivechatInquiryRecord, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
rid: string,
options?: O,
): Promise<DocumentWithProjection<T, O> | null> {
const query = {
rid,
};
return this.findOne<T, O>(query, options);
}
getDistinctQueuedDepartments(options: AggregateOptions): Promise<{ _id: string | null }[]> {
return this.col
.aggregate<{ _id: string | null }>(
[
{ $match: { status: LivechatInquiryStatus.QUEUED } },
{
$group: {
_id: '$department',
},
},
],
options,
)
.toArray();
}
async setDepartmentByInquiryId(inquiryId: string, department: string): Promise<ILivechatInquiryRecord | null> {
return this.findOneAndUpdate({ _id: inquiryId }, { $set: { department } }, { returnDocument: 'after' });
}
/**
* Updates the `lastMessage` of inquiries that are not taken yet, after they're taken we only need to update room's `lastMessage`
*/
async setLastMessageByRoomId(rid: ILivechatInquiryRecord['rid'], message: IMessage): Promise<ILivechatInquiryRecord | null> {
return this.findOneAndUpdate(
{ rid, status: { $ne: LivechatInquiryStatus.TAKEN } },
{ $set: { lastMessage: message } },
{ returnDocument: 'after' },
);
}
async setLastMessageById(inquiryId: string, lastMessage: IMessage): Promise<UpdateResult> {
return this.updateOne({ _id: inquiryId }, { $set: { lastMessage } });
}
async findNextAndLock(
queueSortBy: FindOptions<ILivechatInquiryRecord>['sort'],
department: string | null,
): Promise<ILivechatInquiryRecord | null> {
const date = new Date();
return this.findOneAndUpdate(
{
status: LivechatInquiryStatus.QUEUED,
...(department ? { department } : { department: { $exists: false } }),
$or: [
{
locked: true,
lockedAt: {
$lte: new Date(date.getTime() - parseInt(INQUIRY_LOCK_TIMEOUT)),
},
},
{
locked: { $ne: true },
},
],
},
{
$set: {
locked: true,
// apply INQUIRY_LOCK_TIMEOUT secs lock lifetime
lockedAt: new Date(),
},
},
{
sort: queueSortBy,
},
);
}
async unlock(inquiryId: string): Promise<UpdateResult> {
return this.updateOne({ _id: inquiryId }, { $unset: { locked: 1, lockedAt: 1 } });
}
async unlockAll(): Promise<UpdateResult | Document> {
return this.updateMany(
{ locked: { $exists: true } },
{ $unset: { locked: 1, lockedAt: 1 }, $set: { status: LivechatInquiryStatus.QUEUED, queuedAt: new Date() } },
);
}
async getCurrentSortedQueueAsync({
inquiryId,
department,
queueSortBy,
}: {
inquiryId?: string;
department?: string;
queueSortBy: FindOptions<ILivechatInquiryRecord>['sort'];
}): Promise<(Pick<ILivechatInquiryRecord, '_id' | 'rid' | 'name' | 'ts' | 'status' | 'department'> & { position: number })[]> {
const filter: Filter<ILivechatInquiryRecord>[] = [
{
$match: {
status: 'queued',
...(department && { department }),
},
},
{ $sort: queueSortBy },
{
$group: {
_id: 1,
inquiry: {
$push: {
_id: '$_id',
rid: '$rid',
name: '$name',
ts: '$ts',
status: '$status',
department: '$department',
},
},
},
},
{
$unwind: {
path: '$inquiry',
includeArrayIndex: 'position',
},
},
{
$project: {
_id: '$inquiry._id',
rid: '$inquiry.rid',
name: '$inquiry.name',
ts: '$inquiry.ts',
status: '$inquiry.status',
department: '$inquiry.department',
position: 1,
},
},
];
// To get the current room position in the queue, we need to apply the next $match after the $project
if (inquiryId) {
filter.push({ $match: { _id: inquiryId } });
}
return this.col
.aggregate<Pick<ILivechatInquiryRecord, '_id' | 'rid' | 'name' | 'ts' | 'status' | 'department'> & { position: number }>(filter, {
readPreference: readSecondaryPreferred(),
})
.toArray();
}
setSlaForRoom(_rid: string, _data: { estimatedWaitingTimeQueue: number; slaId: string }): Promise<null | WithId<ILivechatInquiryRecord>> {
throw new Error('Method not implemented on the community edition.');
}
unsetSlaForRoom(_roomId: string): Promise<null | WithId<ILivechatInquiryRecord>> {
throw new Error('Method not implemented on the community edition.');
}
bulkUnsetSla(_roomIds: string[]): Promise<Document | UpdateResult> {
throw new Error('Method not implemented on the community edition.');
}
setPriorityForRoom(_rid: string, _priority: Pick<ILivechatPriority, '_id' | 'sortItem'>): Promise<null | WithId<ILivechatInquiryRecord>> {
throw new Error('Method not implemented on the community edition.');
}
unsetPriorityForRoom(_rid: string): Promise<null | WithId<ILivechatInquiryRecord>> {
throw new Error('Method not implemented on the community edition.');
}
async removeByRoomId(rid: string, options?: DeleteOptions): Promise<DeleteResult> {
return this.deleteOne({ rid }, options);
}
getQueuedInquiries<T extends Document = ILivechatInquiryRecord, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
return this.find<T, O>({ status: LivechatInquiryStatus.QUEUED }, options);
}
takeInquiry(inquiryId: string, lockedAt?: Date): Promise<UpdateResult> {
return this.updateOne(
{
_id: inquiryId,
...(lockedAt && { lockedAt }),
},
{
$set: { status: LivechatInquiryStatus.TAKEN, takenAt: new Date() },
$unset: { defaultAgent: 1, estimatedInactivityCloseTimeAt: 1, queuedAt: 1 },
},
);
}
async queueInquiry(
inquiryId: string,
lastMessage?: IMessage,
defaultAgent?: SelectedAgent | null,
): Promise<ILivechatInquiryRecord | null> {
return this.findOneAndUpdate(
{
_id: inquiryId,
},
{
$set: {
status: LivechatInquiryStatus.QUEUED,
queuedAt: new Date(),
...(lastMessage && { lastMessage }),
...(defaultAgent && { defaultAgent }),
},
$unset: { takenAt: 1 },
},
{ returnDocument: 'after' },
);
}
queueInquiryAndRemoveDefaultAgent(inquiryId: string): Promise<UpdateResult> {
return this.updateOne(
{
_id: inquiryId,
},
{
$set: { status: LivechatInquiryStatus.QUEUED, queuedAt: new Date() },
$unset: { takenAt: 1, defaultAgent: 1 },
},
);
}
readyInquiry(inquiryId: string): Promise<UpdateResult> {
return this.updateOne(
{
_id: inquiryId,
},
{
$set: {
status: LivechatInquiryStatus.READY,
},
},
);
}
async changeDepartmentIdByRoomId(rid: string, department: string): Promise<UpdateResult> {
const query = {
rid,
};
const updateObj = {
$set: {
department,
},
};
return this.updateOne(query, updateObj);
}
async getStatus(inquiryId: string): Promise<ILivechatInquiryRecord['status'] | undefined> {
return (await this.findOne({ _id: inquiryId }))?.status;
}
updateVisitorStatus(token: string, status: ILivechatInquiryRecord['v']['status']): Promise<UpdateResult> {
const query: Filter<ILivechatInquiryRecord> = {
'v.token': token,
'status': LivechatInquiryStatus.QUEUED,
};
const update: UpdateFilter<ILivechatInquiryRecord> = {
$set: {
'v.status': status,
},
};
return this.updateOne(query, update);
}
setDefaultAgentById(inquiryId: string, defaultAgent: ILivechatInquiryRecord['defaultAgent']): Promise<UpdateResult> {
return this.updateOne(
{
_id: inquiryId,
},
{
$set: {
defaultAgent,
},
},
);
}
async setStatusById(inquiryId: string, status: LivechatInquiryStatus): Promise<ILivechatInquiryRecord> {
const result = await this.findOneAndUpdate(
{ _id: inquiryId },
{ $set: { status } },
{
upsert: true,
returnDocument: 'after',
},
);
if (!result) {
throw new Error('error-failed-to-set-inquiry-status');
}
return result;
}
setNameByRoomId(rid: string, name: string): Promise<UpdateResult> {
const query = { rid };
const update = {
$set: {
name,
},
};
return this.updateOne(query, update);
}
findOneByToken(token: string): Promise<ILivechatInquiryRecord | null> {
const query: Filter<ILivechatInquiryRecord> = {
'v.token': token,
'status': LivechatInquiryStatus.QUEUED,
};
return this.findOne(query);
}
removeDefaultAgentById(inquiryId: string): Promise<UpdateResult | Document> {
return this.updateOne(
{
_id: inquiryId,
},
{
$unset: { defaultAgent: 1 },
},
);
}
async markInquiryActiveForPeriod(rid: ILivechatInquiryRecord['rid'], period: string): Promise<ILivechatInquiryRecord | null> {
// goes through this.col since BaseRaw's setUpdatedAt mishandles pipeline updates
return this.col.findOneAndUpdate(
{ rid },
[{ $set: { 'v.activity': { $setUnion: [{ $ifNull: ['$v.activity', []] }, [period]] }, '_updatedAt': '$$NOW' } }],
{ returnDocument: 'after' },
);
}
updateNameByVisitorIds(visitorIds: string[], name: string): Promise<UpdateResult | Document> {
const query = { 'v._id': { $in: visitorIds } };
const update = {
$set: { name },
};
return this.updateMany(query, update);
}
findByVisitorIds<T extends Document = ILivechatInquiryRecord, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
visitorIds: string[],
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
return this.find<T, O>({ 'v._id': { $in: visitorIds } }, options);
}
}

View File

@@ -0,0 +1,82 @@
import type { ILivechatPriority } from '@rocket.chat/core-typings';
import type { ILivechatPriorityModel } from '@rocket.chat/model-typings';
import { escapeRegExp } from '@rocket.chat/tools';
import type { Db, UpdateFilter, WithId, IndexDescription, FindCursor } from 'mongodb';
import { BaseRaw } from './BaseRaw';
// TODO need to define type for LivechatPriority object
export class LivechatPriorityRaw extends BaseRaw<ILivechatPriority> implements ILivechatPriorityModel {
constructor(db: Db) {
super(db, 'livechat_priority');
}
protected override modelIndexes(): IndexDescription[] {
return [
{
key: {
name: 1,
},
unique: true,
partialFilterExpression: {
$and: [{ name: { $exists: true } }, { name: { $gt: '' } }],
},
},
];
}
findOneByIdOrName(_idOrName: string, options = {}): Promise<ILivechatPriority | null> {
const query = {
$or: [
{
_id: _idOrName,
},
{
name: _idOrName,
},
],
};
return this.findOne(query, options);
}
findOneNameUsingRegex(_idOrName: string, options = {}): Promise<ILivechatPriority | null> {
const query = {
name: new RegExp(`^${escapeRegExp(_idOrName.trim())}$`, 'i'),
};
return this.findOne(query, options);
}
findByDirty(): FindCursor<Pick<ILivechatPriority, '_id'>> {
return this.find({ dirty: true }, { projection: { _id: 1 } });
}
async canResetPriorities(): Promise<boolean> {
return Boolean(await this.findOne({ dirty: true }, { projection: { _id: 1 } }));
}
async resetPriorities(ids: ILivechatPriority['_id'][]): Promise<void> {
await this.updateMany({ _id: { $in: ids } }, [{ $set: { dirty: false } }, { $unset: 'name' }]);
}
async updatePriority(_id: string, reset: boolean, name?: string): Promise<null | WithId<ILivechatPriority>> {
const query = {
_id,
};
const update: Pick<UpdateFilter<ILivechatPriority>, '$set' | '$unset'> = {
...((reset && {
$set: { dirty: false },
$unset: { name: 1 },
}) || {
// Trim value before inserting
$set: { name: name?.trim(), dirty: true },
}),
};
return this.findOneAndUpdate(query, update, {
returnDocument: 'after',
});
}
}

View File

@@ -0,0 +1,34 @@
import type { Db } from 'mongodb';
import { LivechatRoomsRaw } from './LivechatRooms';
import { UpdaterImpl } from '../updater';
// `LivechatRooms` imports `..`, whose barrel pulls in every model and cycles back here.
jest.mock('..', () => ({
getCollectionName: (name: string) => name,
UpdaterImpl: jest.requireActual('../updater').UpdaterImpl,
}));
const db = { collection: () => ({ createIndexes: jest.fn().mockResolvedValue(undefined) }) } as unknown as Db;
describe('getVisitorActiveForPeriodUpdateQuery', () => {
const model = new LivechatRoomsRaw(db);
it('should $addToSet the period when the current activity is an array', () => {
const updater = model.getVisitorActiveForPeriodUpdateQuery('2026-09', new UpdaterImpl(), ['2026-08']);
expect(updater.getRawUpdateFilter()).toEqual({ $addToSet: { 'v.activity': { $each: ['2026-09'] } } });
});
it('should $addToSet the period when the current activity is absent', () => {
const updater = model.getVisitorActiveForPeriodUpdateQuery('2026-09', new UpdaterImpl(), undefined);
expect(updater.getRawUpdateFilter()).toEqual({ $addToSet: { 'v.activity': { $each: ['2026-09'] } } });
});
it('should $set the period when the current activity is null', () => {
const updater = model.getVisitorActiveForPeriodUpdateQuery('2026-09', new UpdaterImpl(), null);
expect(updater.getRawUpdateFilter()).toEqual({ $set: { 'v.activity': ['2026-09'] } });
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
import type { ILivechatTrigger, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { ILivechatTriggerModel } from '@rocket.chat/model-typings';
import type { Collection, FindCursor, Db, IndexDescription, UpdateFilter, UpdateResult } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class LivechatTriggerRaw extends BaseRaw<ILivechatTrigger> implements ILivechatTriggerModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<ILivechatTrigger>>) {
super(db, 'livechat_trigger', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [{ key: { enabled: 1 } }];
}
findEnabled(): FindCursor<ILivechatTrigger> {
return this.find({ enabled: true });
}
updateById(_id: string, data: Omit<ILivechatTrigger, '_id' | '_updatedAt'>): Promise<UpdateResult> {
return this.updateOne({ _id }, { $set: data } as UpdateFilter<ILivechatTrigger>); // TODO: remove this cast when TypeScript is updated
}
}

View File

@@ -0,0 +1,527 @@
import type { IVisitorExternalIdentifier, ILivechatVisitor, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { FindPaginated, ILivechatVisitorsModel, DocumentWithProjection, FindOptionsWithProjection } from '@rocket.chat/model-typings';
import { escapeRegExp } from '@rocket.chat/tools';
import type {
AggregationCursor,
Collection,
FindCursor,
Db,
Document,
Filter,
FindOptions,
UpdateResult,
IndexDescription,
DeleteResult,
UpdateFilter,
WithId,
FindOneAndUpdateOptions,
} from 'mongodb';
import { ObjectId } from 'mongodb';
import { Settings } from '../index';
import { BaseRaw } from './BaseRaw';
export class LivechatVisitorsRaw extends BaseRaw<ILivechatVisitor> implements ILivechatVisitorsModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<ILivechatVisitor>>) {
super(db, 'livechat_visitor', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [
{ key: { token: 1 } },
{ key: { 'phone.phoneNumber': 1 }, sparse: true },
{ key: { 'visitorEmails.address': 1 }, sparse: true },
{ key: { 'externalIds.entityId': 1 }, sparse: true },
{ key: { name: 1 }, sparse: true },
{ key: { username: 1 } },
{ key: { 'contactMananger.username': 1 }, sparse: true },
{ key: { 'livechatData.$**': 1 } },
// TODO: remove this index in the next major release (8.0.0)
// { key: { activity: 1 }, partialFilterExpression: { activity: { $exists: true } } },
{ key: { disabled: 1 }, partialFilterExpression: { disabled: { $exists: true } } },
];
}
findOneVisitorByPhone(phone: string): Promise<ILivechatVisitor | null> {
const query = {
'phone.phoneNumber': phone,
};
return this.findOne(query);
}
async findOneVisitorByPhoneOrEmailAndAddExternalId(
contactData: { phone: string } | { email: string },
appId: string,
externalId: Omit<IVisitorExternalIdentifier, 'appId'>,
): Promise<ILivechatVisitor | null> {
const query =
'phone' in contactData ? { 'phone.phoneNumber': contactData.phone } : { 'visitorEmails.address': contactData.email.toLowerCase() };
const visitor = await this.findOne(query);
if (!visitor) {
return null;
}
const filteredIds = visitor.externalIds?.filter((e) => e.appId !== appId) ?? [];
const newExternalIds = [...filteredIds, { appId, ...externalId }];
await this.updateOne({ _id: visitor._id }, { $set: { externalIds: newExternalIds } });
return { ...visitor, externalIds: newExternalIds };
}
findOneByExternalId(entityId: string): Promise<ILivechatVisitor | null> {
return this.findOne({
'externalIds.entityId': entityId,
});
}
async updateExternalIdById(
_id: string,
appId: string,
externalId: Omit<IVisitorExternalIdentifier, 'appId'>,
): Promise<ILivechatVisitor | null> {
const visitor = await this.findOne({ _id });
if (!visitor) {
return null;
}
const filteredIds = visitor.externalIds?.filter((e) => e.appId !== appId) ?? [];
const newExternalIds = [...filteredIds, { appId, ...externalId }];
await this.updateOne({ _id }, { $set: { externalIds: newExternalIds } });
return { ...visitor, externalIds: newExternalIds };
}
async findOneGuestByEmailAddress(emailAddress: string): Promise<ILivechatVisitor | null> {
if (!emailAddress) {
return null;
}
const query = {
'visitorEmails.address': String(emailAddress).toLowerCase(),
};
return this.findOne(query);
}
/**
* Find visitors by _id
* @param {string} token - Visitor token
*/
findById<T extends Document = ILivechatVisitor, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
_id: string,
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
const query = {
_id,
};
return this.find<T, O>(query, options);
}
findEnabled<T extends Document = ILivechatVisitor, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
query: Filter<ILivechatVisitor>,
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
return this.find<T, O>(
{
...query,
disabled: { $ne: true },
},
options,
);
}
findOneEnabledById<T extends Document = ILivechatVisitor, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
_id: string,
options?: O,
): Promise<DocumentWithProjection<T, O> | null> {
const query = {
_id,
disabled: { $ne: true },
};
return this.findOne<T, O>(query, options);
}
getVisitorByToken<T extends Document = ILivechatVisitor, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
token: string,
options?: O,
): Promise<DocumentWithProjection<T, O> | null> {
const query = {
token,
};
return this.findOne<T, O>(query, options);
}
countVisitorsBetweenDate({ start, end, department }: { start: Date; end: Date; department?: string }): Promise<number> {
const query = {
disabled: { $ne: true },
_updatedAt: {
$gte: new Date(start),
$lt: new Date(end),
},
...(department && department !== 'undefined' && { department }),
};
return this.countDocuments(query);
}
async getNextVisitorUsername(): Promise<string> {
// TODO remove dependency from another model - this logic should be inside a service/function
const livechatCount = await Settings.incrementValueById('Livechat_guest_count', 1, { returnDocument: 'after' });
if (!livechatCount) {
throw new Error("Can't find Livechat_guest_count setting");
}
return `guest-${livechatCount.value}`;
}
findByNameRegexWithExceptionsAndConditions<P extends Document = ILivechatVisitor>(
searchTerm: string,
exceptions: string[] = [],
conditions: Filter<ILivechatVisitor> = {},
options: FindOptions<P extends ILivechatVisitor ? ILivechatVisitor : P> = {},
): AggregationCursor<
P & {
custom_name: string;
}
> {
if (!Array.isArray(exceptions)) {
exceptions = [exceptions];
}
const nameRegex = new RegExp(`^${escapeRegExp(searchTerm).trim()}`, 'i');
const match = {
$match: {
name: nameRegex,
_id: {
$nin: exceptions,
},
...conditions,
},
};
const { projection, sort, skip, limit } = options;
const project = {
$project: {
// TODO: move this logic to client
custom_name: { $concat: ['$username', ' - ', '$name'] },
...projection,
},
};
const order = { $sort: sort || { name: 1 } };
const params: Record<string, unknown>[] = [match, order, skip && { $skip: skip }, limit && { $limit: limit }, project].filter(
Boolean,
) as Record<string, unknown>[];
return this.col.aggregate(params);
}
/**
* Find visitors by their email or phone or username or name
*/
async findPaginatedVisitorsByEmailOrPhoneOrNameOrUsernameOrCustomField<
T extends Document = ILivechatVisitor,
O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>,
>(
emailOrPhone?: string,
nameOrUsername?: RegExp,
allowedCustomFields: string[] = [],
options?: O,
): Promise<FindPaginated<FindCursor<DocumentWithProjection<T, O>>>> {
if (!emailOrPhone && !nameOrUsername && allowedCustomFields.length === 0) {
return this.findPaginated<T, O>({ disabled: { $ne: true } }, options);
}
const query: Filter<ILivechatVisitor> = {
$or: [
...(emailOrPhone
? [
{
'visitorEmails.address': emailOrPhone,
},
{
'phone.phoneNumber': emailOrPhone,
},
]
: []),
...(nameOrUsername
? [
{
name: nameOrUsername,
},
{
username: nameOrUsername,
},
]
: []),
...allowedCustomFields.map((c: string) => ({ [`livechatData.${c}`]: nameOrUsername })),
],
disabled: { $ne: true },
};
return this.findPaginated<T, O>(query, options);
}
async findOneByEmailAndPhoneAndCustomField(
email: string | null | undefined,
phone: string | null | undefined,
customFields?: { [key: string]: RegExp },
): Promise<ILivechatVisitor | null> {
const query = Object.assign(
{
disabled: { $ne: true },
},
{
...(email && { visitorEmails: { address: email } }),
...(phone && { phone: { phoneNumber: phone } }),
...customFields,
},
);
if (Object.keys(query).length === 1) {
return null;
}
return this.findOne(query);
}
updateAllLivechatDataByToken(token: string, livechatDataToUpdate: Record<string, string>): Promise<UpdateResult> {
return this.updateOne({ token }, { $set: livechatDataToUpdate });
}
async updateLivechatDataByToken(
token: string,
key: string,
value: unknown,
overwrite = true,
): Promise<UpdateResult | Document | boolean> {
const query = {
token,
};
if (!overwrite) {
const user = await this.getVisitorByToken(token, { projection: { livechatData: 1 } });
if (user?.livechatData && typeof user.livechatData[key] !== 'undefined') {
return true;
}
}
const update: UpdateFilter<ILivechatVisitor> = {
$set: {
[`livechatData.${key}`]: value,
},
};
return this.updateOne(query, update);
}
updateLastAgentByToken(token: string, lastAgent: ILivechatVisitor['lastAgent']): Promise<Document | UpdateResult> {
const query = {
token,
};
const update = {
$set: {
lastAgent,
},
};
return this.updateOne(query, update);
}
updateById(_id: string, update: UpdateFilter<ILivechatVisitor>): Promise<Document | UpdateResult> {
return this.updateOne({ _id }, update);
}
async updateOneByIdOrToken(
update: Partial<ILivechatVisitor>,
options?: FindOneAndUpdateOptions,
): Promise<null | WithId<ILivechatVisitor>> {
let query: Filter<ILivechatVisitor> = {};
if (update._id) {
query = { _id: update._id };
} else if (update.token) {
query = { token: update.token };
update._id = new ObjectId().toHexString();
}
return this.findOneAndUpdate(query, { $set: update }, options);
}
saveGuestById(
_id: string,
data: { name?: string; username?: string; email?: string; phone?: string; livechatData: { [k: string]: any } },
): Promise<UpdateResult | Document | boolean> {
const setData: DeepWriteable<UpdateFilter<ILivechatVisitor>['$set']> = {};
const unsetData: DeepWriteable<UpdateFilter<ILivechatVisitor>['$unset']> = {};
if (data.name) {
if (data.name?.trim()) {
setData.name = data.name.trim();
} else {
unsetData.name = 1;
}
}
if (data.email) {
if (data.email?.trim()) {
setData.visitorEmails = [{ address: data.email.trim() }];
} else {
unsetData.visitorEmails = 1;
}
}
if (data.phone) {
if (data.phone?.trim()) {
setData.phone = [{ phoneNumber: data.phone.trim() }];
} else {
unsetData.phone = 1;
}
}
if (data.livechatData) {
Object.keys(data.livechatData).forEach((key) => {
const value = data.livechatData[key]?.trim();
if (value) {
setData[`livechatData.${key}`] = value;
} else {
unsetData[`livechatData.${key}`] = 1;
}
});
}
const update: UpdateFilter<ILivechatVisitor> = {
...(Object.keys(setData).length && { $set: setData as UpdateFilter<ILivechatVisitor>['$set'] }),
...(Object.keys(unsetData).length && { $unset: unsetData as UpdateFilter<ILivechatVisitor>['$unset'] }),
};
if (!Object.keys(update).length) {
return Promise.resolve(true);
}
return this.updateOne({ _id }, update);
}
removeDepartmentById(_id: string): Promise<UpdateResult> {
return this.updateOne({ _id }, { $unset: { department: 1 } });
}
override removeById(_id: string): Promise<DeleteResult> {
return this.deleteOne({ _id });
}
async saveGuestEmailPhoneById(_id: string, emails: string[], phones: string[]): Promise<UpdateResult | Document | void> {
const saveEmail = ([] as string[])
.concat(emails)
.filter((email) => email?.trim())
.map((email) => ({ address: email }));
const savePhone = ([] as string[])
.concat(phones)
.filter((phone) => phone?.trim().replace(/[^\d]/g, ''))
.map((phone) => ({ phoneNumber: phone }));
if (!saveEmail.length && !savePhone.length) {
return Promise.resolve();
}
// TODO: Lead Capture features: Either create a migration script fixing records or deprecate it
// Temporary workaround for legacy data.
// Some old records have 'visitorEmails' or 'phone' set to 'null', which breaks the $addToSet below.
// Pending Product decision: either run a proper DB migration to fix all nulls,
// or deprecate this feature entirely. Remove this once a decision is made.
if (saveEmail.length) {
await this.updateOne({ _id, visitorEmails: null as any }, { $set: { visitorEmails: [] } });
}
if (savePhone.length) {
await this.updateOne({ _id, phone: null }, { $set: { phone: [] } });
}
return this.updateOne(
{ _id },
{
$addToSet: {
...(saveEmail.length && { visitorEmails: { $each: saveEmail } }),
...(savePhone.length && { phone: { $each: savePhone } }),
},
},
);
}
removeContactManagerByUsername(manager: string): Promise<Document | UpdateResult> {
return this.updateMany(
{
contactManager: {
username: manager,
},
},
{
$unset: {
contactManager: true,
},
},
);
}
disableById(_id: string): Promise<UpdateResult> {
return this.updateOne(
{ _id },
{
$set: { disabled: true },
$unset: {
department: 1,
contactManager: 1,
token: 1,
visitorEmails: 1,
phone: 1,
name: 1,
livechatData: 1,
lastChat: 1,
ip: 1,
host: 1,
userAgent: 1,
username: 1,
ts: 1,
status: 1,
},
},
);
}
setLastChatById(_id: string, lastChat: Required<ILivechatVisitor['lastChat']>): Promise<UpdateResult> {
return this.updateOne(
{ _id },
{
$set: {
lastChat,
},
},
);
}
updateDepartmentById(_id: string, department: string) {
return this.findOneAndUpdate({ _id }, { $set: { department } }, { returnDocument: 'after' });
}
findByIds<T extends Document = ILivechatVisitor, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
ids: string[],
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
const query = {
_id: { $in: ids },
};
return this.find<T, O>(query, options);
}
}
type DeepWriteable<T> = { -readonly [P in keyof T]: DeepWriteable<T[P]> };

View File

@@ -0,0 +1,38 @@
import { randomBytes } from 'crypto';
import type { ILoginCode } from '@rocket.chat/core-typings';
import type { ILoginCodesModel } from '@rocket.chat/model-typings';
import type { Db, IndexDescription } from 'mongodb';
import { BaseRaw } from './BaseRaw';
const CODE_TTL_MS = 60 * 1000;
export class LoginCodesRaw extends BaseRaw<ILoginCode> implements ILoginCodesModel {
constructor(db: Db) {
super(db, 'login_codes');
}
override modelIndexes(): IndexDescription[] {
return [{ key: { expireAt: 1 }, expireAfterSeconds: 0 }];
}
async createCode(userId: string): Promise<string> {
const now = new Date();
const code = randomBytes(32).toString('hex');
await this.insertOne({
_id: code,
userId,
createdAt: now,
expireAt: new Date(now.getTime() + CODE_TTL_MS),
});
return code;
}
findOneNotExpiredByCodeAndDelete(code: string): Promise<ILoginCode | null> {
return this.findOneAndDelete({
_id: code,
expireAt: { $gt: new Date() },
});
}
}

View File

@@ -0,0 +1,57 @@
import type { LoginServiceConfiguration, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { ILoginServiceConfigurationModel, DocumentWithProjection, FindOptionsWithProjection } from '@rocket.chat/model-typings';
import type { Collection, Db, Document } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class LoginServiceConfigurationRaw extends BaseRaw<LoginServiceConfiguration> implements ILoginServiceConfigurationModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<LoginServiceConfiguration>>) {
super(db, 'meteor_accounts_loginServiceConfiguration', trash, {
preventSetUpdatedAt: true,
collectionNameResolver(name) {
return name;
},
});
}
async createOrUpdateService(
serviceName: LoginServiceConfiguration['service'],
serviceData: Partial<LoginServiceConfiguration>,
): Promise<LoginServiceConfiguration['_id']> {
const service = serviceName.toLowerCase();
const existing = await this.findOne({ service });
if (!existing) {
const insertResult = await this.insertOne({
service,
...serviceData,
});
return insertResult.insertedId;
}
if (Object.keys(serviceData).length > 0) {
await this.updateOne(
{
_id: existing._id,
},
{
$set: serviceData,
},
);
}
return existing._id;
}
removeByService(serviceName: LoginServiceConfiguration['service']): Promise<Pick<LoginServiceConfiguration, '_id'> | null> {
return this.findOneAndDelete({ service: serviceName.toLowerCase() }, { projection: { _id: 1 } });
}
async findOneByService<
P extends Document = LoginServiceConfiguration,
O extends FindOptionsWithProjection<P> = FindOptionsWithProjection<P>,
>(serviceName: LoginServiceConfiguration['service'], options?: O): Promise<DocumentWithProjection<P, O> | null> {
return this.findOne<P, O>({ service: serviceName.toLowerCase() }, options);
}
}

View File

@@ -0,0 +1,94 @@
import type {
RocketChatRecordDeleted,
IMediaCallNegotiation,
MediaCallNegotiationStream,
RTCSessionDescriptionInit,
} from '@rocket.chat/core-typings';
import type { IMediaCallNegotiationsModel, DocumentWithProjection, FindOptionsWithProjection } from '@rocket.chat/model-typings';
import type { IndexDescription, Collection, Db, Document, UpdateResult } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class MediaCallNegotiationsRaw extends BaseRaw<IMediaCallNegotiation> implements IMediaCallNegotiationsModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IMediaCallNegotiation>>) {
super(db, 'media_call_negotiations', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [{ key: { callId: 1, requestTimestamp: -1 }, unique: false }];
}
public async findLatestByCallId<
T extends Document = IMediaCallNegotiation,
O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>,
>(callId: IMediaCallNegotiation['callId'], options?: O): Promise<DocumentWithProjection<T, O> | null> {
return this.findOne<T, O>(
{
callId,
},
// safe to merge into `O`: only `O['projection']` feeds the return type, and it survives the spread.
// note the model's `sort`/`limit` win over caller-supplied ones.
{
...options,
sort: {
requestTimestamp: -1,
},
limit: 1,
} as unknown as O,
);
}
public async setOfferById(
id: string,
offer: RTCSessionDescriptionInit,
offerStreams?: MediaCallNegotiationStream[],
): Promise<UpdateResult> {
return this.updateOne(
{
_id: id,
offer: { $exists: false },
},
{
$set: {
offer,
offerTimestamp: new Date(),
...(offerStreams && { offerStreams }),
},
},
);
}
public async setAnswerById(
id: string,
answer: RTCSessionDescriptionInit,
answerStreams?: MediaCallNegotiationStream[],
): Promise<UpdateResult> {
return this.updateOne(
{
_id: id,
answer: { $exists: false },
},
{
$set: {
answer,
answerTimestamp: new Date(),
...(answerStreams && { answerStreams }),
},
},
);
}
public async setStableById(id: string): Promise<UpdateResult> {
return this.updateOne(
{
_id: id,
stableTimestamp: { $exists: false },
},
{
$set: {
stableTimestamp: new Date(),
},
},
);
}
}

View File

@@ -0,0 +1,227 @@
import type {
IMediaCall,
RocketChatRecordDeleted,
MediaCallActorType,
MediaCallSignedContact,
MediaCallContact,
IUser,
MediaCallActor,
} from '@rocket.chat/core-typings';
import type { IMediaCallsModel, DocumentWithProjection, FindOptionsWithProjection } from '@rocket.chat/model-typings';
import type { IndexDescription, Collection, Db, UpdateFilter, UpdateOptions, UpdateResult, Document, FindCursor } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class MediaCallsRaw extends BaseRaw<IMediaCall> implements IMediaCallsModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IMediaCall>>) {
super(db, 'media_calls', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [
{ key: { createdAt: 1 }, unique: false },
{ key: { ended: 1, uids: 1, expiresAt: 1 }, unique: false },
{
key: { 'caller.type': 1, 'caller.id': 1, 'callerRequestedId': 1 },
sparse: true,
},
];
}
public async findOneByIdAndCallee<T extends Document = IMediaCall, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
id: IMediaCall['_id'],
callee: MediaCallActor,
options?: O,
): Promise<DocumentWithProjection<T, O> | null> {
return this.findOne<T, O>(
{
'_id': id,
'callee.type': callee.type,
'callee.id': callee.id,
...(callee.contractId && { 'callee.contractId': callee.contractId }),
},
options,
);
}
public async findOneByCallerRequestedId<
T extends Document = IMediaCall,
O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>,
>(
id: Required<IMediaCall>['callerRequestedId'],
caller: { type: MediaCallActorType; id: string },
options?: O,
): Promise<DocumentWithProjection<T, O> | null> {
return this.findOne<T, O>(
{
'caller.type': caller.type,
'caller.id': caller.id,
'callerRequestedId': id,
},
options,
);
}
public updateOneById(
_id: string,
update: UpdateFilter<IMediaCall> | Partial<IMediaCall>,
options?: UpdateOptions,
): Promise<UpdateResult> {
return this.updateOne({ _id }, update, options);
}
public async startRingingById(callId: string, expiresAt: Date): Promise<UpdateResult> {
return this.updateOne(
{
_id: callId,
state: 'none',
},
{ $set: { state: 'ringing', expiresAt } },
);
}
public async acceptCallById(
callId: string,
data: { calleeContractId: string; supportedFeatures: string[]; sipCallId?: string },
expiresAt: Date,
): Promise<UpdateResult> {
const { calleeContractId, sipCallId } = data;
return this.updateOne(
{
_id: callId,
state: { $in: ['none', 'ringing'] },
},
{
$set: {
'state': 'accepted',
'callee.contractId': calleeContractId,
'acceptedAt': new Date(),
expiresAt,
...(sipCallId && { sipCallId }),
},
$pull: {
features: {
$nin: data.supportedFeatures,
},
},
},
);
}
public async activateCallById(callId: string, expiresAt: Date): Promise<UpdateResult> {
return this.updateOne(
{
_id: callId,
state: 'accepted',
},
{
$set: {
state: 'active',
activatedAt: new Date(),
expiresAt,
},
},
);
}
public async hangupCallById(callId: string, params?: { endedBy?: IMediaCall['endedBy']; reason?: string }): Promise<UpdateResult> {
const { endedBy, reason } = params || {};
return this.updateOne(
{
_id: callId,
ended: false,
},
{
$set: {
state: 'hangup',
ended: true,
endedAt: new Date(),
...(endedBy && { endedBy }),
...(reason && { hangupReason: reason }),
},
},
);
}
public async setExpiresAtById(callId: string, expiresAt: Date): Promise<UpdateResult> {
return this.updateOne(
{
_id: callId,
ended: false,
},
{
$set: { expiresAt },
},
);
}
public async transferCallById(callId: string, params: { by: MediaCallSignedContact; to: MediaCallContact }): Promise<UpdateResult> {
return this.updateOne(
{
_id: callId,
state: {
$in: ['accepted', 'active'],
},
transferredAt: {
$exists: false,
},
},
{
$set: {
transferredAt: new Date(),
transferredBy: params.by,
transferredTo: params.to,
},
},
);
}
public findAllExpiredCalls<T extends Document = IMediaCall, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
return this.find<T, O>(
{
ended: false,
expiresAt: {
$lte: new Date(),
},
},
options,
);
}
public findAllNotOverByUid<T extends Document = IMediaCall, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
uid: IUser['_id'],
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
return this.find<T, O>(
{
ended: false,
expiresAt: {
$gt: new Date(),
},
uids: uid,
},
options,
);
}
public async hasUnfinishedCalls(): Promise<boolean> {
const count = await this.countDocuments({ ended: false }, { limit: 1 });
return count > 0;
}
public async hasUnfinishedCallsByUid(uid: IUser['_id'], exceptCallId?: string): Promise<boolean> {
const count = await this.countDocuments(
{
ended: false,
uids: uid,
...(exceptCallId && { _id: { $ne: exceptCallId } }),
},
{ limit: 1 },
);
return count > 0;
}
}

View File

@@ -0,0 +1,62 @@
import type { MessageReads, IUser, IMessage, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IMessageReadsModel } from '@rocket.chat/model-typings';
import type { Collection, Db, IndexDescription, UpdateResult } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class MessageReadsRaw extends BaseRaw<MessageReads> implements IMessageReadsModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<MessageReads>>) {
super(db, 'message_reads', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [{ key: { tmid: 1, userId: 1 }, unique: true }, { key: { ls: 1 } }];
}
async findOneByUserIdAndThreadId(userId: IUser['_id'], tmid: IMessage['_id']): Promise<MessageReads | null> {
return this.findOne({ userId, tmid });
}
getMinimumLastSeenByThreadId(tmid: IMessage['_id']): Promise<MessageReads | null> {
return this.findOne(
{
tmid,
},
{
sort: {
ls: 1,
},
},
);
}
updateReadTimestampByUserIdAndThreadId(userId: IUser['_id'], tmid: IMessage['_id']): Promise<UpdateResult> {
const query = {
userId,
tmid,
};
const update = {
$set: {
ls: new Date(),
},
};
return this.updateOne(query, update, { upsert: true });
}
async countByThreadAndUserIds(tmid: IMessage['_id'], userIds: IUser['_id'][]): Promise<number> {
const query = {
tmid,
userId: { $in: userIds },
};
return this.countDocuments(query);
}
async countByThreadId(tmid: IMessage['_id']): Promise<number> {
const query = {
tmid,
};
return this.countDocuments(query);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
import type { IControl } from '@rocket.chat/core-typings';
import type { IMigrationsModel } from '@rocket.chat/model-typings';
import type { Db } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class MigrationsRaw extends BaseRaw<IControl> implements IMigrationsModel {
constructor(db: Db) {
super(db, 'migrations', undefined, {
collectionNameResolver(name) {
return name;
},
});
}
}

View File

@@ -0,0 +1,441 @@
import type {
IMessage,
IModerationAudit,
IModerationReport,
RocketChatRecordDeleted,
MessageReport,
UserReport,
} from '@rocket.chat/core-typings';
import type { FindPaginated, IModerationReportsModel, PaginationParams } from '@rocket.chat/model-typings';
import type { AggregationCursor, Collection, Db, Document, FindCursor, FindOptions, IndexDescription, UpdateResult } from 'mongodb';
import { BaseRaw } from './BaseRaw';
import { readSecondaryPreferred } from '../readSecondaryPreferred';
export class ModerationReportsRaw extends BaseRaw<IModerationReport> implements IModerationReportsModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IModerationReport>>) {
super(db, 'moderation_reports', trash);
}
override modelIndexes(): IndexDescription[] | undefined {
return [
// TODO deprecated. remove within a migration in v7.0
// { key: { 'ts': 1, 'reports.ts': 1 } },
// { key: { 'message.u._id': 1, 'ts': 1 } },
// { key: { 'reportedUser._id': 1, 'ts': 1 } },
// { key: { 'message.rid': 1, 'ts': 1 } },
// { key: { 'message._id': 1, 'ts': 1 } },
// { key: { userId: 1, ts: 1 } },
{ key: { _hidden: 1, ts: 1 } },
{ key: { 'message._id': 1, '_hidden': 1, 'ts': 1 } },
{ key: { 'message.u._id': 1, '_hidden': 1, 'ts': 1 } },
{ key: { 'reportedUser._id': 1, '_hidden': 1, 'ts': 1 } },
];
}
createWithMessageDescriptionAndUserId(
message: IMessage,
description: IModerationReport['description'],
room: IModerationReport['room'],
reportedBy: IModerationReport['reportedBy'],
): ReturnType<BaseRaw<IModerationReport>['insertOne']> {
const record: Pick<IModerationReport, 'message' | 'description' | 'ts' | 'reportedBy' | 'room'> = {
message,
description,
reportedBy,
room,
ts: new Date(),
};
return this.insertOne(record);
}
createWithDescriptionAndUser(
reportedUser: UserReport['reportedUser'],
description: UserReport['description'],
reportedBy: UserReport['reportedBy'],
): ReturnType<BaseRaw<IModerationReport>['insertOne']> {
const record = {
description,
reportedBy,
reportedUser,
ts: new Date(),
};
return this.insertOne(record);
}
findMessageReportsGroupedByUser(
latest: Date,
oldest: Date,
selector: string,
pagination: PaginationParams<IModerationReport>,
): AggregationCursor<IModerationAudit> {
const query = {
_hidden: {
$ne: true,
},
ts: {
$lt: latest,
$gt: oldest,
},
...this.getSearchQueryForSelector(selector),
};
const { sort, offset, count } = pagination;
const params = [
{ $match: query },
{
$group: {
_id: { user: '$message.u._id' },
reports: { $first: '$$ROOT' },
rooms: { $addToSet: '$room' }, // to be replaced with room
count: { $sum: 1 },
},
},
{
$sort: sort || {
'reports.ts': -1,
},
},
{
$skip: offset,
},
{
$limit: count,
},
{
$lookup: {
from: 'users',
localField: '_id.user',
foreignField: '_id',
as: 'user',
},
},
{
$unwind: {
path: '$user',
preserveNullAndEmptyArrays: true,
},
},
{
// TODO: maybe clean up the projection, i.e. exclude things we don't need
$project: {
_id: 0,
message: '$reports.message.msg',
msgId: '$reports.message._id',
ts: '$reports.ts',
username: '$reports.message.u.username',
name: '$reports.message.u.name',
userId: '$reports.message.u._id',
isUserDeleted: { $cond: ['$user', false, true] },
count: 1,
rooms: 1,
roomIds: { $map: { input: '$rooms', as: 'r', in: '$$r._id' } },
},
},
];
return this.col.aggregate(params, { allowDiskUse: true });
}
findUserReports(
latest: Date,
oldest: Date,
selector: string,
pagination: PaginationParams<IModerationReport>,
): AggregationCursor<Pick<UserReport, '_id' | 'reportedUser' | 'ts'> & { count: number }> {
const query = {
_hidden: {
$ne: true,
},
ts: {
$lt: latest,
$gt: oldest,
},
...this.getSearchQueryForSelectorUsers(selector),
};
const { sort, offset, count } = pagination;
const pipeline = [
{ $match: query },
{
$sort: {
ts: -1,
},
},
{
$group: {
_id: '$reportedUser._id',
count: { $sum: 1 },
reports: { $first: '$$ROOT' },
},
},
{
$sort: sort || {
'reports.ts': -1,
},
},
{
$skip: offset,
},
{
$limit: count,
},
{
$project: {
_id: 0,
reportedUser: '$reports.reportedUser',
ts: '$reports.ts',
count: 1,
},
},
];
return this.col.aggregate(pipeline, { allowDiskUse: true, readPreference: readSecondaryPreferred() });
}
async getTotalUniqueReportedUsers(latest: Date, oldest: Date, selector: string, isMessageReports?: boolean): Promise<number> {
const query = {
_hidden: {
$ne: true,
},
ts: {
$lt: latest,
$gt: oldest,
},
...(isMessageReports ? this.getSearchQueryForSelector(selector) : this.getSearchQueryForSelectorUsers(selector)),
};
const field = isMessageReports ? 'message.u._id' : 'reportedUser._id';
const pipeline = [{ $match: query }, { $group: { _id: `$${field}` } }, { $group: { _id: null, count: { $sum: 1 } } }];
const result = await this.col.aggregate(pipeline).toArray();
return result[0]?.count || 0;
}
countMessageReportsInRange(latest: Date, oldest: Date, selector: string): Promise<number> {
return this.countDocuments({
_hidden: { $ne: true },
ts: { $lt: latest, $gt: oldest },
...this.getSearchQueryForSelector(selector),
});
}
findReportedMessagesByReportedUserId(
userId: string,
selector: string,
pagination: PaginationParams<IModerationReport>,
options: FindOptions<IModerationReport> = {},
): FindPaginated<FindCursor<Pick<MessageReport, '_id' | 'message' | 'ts' | 'room'>>> {
const query = {
'_hidden': {
$ne: true,
},
'message.u._id': userId,
};
const { sort, offset, count } = pagination;
const fuzzyQuery = selector
? {
'message.msg': {
$regex: selector,
$options: 'i',
},
}
: {};
const params = {
sort: sort || {
ts: -1,
},
skip: offset,
limit: count,
projection: {
_id: 1,
message: 1,
ts: 1,
room: 1,
},
...options,
};
return this.findPaginated({ ...query, ...fuzzyQuery }, params);
}
findUserReportsByReportedUserId(
userId: string,
selector: string,
pagination: PaginationParams<IModerationReport>,
options: FindOptions<IModerationReport> = {},
): FindPaginated<FindCursor<Omit<UserReport, 'moderationInfo'>>> {
const query = {
'_hidden': {
$ne: true,
},
'reportedUser._id': userId,
...this.getSearchQueryForSelectorUsers(selector),
};
const { count, offset, sort } = pagination;
const opts = {
sort: sort || {
ts: -1,
},
skip: offset,
limit: count,
projection: {
_id: 1,
description: 1,
ts: 1,
reportedBy: 1,
reportedUser: 1,
},
...options,
};
return this.findPaginated(query, opts);
}
findReportsByMessageId(
messageId: string,
selector: string,
pagination: PaginationParams<IModerationReport>,
options: FindOptions<IModerationReport> = {},
): FindPaginated<FindCursor<Pick<IModerationReport, '_id' | 'description' | 'reportedBy' | 'ts' | 'room'>>> {
const query = {
'_hidden': {
$ne: true,
},
'message._id': messageId,
...this.getSearchQueryForSelector(selector),
};
const { count, offset, sort } = pagination;
const opts = {
sort: sort || {
ts: -1,
},
skip: offset,
limit: count,
projection: {
_id: 1,
description: 1,
ts: 1,
reportedBy: 1,
room: 1,
},
...options,
};
return this.findPaginated(query, opts);
}
async hideMessageReportsByMessageId(messageId: string, userId: string, reason: string, action: string): Promise<UpdateResult | Document> {
const query = {
'message._id': messageId,
};
const update = {
$set: {
_hidden: true,
moderationInfo: { hiddenAt: new Date(), moderatedBy: userId, reason, action },
},
};
return this.updateMany(query, update);
}
async hideMessageReportsByUserId(userId: string, moderatorId: string, reason: string, action: string): Promise<UpdateResult | Document> {
const query = {
'message.u._id': userId,
};
const update = {
$set: {
_hidden: true,
moderationInfo: { hiddenAt: new Date(), moderatedBy: moderatorId, reason, action },
},
};
return this.updateMany(query, update);
}
async hideUserReportsByUserId(userId: string, moderatorId: string, reason: string, action: string): Promise<UpdateResult | Document> {
const query = {
'reportedUser._id': userId,
};
const update = {
$set: {
_hidden: true,
moderationInfo: { hiddenAt: new Date(), moderatedBy: moderatorId, reason, action },
},
};
return this.updateMany(query, update);
}
private getSearchQueryForSelector(selector?: string): any {
const messageExistsQuery = { message: { $exists: true } };
if (!selector) {
return messageExistsQuery;
}
return {
...messageExistsQuery,
$or: [
{
'message.msg': {
$regex: selector,
$options: 'i',
},
},
{
description: {
$regex: selector,
$options: 'i',
},
},
{
'message.u.username': {
$regex: selector,
$options: 'i',
},
},
{
'message.u.name': {
$regex: selector,
$options: 'i',
},
},
],
};
}
private getSearchQueryForSelectorUsers(selector?: string): any {
const messageAbsentQuery = { message: { $exists: false } };
if (!selector) {
return messageAbsentQuery;
}
return {
...messageAbsentQuery,
$or: [
{
'reportedUser.username': {
$regex: selector,
$options: 'i',
},
},
{
'reportedUser.name': {
$regex: selector,
$options: 'i',
},
},
],
};
}
}

View File

@@ -0,0 +1,102 @@
import type { INotification, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { INotificationQueueModel } from '@rocket.chat/model-typings';
import type { Collection, Db, Document, IndexDescription, UpdateResult } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class NotificationQueueRaw extends BaseRaw<INotification> implements INotificationQueueModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<INotification>>) {
super(db, 'notification_queue', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [
{ key: { uid: 1 } },
{ key: { ts: 1 }, expireAfterSeconds: 2 * 60 * 60 },
{ key: { schedule: 1 }, sparse: true },
{ key: { sending: 1 }, sparse: true },
{ key: { error: 1 }, sparse: true },
];
}
unsetSendingById(_id: string): Promise<UpdateResult> {
return this.updateOne(
{ _id },
{
$unset: {
sending: 1,
},
},
);
}
setErrorById(_id: string, error: any): Promise<UpdateResult> {
return this.updateOne(
{
_id,
},
{
$set: {
error,
},
$unset: {
sending: 1,
},
},
);
}
clearScheduleByUserId(uid: string): Promise<UpdateResult | Document> {
return this.updateMany(
{
uid,
schedule: { $exists: true },
},
{
$unset: {
schedule: 1,
},
},
);
}
async clearQueueByUserId(uid: string): Promise<number | undefined> {
const op = await this.deleteMany({
uid,
});
return op.deletedCount;
}
async findNextInQueueOrExpired(expired: Date): Promise<INotification | null> {
const now = new Date();
const result = await this.findOneAndUpdate(
{
$and: [
{
$or: [{ sending: { $exists: false } }, { sending: { $lte: expired } }],
},
{
$or: [{ schedule: { $exists: false } }, { schedule: { $lte: now } }],
},
{
error: { $exists: false },
},
],
},
{
$set: {
sending: now,
},
},
{
sort: {
ts: 1,
},
},
);
return result;
}
}

View File

@@ -0,0 +1,98 @@
import type { INps, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import { NPSStatus } from '@rocket.chat/core-typings';
import type { INpsModel } from '@rocket.chat/model-typings';
import type { Collection, Db, Document, IndexDescription, UpdateResult } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class NpsRaw extends BaseRaw<INps> implements INpsModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<INps>>) {
super(db, 'nps', trash);
}
override modelIndexes(): IndexDescription[] {
return [{ key: { status: 1, expireAt: 1 } }];
}
// get expired surveys still in progress
async getOpenExpiredAndStartSending(): Promise<INps | null> {
const today = new Date();
const query = {
status: NPSStatus.OPEN,
expireAt: { $lte: today },
};
const update = {
$set: {
status: NPSStatus.SENDING,
},
};
const value = await this.findOneAndUpdate(query, update, { sort: { expireAt: 1 } });
return value;
}
// get expired surveys already sending results
async getOpenExpiredAlreadySending(): Promise<INps | null> {
const today = new Date();
const query = {
status: NPSStatus.SENDING,
expireAt: { $lte: today },
};
return this.col.findOne(query);
}
updateStatusById(_id: INps['_id'], status: INps['status']): Promise<UpdateResult> {
const update = {
$set: {
status,
},
};
return this.col.updateOne({ _id }, update);
}
save({
_id,
startAt,
expireAt,
createdBy,
status,
}: Pick<INps, '_id' | 'startAt' | 'expireAt' | 'createdBy' | 'status'>): Promise<UpdateResult> {
return this.col.updateOne(
{
_id,
},
{
$set: {
startAt,
_updatedAt: new Date(),
},
$setOnInsert: {
expireAt,
createdBy,
createdAt: new Date(),
status,
},
},
{
upsert: true,
},
);
}
closeAllByStatus(status: NPSStatus): Promise<UpdateResult | Document> {
const query = {
status,
};
const update = {
$set: {
status: NPSStatus.CLOSED,
},
};
return this.col.updateMany(query, update);
}
}

View File

@@ -0,0 +1,125 @@
import type { INpsVote, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import { INpsVoteStatus } from '@rocket.chat/core-typings';
import type { INpsVoteModel, DocumentWithProjection, FindOptionsWithProjection } from '@rocket.chat/model-typings';
import type { Collection, FindCursor, Db, Document, IndexDescription, UpdateResult } from 'mongodb';
import { ObjectId } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class NpsVoteRaw extends BaseRaw<INpsVote> implements INpsVoteModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<INpsVote>>) {
super(db, 'nps_vote', trash);
}
override modelIndexes(): IndexDescription[] {
return [{ key: { npsId: 1, status: 1, sentAt: 1 } }, { key: { npsId: 1, identifier: 1 }, unique: true }];
}
// `sort` and `limit` are branded away because the cursor below overwrites both; a caller passing
// them would have them silently dropped
findNotSentByNpsId<T extends Document = INpsVote, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
npsId: string,
options?: O & { sort?: never; limit?: never },
): FindCursor<DocumentWithProjection<T, O>> {
const query = {
npsId,
status: INpsVoteStatus.NEW,
};
const cursor = options ? this.find<T, O>(query, options) : this.find<T, O>(query);
return cursor.sort({ ts: 1 }).limit(1000);
}
findByNpsIdAndStatus<T extends Document = INpsVote, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
npsId: string,
status: INpsVoteStatus,
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
const query = {
npsId,
status,
};
if (options) {
return this.find<T, O>(query, options);
}
return this.find<T, O>(query);
}
findByNpsId<T extends Document = INpsVote, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
npsId: string,
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
const query = {
npsId,
};
if (options) {
return this.find<T, O>(query, options);
}
return this.find<T, O>(query);
}
save(vote: Omit<INpsVote, '_id' | '_updatedAt'>): Promise<UpdateResult> {
const { npsId, identifier } = vote;
const query = {
npsId,
identifier,
};
const update = {
$set: {
...vote,
_updatedAt: new Date(),
},
$setOnInsert: {
_id: new ObjectId().toHexString(), // TODO this should be done by BaseRaw
},
};
return this.updateOne(query, update, { upsert: true });
}
updateVotesToSent(voteIds: string[]): Promise<UpdateResult | Document> {
const query = {
_id: { $in: voteIds },
};
const update = {
$set: {
status: INpsVoteStatus.SENT,
},
};
return this.updateMany(query, update);
}
updateOldSendingToNewByNpsId(npsId: string): Promise<UpdateResult | Document> {
const fiveMinutes = new Date();
fiveMinutes.setMinutes(fiveMinutes.getMinutes() - 5);
const query = {
npsId,
status: INpsVoteStatus.SENDING,
sentAt: { $lt: fiveMinutes },
};
const update = {
$set: {
status: INpsVoteStatus.NEW,
},
$unset: {
sentAt: 1 as const, // why do you do this to me TypeScript?
},
};
return this.updateMany(query, update);
}
countByNpsId(npsId: string): Promise<number> {
return this.countDocuments({ npsId });
}
countByNpsIdAndStatus(npsId: string, status: INpsVoteStatus): Promise<number> {
const query = {
npsId,
status,
};
return this.countDocuments(query);
}
}

View File

@@ -0,0 +1,49 @@
import type { IOAuthAccessToken, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IOAuthAccessTokensModel, DocumentWithProjection, FindOptionsWithProjection } from '@rocket.chat/model-typings';
import type { Db, Collection, DeleteResult, IndexDescription, Document } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class OAuthAccessTokensRaw extends BaseRaw<IOAuthAccessToken> implements IOAuthAccessTokensModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IOAuthAccessToken>>) {
super(db, 'oauth_access_tokens', trash);
}
override modelIndexes(): IndexDescription[] {
return [
{ key: { accessToken: 1 } },
{ key: { refreshToken: 1 } },
{ key: { userId: 1 } },
{ key: { expires: 1 }, expireAfterSeconds: 60 * 60 * 24 * 30 },
{ key: { refreshTokenExpiresAt: 1 }, expireAfterSeconds: 60 * 60 * 24 * 30 },
];
}
async findOneByAccessToken<T extends Document = IOAuthAccessToken, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
accessToken: string,
options?: O,
): Promise<DocumentWithProjection<T, O> | null> {
if (typeof accessToken !== 'string' || !accessToken) {
return null;
}
return this.findOne<T, O>({ accessToken }, options);
}
async findOneByRefreshToken<
T extends Document = IOAuthAccessToken,
O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>,
>(refreshToken: string, options?: O): Promise<DocumentWithProjection<T, O> | null> {
if (typeof refreshToken !== 'string' || !refreshToken) {
return null;
}
return this.findOne<T, O>({ refreshToken }, options);
}
async deleteByUserId(userId: string): Promise<DeleteResult> {
return this.deleteMany({ userId });
}
async deleteByUserIds(userIds: string[]): Promise<DeleteResult> {
return this.deleteMany({ userId: { $in: userIds } });
}
}

View File

@@ -0,0 +1,69 @@
import type { IOAuthApps, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IOAuthAppsModel, DocumentWithProjection, FindOptionsWithProjection } from '@rocket.chat/model-typings';
import type { Db, Collection, IndexDescription, Document } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class OAuthAppsRaw extends BaseRaw<IOAuthApps> implements IOAuthAppsModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IOAuthApps>>) {
super(db, 'oauth_apps', trash);
}
override modelIndexes(): IndexDescription[] {
return [{ key: { clientId: 1, clientSecret: 1 } }, { key: { appId: 1 } }];
}
findOneAuthAppByIdOrClientId<T extends Document = IOAuthApps, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
props: { clientId: string } | { appId: string } | { _id: string },
options?: O,
): Promise<DocumentWithProjection<T, O> | null> {
return this.findOne<T, O>(
{
...('_id' in props && { _id: props._id }),
...('appId' in props && { _id: props.appId }),
...('clientId' in props && { clientId: props.clientId }),
},
options,
);
}
findOneActiveByClientId<T extends Document = IOAuthApps, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
clientId: string,
options?: O,
): Promise<DocumentWithProjection<T, O> | null> {
if (typeof clientId !== 'string' || !clientId) {
return Promise.resolve(null);
}
return this.findOne<T, O>(
{
active: true,
clientId,
},
options,
);
}
updateById(
_id: IOAuthApps['_id'],
data: Partial<Pick<IOAuthApps, 'name' | 'active' | 'redirectUri' | '_updatedBy'>>,
): Promise<IOAuthApps | null> {
return this.findOneAndUpdate({ _id }, { $set: data }, { returnDocument: 'after' });
}
findOneActiveByClientIdAndClientSecret<
T extends Document = IOAuthApps,
O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>,
>(clientId: string, clientSecret: string, options?: O): Promise<DocumentWithProjection<T, O> | null> {
if (typeof clientId !== 'string' || !clientId || typeof clientSecret !== 'string' || !clientSecret) {
return Promise.resolve(null);
}
return this.findOne<T, O>(
{
active: true,
clientId,
clientSecret,
},
options,
);
}
}

View File

@@ -0,0 +1,33 @@
import type { IOAuthAuthCode, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IOAuthAuthCodesModel, DocumentWithProjection, FindOptionsWithProjection } from '@rocket.chat/model-typings';
import type { Db, Collection, DeleteResult, IndexDescription, Document } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class OAuthAuthCodesRaw extends BaseRaw<IOAuthAuthCode> implements IOAuthAuthCodesModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IOAuthAuthCode>>) {
super(db, 'oauth_auth_codes', trash);
}
override modelIndexes(): IndexDescription[] {
return [{ key: { authCode: 1 } }, { key: { userId: 1 } }, { key: { expires: 1 }, expireAfterSeconds: 60 * 5 }];
}
findOneByAuthCode<T extends Document = IOAuthAuthCode, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
authCode: string,
options?: O,
): Promise<DocumentWithProjection<T, O> | null> {
if (typeof authCode !== 'string' || !authCode) {
return Promise.resolve(null);
}
return this.findOne<T, O>({ authCode }, options);
}
async deleteByUserId(userId: string): Promise<DeleteResult> {
return this.deleteMany({ userId });
}
async deleteByUserIds(userIds: string[]): Promise<DeleteResult> {
return this.deleteMany({ userId: { $in: userIds } });
}
}

View File

@@ -0,0 +1,33 @@
import type { IOAuthRefreshToken, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IOAuthRefreshTokensModel, DocumentWithProjection, FindOptionsWithProjection } from '@rocket.chat/model-typings';
import type { Db, Collection, DeleteResult, IndexDescription, Document } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class OAuthRefreshTokensRaw extends BaseRaw<IOAuthRefreshToken> implements IOAuthRefreshTokensModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IOAuthRefreshToken>>) {
super(db, 'oauth_refresh_tokens', trash);
}
override modelIndexes(): IndexDescription[] {
return [{ key: { refreshToken: 1 } }, { key: { userId: 1 } }, { key: { expires: 1 }, expireAfterSeconds: 60 * 60 * 24 * 30 }];
}
findOneByRefreshToken<T extends Document = IOAuthRefreshToken, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
refreshToken: string,
options?: O,
): Promise<DocumentWithProjection<T, O> | null> {
if (typeof refreshToken !== 'string' || !refreshToken) {
return Promise.resolve(null);
}
return this.findOne<T, O>({ refreshToken }, options);
}
async deleteByUserId(userId: string): Promise<DeleteResult> {
return this.deleteMany({ userId });
}
async deleteByUserIds(userIds: string[]): Promise<DeleteResult> {
return this.deleteMany({ userId: { $in: userIds } });
}
}

View File

@@ -0,0 +1,35 @@
import type { IOEmbedCache, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IOEmbedCacheModel } from '@rocket.chat/model-typings';
import type { Collection, Db, DeleteResult, IndexDescription } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class OEmbedCacheRaw extends BaseRaw<IOEmbedCache> implements IOEmbedCacheModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IOEmbedCache>>) {
super(db, 'oembed_cache', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [{ key: { updatedAt: 1 } }];
}
async createWithIdAndData(_id: string, data: any): Promise<IOEmbedCache> {
const record = {
_id,
data,
updatedAt: new Date(),
_updatedAt: new Date(), // TODO: insertOne inserts the field `_updatedAt` synchronously but it's not guaranteed
};
record._id = (await this.insertOne(record)).insertedId;
return record;
}
removeBeforeDate(date: Date): Promise<DeleteResult> {
const query = {
updatedAt: {
$lte: date,
},
};
return this.deleteMany(query);
}
}

View File

@@ -0,0 +1,67 @@
import type { IPermission, IRole, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IPermissionsModel } from '@rocket.chat/model-typings';
import type { Collection, Db, FindCursor, IndexDescription } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class PermissionsRaw extends BaseRaw<IPermission> implements IPermissionsModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IPermission>>) {
super(db, 'permissions', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [
{
key: {
level: 1,
settingId: 1,
},
},
];
}
async createOrUpdate(name: string, roles: IRole['_id'][]): Promise<IPermission['_id']> {
const exists = await this.findOne<Pick<IPermission, '_id'>>(
{
_id: name,
roles,
},
{ projection: { _id: 1 } },
);
if (exists) {
return exists._id;
}
await this.updateOne({ _id: name }, { $set: { roles } }, { upsert: true });
return name;
}
async create(id: string, roles: IRole['_id'][]): Promise<IPermission['_id']> {
const exists = await this.findOneById<Pick<IPermission, '_id'>>(id, { projection: { _id: 1 } });
if (exists) {
return exists._id;
}
await this.updateOne({ _id: id }, { $set: { roles } }, { upsert: true });
return id;
}
async addRole(permission: string, role: IRole['_id']): Promise<void> {
await this.updateOne({ _id: permission, roles: { $ne: role } }, { $addToSet: { roles: role } });
}
async setRoles(permission: string, roles: IRole['_id'][]): Promise<void> {
await this.updateOne({ _id: permission }, { $set: { roles } });
}
async removeRole(permission: string, role: IRole['_id']): Promise<void> {
await this.updateOne({ _id: permission, roles: role }, { $pull: { roles: role } });
}
findByLevel(level: 'settings', settingId?: string): FindCursor<IPermission> {
return this.find({ level, ...(settingId && { settingId }) });
}
}

View File

@@ -0,0 +1,182 @@
import type { IPushToken, IUser, AtLeast } from '@rocket.chat/core-typings';
import type { IPushTokenModel, DocumentWithProjection, FindOptionsWithProjection } from '@rocket.chat/model-typings';
import type { Db, DeleteResult, IndexDescription, InsertOneResult, UpdateResult, FindCursor, Document } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class PushTokenRaw extends BaseRaw<IPushToken> implements IPushTokenModel {
constructor(db: Db) {
super(db, '_raix_push_app_tokens', undefined, {
collectionNameResolver(name) {
return name;
},
});
}
override modelIndexes(): IndexDescription[] {
return [{ key: { userId: 1, authToken: 1 } }, { key: { appName: 1, token: 1 } }];
}
countApnTokens() {
const query = {
'token.apn': { $exists: true },
};
return this.countDocuments(query);
}
countGcmTokens() {
const query = {
'token.gcm': { $exists: true },
};
return this.countDocuments(query);
}
countTokensByUserId(userId: IUser['_id']) {
const query = {
userId,
$or: [{ 'token.apn': { $exists: true } }, { 'token.gcm': { $exists: true } }],
};
return this.countDocuments(query);
}
async findFirstByUserId<T extends Document = IPushToken, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
userId: IUser['_id'],
options?: O,
): Promise<DocumentWithProjection<T, O> | null> {
return this.findOne<T, O>({ userId }, options);
}
findAllTokensByUserId<T extends Document = IPushToken, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
userId: IUser['_id'],
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
return this.find<T, O>(
{
userId,
$or: [{ 'token.apn': { $exists: true } }, { 'token.gcm': { $exists: true } }],
},
options,
);
}
findTokensByUserIdExceptId<T extends Document = IPushToken, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
userId: IUser['_id'],
idToIgnore: IPushToken['_id'],
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
return this.find<T, O>(
{
_id: { $ne: idToIgnore },
userId,
$or: [{ 'token.apn': { $exists: true } }, { 'token.gcm': { $exists: true } }],
},
options,
);
}
async insertToken(data: AtLeast<IPushToken, 'token' | 'authToken' | 'appName' | 'userId'>): Promise<InsertOneResult<IPushToken>> {
return this.insertOne({
enabled: true,
createdAt: new Date(),
...data,
});
}
async refreshTokenById(
id: IPushToken['_id'],
data: Pick<IPushToken, 'token' | 'appName' | 'authToken' | 'userId' | 'voipToken'>,
): Promise<UpdateResult<IPushToken>> {
return this.updateOne(
{ _id: id },
{
$set: {
token: data.token,
authToken: data.authToken,
appName: data.appName,
userId: data.userId,
...(data.voipToken && { voipToken: data.voipToken }),
},
...(!data.voipToken && { $unset: { voipToken: 1 } }),
},
);
}
findOneByTokenAndAppName(token: IPushToken['token'], appName: IPushToken['appName']): Promise<IPushToken | null> {
return this.findOne({
token,
appName,
});
}
removeByUserIdExceptTokens(userId: string, tokens: IPushToken['authToken'][]): Promise<DeleteResult> {
return this.deleteMany({
userId,
authToken: { $nin: tokens },
});
}
removeDuplicateTokens(tokenData: Pick<IPushToken, '_id' | 'token' | 'appName' | 'authToken'>): Promise<DeleteResult> {
return this.deleteMany({
_id: { $ne: tokenData._id },
$or: [
{
token: tokenData.token,
appName: tokenData.appName,
},
{
authToken: tokenData.authToken,
},
],
});
}
removeAllByUserId(userId: string): Promise<DeleteResult> {
return this.deleteMany({
userId,
});
}
removeAllByTokenStringAndUserId(token: string, userId: string): Promise<DeleteResult> {
return this.deleteMany({
$or: [
{
'token.apn': token,
},
{
'token.gcm': token,
},
{
voipToken: token,
},
],
userId,
});
}
async removeOrUnsetByTokenString(token: string): Promise<void> {
await this.deleteMany({
$or: [
{
'token.apn': token,
},
{
'token.gcm': token,
},
],
});
await this.updateMany(
{
voipToken: token,
},
{
$unset: {
voipToken: 1,
},
},
);
}
}

View File

@@ -0,0 +1,21 @@
import type { IMessage, IReport, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IReportsModel } from '@rocket.chat/model-typings';
import type { Db, Collection } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class ReportsRaw extends BaseRaw<IReport> implements IReportsModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IReport>>) {
super(db, 'reports', trash);
}
createWithMessageDescriptionAndUserId(message: IMessage, description: string, userId: string): ReturnType<BaseRaw<IReport>['insertOne']> {
const record: Pick<IReport, 'message' | 'description' | 'ts' | 'userId'> = {
message,
description,
ts: new Date(),
userId,
};
return this.insertOne(record);
}
}

View File

@@ -0,0 +1,268 @@
import type { IRole, IRoom, IUser, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IRolesModel, DocumentWithProjection, FindOptionsWithProjection } from '@rocket.chat/model-typings';
import type { Collection, FindCursor, Db, Filter, FindOptions, Document, CountDocumentsOptions } from 'mongodb';
import { Subscriptions, Users } from '../index';
import { BaseRaw } from './BaseRaw';
export class RolesRaw extends BaseRaw<IRole> implements IRolesModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IRole>>) {
super(db, 'roles', trash);
}
findByUpdatedDate<T extends Document = IRole, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
updatedAfterDate: Date,
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
const query = {
_updatedAt: { $gte: new Date(updatedAfterDate) },
};
return options ? this.find<T, O>(query, options) : this.find<T, O>(query);
}
async isUserInRoles(userId: IUser['_id'], roles: IRole['_id'][], scope?: IRoom['_id']): Promise<boolean> {
if (process.env.NODE_ENV === 'development' && (scope === 'Users' || scope === 'Subscriptions')) {
throw new Error('Roles.isUserInRoles method received a role scope instead of a scope value.');
}
for (const roleId of roles) {
const role = await this.findOneById<Pick<IRole, '_id' | 'scope'>>(roleId, { projection: { scope: 1 } });
if (!role) {
continue;
}
switch (role.scope) {
case 'Subscriptions':
if (await Subscriptions.isUserInRole(userId, roleId, scope)) {
return true;
}
break;
case 'Users':
default:
if (await Users.isUserInRole(userId, roleId)) {
return true;
}
}
}
return false;
}
findOneByIdOrName<P extends Document = IRole, O extends FindOptionsWithProjection<P> = FindOptionsWithProjection<P>>(
_idOrName: IRole['_id'],
options?: O,
): Promise<DocumentWithProjection<P, O> | null> {
const query: Filter<IRole> = {
$or: [
{
_id: _idOrName,
},
{
name: _idOrName,
},
],
};
return this.findOne<P, O>(query, options);
}
async findOneByName<P = IRole>(name: IRole['name'], options?: any): Promise<IRole | P | null> {
const query: Filter<IRole> = {
name,
};
return this.findOne<IRole>(query, options);
}
findInIds<P>(ids: IRole['_id'][], options?: FindOptions<IRole>): P extends Pick<IRole, '_id'> ? FindCursor<P> : FindCursor<IRole> {
const query: Filter<IRole> = {
_id: {
$in: ids,
},
};
return this.find(query, options || {}) as P extends Pick<IRole, '_id'> ? FindCursor<P> : FindCursor<IRole>;
}
findInIdsOrNames<P>(
_idsOrNames: IRole['_id'][],
options?: FindOptions<IRole>,
): P extends Pick<IRole, '_id'> ? FindCursor<P> : FindCursor<IRole> {
const query: Filter<IRole> = {
$or: [
{
_id: {
$in: _idsOrNames,
},
},
{
name: {
$in: _idsOrNames,
},
},
],
};
return this.find(query, options || {}) as P extends Pick<IRole, '_id'> ? FindCursor<P> : FindCursor<IRole>;
}
findByScope<T extends Document = IRole, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
scope: IRole['scope'],
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
const query = {
scope,
};
return this.find<T, O>(query, options);
}
countByScope(scope: IRole['scope'], options?: CountDocumentsOptions): Promise<number> {
const query = {
scope,
};
return this.countDocuments(query, options);
}
findCustomRoles<T extends Document = IRole, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
const query: Filter<IRole> = {
protected: false,
};
return this.find<T, O>(query, options);
}
countCustomRoles(options?: CountDocumentsOptions): Promise<number> {
const query: Filter<IRole> = {
protected: false,
};
return this.countDocuments(query, options || {});
}
async updateById(
_id: IRole['_id'],
name: IRole['name'],
scope: IRole['scope'],
description: IRole['description'] = '',
mandatory2fa: IRole['mandatory2fa'] = false,
): Promise<IRole> {
const response = await this.findOneAndUpdate(
{ _id },
{
$set: {
name,
scope,
description,
mandatory2fa,
},
},
{ upsert: true, returnDocument: 'after' },
);
if (!response) {
console.log('Role not found', _id, name);
throw new Error('Role not found');
}
return response;
}
findUsersInRole(roleId: IRole['_id'], scope?: IRoom['_id']): Promise<FindCursor<IUser>>;
findUsersInRole(roleId: IRole['_id'], scope: IRoom['_id'] | undefined, options: FindOptions<IUser>): Promise<FindCursor<IUser>>;
findUsersInRole<P extends Document>(
roleId: IRole['_id'],
scope: IRoom['_id'] | undefined,
options: FindOptions<P extends IUser ? IUser : P>,
): Promise<FindCursor<P extends IUser ? IUser : P>>;
/** @deprecated function getUsersInRole should be used instead */
async findUsersInRole<P extends Document>(
roleId: IRole['_id'],
scope: IRoom['_id'] | undefined,
options?: any,
): Promise<FindCursor<IUser | P>> {
if (process.env.NODE_ENV === 'development' && (scope === 'Users' || scope === 'Subscriptions')) {
throw new Error('Roles.findUsersInRole method received a role scope instead of a scope value.');
}
const role = await this.findOneById<Pick<IRole, '_id' | 'scope'>>(roleId, { projection: { scope: 1 } });
if (!role) {
throw new Error('RolesRaw.findUsersInRole: role not found');
}
switch (role.scope) {
case 'Subscriptions':
return Subscriptions.findUsersInRoles([role._id], scope, options);
case 'Users':
default:
return Users.findUsersInRoles<IUser | P>([role._id], null, options);
}
}
async countUsersInRole(roleId: IRole['_id'], scope?: IRoom['_id']): Promise<number> {
const role = await this.findOneById<Pick<IRole, '_id' | 'scope'>>(roleId, { projection: { scope: 1 } });
if (!role) {
throw new Error('RolesRaw.countUsersInRole: role not found');
}
switch (role.scope) {
case 'Subscriptions':
return Subscriptions.countUsersInRoles([role._id], scope);
case 'Users':
default:
return Users.countUsersInRoles([role._id]);
}
}
async createWithRandomId(
name: IRole['name'],
scope: IRole['scope'] = 'Users',
description = '',
protectedRole = true,
mandatory2fa = false,
): Promise<IRole> {
const role = {
name,
scope,
description,
protected: protectedRole,
mandatory2fa,
};
const res = await this.insertOne(role);
return {
_id: res.insertedId,
_updatedAt: new Date(), // TODO: this should be set by the BaseRaw, but we need to set it here to return a complete IRole object
...role,
};
}
async canAddUserToRole(uid: IUser['_id'], roleId: IRole['_id'], scope?: IRoom['_id']): Promise<boolean> {
if (process.env.NODE_ENV === 'development' && (scope === 'Users' || scope === 'Subscriptions')) {
throw new Error('Roles.canAddUserToRole method received a role scope instead of a scope value.');
}
const role = await this.findOne({ _id: roleId }, { projection: { scope: 1 } });
if (!role) {
return false;
}
switch (role.scope) {
case 'Subscriptions':
return Subscriptions.isUserInRoleScope(uid, scope);
case 'Users':
default:
return Users.isUserInRoleScope(uid);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
import crypto from 'crypto';
import type { ISamlUsedAssertions, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { ISamlUsedAssertionsModel } from '@rocket.chat/model-typings';
import type { MongoServerError, Collection, Db, IndexDescription } from 'mongodb';
import { BaseRaw } from './BaseRaw';
const DUPLICATE_KEY_ERROR_CODE = 11000;
export class SamlUsedAssertionsRaw extends BaseRaw<ISamlUsedAssertions> implements ISamlUsedAssertionsModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<ISamlUsedAssertions>>) {
super(db, 'saml_used_assertions', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [{ key: { expireAt: 1 }, sparse: true, expireAfterSeconds: 0 }];
}
async markUsed(assertionId: string, issuer: string, expireAt: Date): Promise<boolean> {
try {
await this.insertOne({
_id: crypto.createHash('sha256').update(JSON.stringify({ issuer, assertionId })).digest('hex'),
assertionId,
issuer,
expireAt,
createdAt: new Date(),
});
return true;
} catch (error: unknown) {
if (typeof error === 'object' && error !== null && (error as MongoServerError).code === DUPLICATE_KEY_ERROR_CODE) {
return false;
}
throw error;
}
}
}

View File

@@ -0,0 +1,98 @@
import type {
ExtractDataToParams,
IAuditServerActor,
IServerEvent,
IServerEvents,
RocketChatRecordDeleted,
} from '@rocket.chat/core-typings';
import { ServerEventType } from '@rocket.chat/core-typings';
import type { IServerEventsModel } from '@rocket.chat/model-typings';
import type { Collection, Db, IndexDescription } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class ServerEventsRaw extends BaseRaw<IServerEvent> implements IServerEventsModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IServerEvent>>) {
super(db, 'server_events', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [{ key: { t: 1, ip: 1, ts: -1 } }, { key: { 't': 1, 'u.username': 1, 'ts': -1 } }];
}
async findLastFailedAttemptByIp(ip: string): Promise<IServerEvent | null> {
return this.findOne<IServerEvent>(
{
ip,
t: ServerEventType.FAILED_LOGIN_ATTEMPT,
},
{ sort: { ts: -1 } },
);
}
async findLastFailedAttemptByUsername(username: string): Promise<IServerEvent | null> {
return this.findOne<IServerEvent>(
{
'u.username': username,
't': ServerEventType.FAILED_LOGIN_ATTEMPT,
},
{ sort: { ts: -1 } },
);
}
async findLastSuccessfulAttemptByIp(ip: string): Promise<IServerEvent | null> {
return this.findOne<IServerEvent>(
{
ip,
t: ServerEventType.LOGIN,
},
{ sort: { ts: -1 } },
);
}
async findLastSuccessfulAttemptByUsername(username: string): Promise<IServerEvent | null> {
return this.findOne<IServerEvent>(
{
'u.username': username,
't': ServerEventType.LOGIN,
},
{ sort: { ts: -1 } },
);
}
async countFailedAttemptsByUsernameSince(username: string, since: Date): Promise<number> {
return this.countDocuments({
'u.username': username,
't': ServerEventType.FAILED_LOGIN_ATTEMPT,
'ts': {
$gte: since,
},
});
}
countFailedAttemptsByIpSince(ip: string, since: Date): Promise<number> {
return this.countDocuments({
ip,
t: ServerEventType.FAILED_LOGIN_ATTEMPT,
ts: {
$gte: since,
},
});
}
async createAuditServerEvent<K extends keyof IServerEvents, E extends IServerEvents[K]>(
key: K,
data: ExtractDataToParams<E>,
actor: IAuditServerActor,
): Promise<void> {
await this.insertOne({
t: key,
ts: new Date(),
actor,
data: Object.entries(data).map(([key, value]) => ({ key, value })),
// deprecated just to keep backward compatibility
ip: '0.0.0.0',
...(actor.type === 'user' && { ip: actor?.ip || '0.0.0.0', u: { _id: actor._id, username: actor.username } }),
});
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,256 @@
import type { ISetting, ISettingColor, ISettingSelectOption, RocketChatRecordDeleted, SettingValue } from '@rocket.chat/core-typings';
import type { ISettingsModel, DocumentWithProjection, FindOptionsWithProjection } from '@rocket.chat/model-typings';
import type {
Collection,
FindCursor,
Db,
Filter,
UpdateFilter,
UpdateResult,
Document,
FindOneAndUpdateOptions,
WithId,
UpdateOptions,
} from 'mongodb';
import { BaseRaw } from './BaseRaw';
type PublicSettingFields<T extends ISetting> = T extends ISettingColor
? Pick<T, '_id' | 'value' | 'editor' | 'enterprise' | 'invalidValue' | 'modules' | 'requiredOnWizard'>
: Pick<T, '_id' | 'value' | 'enterprise' | 'invalidValue' | 'modules' | 'requiredOnWizard'>;
export class SettingsRaw extends BaseRaw<ISetting> implements ISettingsModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<ISetting>>) {
super(db, 'settings', trash);
}
async getValueById<T extends SettingValue = SettingValue>(_id: string): Promise<T | undefined> {
const setting = await this.findOne<Pick<ISetting, 'value'>>({ _id }, { projection: { value: 1 } });
return setting?.value as T;
}
findNotHidden({ updatedAfter }: { updatedAfter?: Date } = {}): FindCursor<ISetting> {
const query: Filter<ISetting> = {
hidden: { $ne: true },
};
if (updatedAfter) {
query._updatedAt = { $gt: updatedAfter };
}
return this.find(query);
}
findOneNotHiddenById(_id: string): Promise<ISetting | null> {
const query = {
_id,
hidden: { $ne: true },
};
return this.findOne(query);
}
findByIds<T extends Document = ISetting, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
_id: string[] | string = [],
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
if (typeof _id === 'string') {
_id = [_id];
}
const query = {
_id: {
$in: _id,
},
};
return this.find<T, O>(query, options);
}
updateValueById(
_id: string,
value: (ISetting['value'] extends undefined ? never : ISetting['value']) | null,
options?: UpdateOptions,
): Promise<UpdateResult> {
const query = {
blocked: { $ne: true },
value: { $ne: value },
_id,
};
const update = {
$set: {
value,
},
};
return this.updateOne(query, update, options);
}
async resetValueById(
_id: string,
value?: (ISetting['value'] extends undefined ? never : ISetting['value']) | null,
): Promise<Document | UpdateResult | undefined> {
if (value == null) {
const record = await this.findOneById(_id);
if (record) {
const prop = record.valueSource || 'packageValue';
value = record[prop];
}
}
if (value == null) {
return;
}
return this.updateValueById(_id, value);
}
async incrementValueById(
_id: ISetting['_id'],
value?: ISetting['value'],
options?: FindOneAndUpdateOptions,
): Promise<null | WithId<ISetting>> {
return this.findOneAndUpdate(
{
blocked: { $ne: true },
_id,
},
{
$inc: {
value: value || 1,
},
} as unknown as UpdateFilter<ISetting>,
options,
);
}
updateOptionsById<T extends ISetting = ISetting>(
_id: ISetting['_id'],
options: UpdateFilter<T>['$set'],
): Promise<Document | UpdateResult> {
const query = {
blocked: { $ne: true },
_id,
};
const update = { $set: options };
return this.updateOne(query, update);
}
updateValueNotHiddenById<T extends ISetting['value'] = ISetting['value']>(
_id: ISetting['_id'],
value: T,
): Promise<Document | UpdateResult> {
const query = {
_id,
hidden: { $ne: true },
blocked: { $ne: true },
};
const update = {
$set: {
value,
},
};
return this.updateOne(query, update);
}
updateValueAndEditorById<T extends ISetting['value'] = ISetting['value']>(
_id: ISetting['_id'],
value: T,
editor: ISettingColor['editor'],
): Promise<Document | UpdateResult> {
const query = {
blocked: { $ne: true },
value: { $ne: value },
_id,
};
const update = {
$set: {
value,
editor,
},
};
return this.updateOne(query, update);
}
findNotHiddenPublic<T extends ISetting = ISetting>(ids: ISetting['_id'][] = []): FindCursor<PublicSettingFields<T>> {
const filter: Filter<ISetting> = {
hidden: { $ne: true },
public: true,
};
if (ids.length > 0) {
filter._id = { $in: ids };
}
// the projection below matches PublicSettingFields, but TypeScript cannot check that against
// a conditional type whose input is still a type parameter
return this.find(filter, {
projection: {
_id: 1,
value: 1,
editor: 1,
enterprise: 1,
invalidValue: 1,
modules: 1,
requiredOnWizard: 1,
},
}) as unknown as FindCursor<PublicSettingFields<T>>;
}
findSetupWizardSettings(): FindCursor<ISetting> {
return this.find({ wizard: { $exists: true } });
}
addOptionValueById(_id: ISetting['_id'], option: ISettingSelectOption): Promise<Document | UpdateResult> {
const query = {
blocked: { $ne: true },
_id,
};
const { key, i18nLabel } = option;
const update = {
$addToSet: {
values: {
key,
i18nLabel,
},
},
};
return this.updateOne(query, update);
}
findNotHiddenPublicUpdatedAfter(updatedAt: Date): FindCursor<ISetting> {
const filter = {
hidden: { $ne: true },
public: true,
_updatedAt: {
$gt: updatedAt,
},
};
return this.find(filter, {
projection: {
_id: 1,
value: 1,
editor: 1,
enterprise: 1,
invalidValue: 1,
modules: 1,
requiredOnWizard: 1,
},
});
}
findEnterpriseSettings(): FindCursor<ISetting> {
return this.find({ enterprise: true });
}
}

View File

@@ -0,0 +1,11 @@
import type { ISmarshHistory, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { ISmarshHistoryModel } from '@rocket.chat/model-typings';
import type { Db, Collection } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class SmarshHistoryRaw extends BaseRaw<ISmarshHistory> implements ISmarshHistoryModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<ISmarshHistory>>) {
super(db, 'smarsh_history', trash);
}
}

View File

@@ -0,0 +1,93 @@
import type { IStats } from '@rocket.chat/core-typings';
import type { IStatisticsModel } from '@rocket.chat/model-typings';
import type { Db, IndexDescription } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class StatisticsRaw extends BaseRaw<IStats> implements IStatisticsModel {
constructor(db: Db) {
super(db, 'statistics');
}
protected override modelIndexes(): IndexDescription[] {
return [
{ key: { createdAt: -1 } },
{ key: { createdAt: 1 }, expireAfterSeconds: 365 * 24 * 60 * 60 }, // expire after 1 year
];
}
async findLast(): Promise<IStats> {
const records = await this.find(
{},
{
sort: {
createdAt: -1,
},
limit: 1,
},
).toArray();
return records?.[0];
}
async findLastStatsToken(): Promise<IStats['statsToken']> {
const records = await this.find(
{
statsToken: { $exists: true },
},
{
sort: {
createdAt: -1,
},
projection: {
statsToken: 1,
},
limit: 1,
},
).toArray();
return records?.[0]?.statsToken;
}
async findMonthlyPeakConnections() {
const oneMonthAgo = new Date();
oneMonthAgo.setDate(oneMonthAgo.getDate() - 30);
oneMonthAgo.setHours(0, 0, 0, 0);
return this.findOne<Pick<IStats, 'dailyPeakConnections' | 'createdAt'>>(
{
createdAt: { $gte: oneMonthAgo },
},
{
sort: {
dailyPeakConnections: -1,
},
projection: {
dailyPeakConnections: 1,
createdAt: 1,
},
},
);
}
async findInstallationDates() {
return this.col
.aggregate<Pick<IStats, 'version' | 'installedAt'>>([
{
$group: {
_id: '$version',
installedAt: { $min: '$installedAt' },
},
},
{
$project: {
_id: 0,
version: '$_id',
installedAt: 1,
},
},
{
$sort: { installedAt: 1 },
},
])
.toArray();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,212 @@
import type { ITeam, RocketChatRecordDeleted, TeamType } from '@rocket.chat/core-typings';
import type { FindPaginated, ITeamModel, DocumentWithProjection, FindOptionsWithProjection } from '@rocket.chat/model-typings';
import type { Collection, FindCursor, Db, DeleteResult, Document, Filter, IndexDescription, UpdateResult, FindOptions } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class TeamRaw extends BaseRaw<ITeam> implements ITeamModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<ITeam>>) {
super(db, 'team', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [{ key: { name: 1 }, unique: true }];
}
findByNames(names: Array<string>): FindCursor<ITeam>;
findByNames(names: Array<string>, options: FindOptions<ITeam>): FindCursor<ITeam>;
findByNames<P extends Document>(names: Array<string>, options: FindOptions<P extends ITeam ? ITeam : P>): FindCursor<P>;
findByNames<P extends Document>(
names: Array<string>,
options?: FindOptions<ITeam> | FindOptions<P extends ITeam ? ITeam : P>,
): FindCursor<P> | FindCursor<ITeam> {
if (options === undefined) {
return this.col.find({ name: { $in: names } });
}
return this.col.find({ name: { $in: names } }, options);
}
findByIds<P extends Document = ITeam, O extends FindOptionsWithProjection<P> = FindOptionsWithProjection<P>>(
ids: Array<string>,
options?: O,
query?: Filter<ITeam>,
): FindCursor<DocumentWithProjection<P, O>> {
if (options === undefined) {
return this.find<P, O>({ ...query, _id: { $in: ids } });
}
return this.find<P, O>({ ...query, _id: { $in: ids } }, options);
}
findByIdsPaginated<T extends Document = ITeam, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
ids: Array<string>,
options?: O,
query?: Filter<ITeam>,
): FindPaginated<FindCursor<DocumentWithProjection<T, O>>> {
if (options === undefined) {
return this.findPaginated<T, O>({ ...query, _id: { $in: ids } });
}
return this.findPaginated<T, O>({ ...query, _id: { $in: ids } }, options);
}
findByIdsAndType(ids: Array<string>, type: TeamType): FindCursor<ITeam>;
findByIdsAndType(ids: Array<string>, type: TeamType, options: FindOptions<ITeam>): FindCursor<ITeam>;
findByIdsAndType<P extends Document>(
ids: Array<string>,
type: TeamType,
options: FindOptions<P extends ITeam ? ITeam : P>,
): FindCursor<P>;
findByIdsAndType<P extends Document>(
ids: Array<string>,
type: TeamType,
options?: FindOptions<ITeam> | FindOptions<P extends ITeam ? ITeam : P>,
): FindCursor<P> | FindCursor<ITeam> {
if (options === undefined) {
return this.col.find({ _id: { $in: ids }, type });
}
return this.col.find({ _id: { $in: ids }, type }, options);
}
findByType(type: number): FindCursor<ITeam>;
findByType(type: number, options: FindOptions<ITeam>): FindCursor<ITeam>;
findByType<P extends Document>(type: number, options: FindOptions<P extends ITeam ? ITeam : P>): FindCursor<P>;
findByType<P extends Document>(
type: number,
options?: FindOptions<ITeam> | FindOptions<P extends ITeam ? ITeam : P>,
): FindCursor<ITeam> | FindCursor<P> {
if (options === undefined) {
return this.col.find({ type }, options);
}
return this.col.find({ type }, options);
}
findByNameAndTeamIds(name: string | RegExp, teamIds: Array<string>): FindCursor<ITeam>;
findByNameAndTeamIds(name: string | RegExp, teamIds: Array<string>, options: FindOptions<ITeam>): FindCursor<ITeam>;
findByNameAndTeamIds<P extends Document>(
name: string | RegExp,
teamIds: Array<string>,
options: FindOptions<P extends ITeam ? ITeam : P>,
): FindCursor<P>;
findByNameAndTeamIds<P extends Document>(
name: string | RegExp,
teamIds: Array<string>,
options?: FindOptions<ITeam> | FindOptions<P extends ITeam ? ITeam : P>,
): FindCursor<P> | FindCursor<ITeam> {
if (options === undefined) {
return this.col.find({
name,
$or: [
{
type: 0,
},
{
_id: {
$in: teamIds,
},
},
],
});
}
return this.col.find(
{
name,
$or: [
{
type: 0,
},
{
_id: {
$in: teamIds,
},
},
],
},
options,
);
}
findOneByName(name: string | RegExp): Promise<ITeam | null>;
findOneByName(name: string | RegExp, options: FindOptions<ITeam>): Promise<ITeam | null>;
findOneByName<P extends Document>(name: string | RegExp, options: FindOptions<P>): Promise<P | null>;
findOneByName<P extends Document>(
name: string | RegExp,
options?: FindOptions<ITeam> | FindOptions<P extends ITeam ? ITeam : P>,
): Promise<P | null> | Promise<ITeam | null> {
if (options === undefined) {
return this.col.findOne({ name });
}
return this.col.findOne({ name }, options);
}
findOneByMainRoomId(roomId: string): Promise<ITeam | null>;
findOneByMainRoomId(roomId: string, options: FindOptions<ITeam>): Promise<ITeam | null>;
findOneByMainRoomId<P extends Document>(roomId: string, options: FindOptions<P>): Promise<P | null>;
findOneByMainRoomId<P extends Document>(
roomId: string,
options?: FindOptions<ITeam> | FindOptions<P extends ITeam ? ITeam : P>,
): Promise<P | null> | Promise<ITeam | null> {
return options ? this.col.findOne({ roomId }, options) : this.col.findOne({ roomId });
}
updateMainRoomForTeam(id: string, roomId: string): Promise<UpdateResult> {
return this.updateOne(
{
_id: id,
},
{
$set: {
roomId,
},
},
);
}
deleteOneById(id: string): Promise<DeleteResult> {
return this.col.deleteOne({
_id: id,
});
}
deleteOneByName(name: string): Promise<DeleteResult> {
return this.col.deleteOne({ name });
}
updateNameAndType(teamId: string, nameAndType: { name?: string; type?: TeamType }): Promise<UpdateResult> {
const query = {
_id: teamId,
};
const update = {
$set: {},
};
if (nameAndType.name) {
Object.assign(update.$set, { name: nameAndType.name });
}
if (typeof nameAndType.type !== 'undefined') {
Object.assign(update.$set, { type: nameAndType.type });
}
return this.updateOne(query, update);
}
}

View File

@@ -0,0 +1,186 @@
import type { IRole, ITeamMember, IUser, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { FindPaginated, ITeamMemberModel } from '@rocket.chat/model-typings';
import type {
Collection,
FindCursor,
Db,
DeleteResult,
Document,
Filter,
FindOptions,
IndexDescription,
InsertOneResult,
UpdateResult,
} from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class TeamMemberRaw extends BaseRaw<ITeamMember> implements ITeamMemberModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<ITeamMember>>) {
super(db, 'team_member', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [
{
key: { teamId: 1 },
},
{
key: { teamId: 1, userId: 1 },
unique: true,
},
];
}
findByUserId(userId: string): FindCursor<ITeamMember>;
findByUserId(userId: string, options: FindOptions<ITeamMember>): FindCursor<ITeamMember>;
findByUserId<P extends Document>(userId: string, options: FindOptions<P>): FindCursor<P>;
findByUserId<P extends Document>(
userId: string,
options?: FindOptions<ITeamMember> | FindOptions<P extends ITeamMember ? ITeamMember : P>,
): FindCursor<P> | FindCursor<ITeamMember> {
return options ? this.col.find({ userId }, options) : this.col.find({ userId }, options);
}
findOneByUserIdAndTeamId(userId: string, teamId: string): Promise<ITeamMember | null>;
findOneByUserIdAndTeamId(userId: string, teamId: string, options: FindOptions<ITeamMember>): Promise<ITeamMember | null>;
findOneByUserIdAndTeamId<P extends Document>(userId: string, teamId: string, options: FindOptions<P>): Promise<P | null>;
findOneByUserIdAndTeamId<P extends Document>(
userId: string,
teamId: string,
options?: FindOptions<ITeamMember> | FindOptions<P extends ITeamMember ? ITeamMember : P>,
): Promise<P | null | ITeamMember> {
return options ? this.col.findOne({ userId, teamId }, options) : this.col.findOne({ userId, teamId }, options);
}
findByTeamId(teamId: string): FindCursor<ITeamMember>;
findByTeamId(teamId: string, options: FindOptions<ITeamMember>): FindCursor<ITeamMember>;
findByTeamId<P extends Document>(teamId: string, options: FindOptions<P>): FindCursor<P>;
findByTeamId<P extends Document>(
teamId: string,
options?: FindOptions<ITeamMember> | FindOptions<P extends ITeamMember ? ITeamMember : P>,
): FindCursor<P> | FindCursor<ITeamMember> {
return options ? this.col.find({ teamId }, options) : this.col.find({ teamId }, options);
}
countByTeamId(teamId: string): Promise<number> {
return this.countDocuments({ teamId });
}
findByTeamIds(teamIds: Array<string>): FindCursor<ITeamMember>;
findByTeamIds(teamIds: Array<string>, options: FindOptions<ITeamMember>): FindCursor<ITeamMember>;
findByTeamIds<P extends Document>(teamIds: Array<string>, options: FindOptions<P>): FindCursor<P>;
findByTeamIds<P extends Document>(
teamIds: Array<string>,
options?: FindOptions<ITeamMember> | FindOptions<P extends ITeamMember ? ITeamMember : P>,
): FindCursor<P> | FindCursor<ITeamMember> {
return options ? this.col.find({ teamId: { $in: teamIds } }, options) : this.col.find({ teamId: { $in: teamIds } }, options);
}
countByTeamIdAndRole(teamId: string, role: IRole['_id']): Promise<number> {
return this.countDocuments({ teamId, roles: role });
}
findByUserIdAndTeamIds(userId: string, teamIds: Array<string>, options: FindOptions<ITeamMember> = {}): FindCursor<ITeamMember> {
const query = {
userId,
teamId: {
$in: teamIds,
},
};
return this.col.find(query, options);
}
findPaginatedMembersInfoByTeamId(
teamId: string,
limit: number,
skip: number,
query?: Filter<ITeamMember>,
): FindPaginated<FindCursor<Pick<ITeamMember, '_id' | 'userId' | 'roles' | 'createdBy' | 'createdAt'>>> {
return this.findPaginated(
{ ...query, teamId },
{
limit,
skip,
projection: {
userId: 1,
roles: 1,
createdBy: 1,
createdAt: 1,
},
},
);
}
updateOneByUserIdAndTeamId(userId: string, teamId: string, update: Partial<ITeamMember>): Promise<UpdateResult> {
return this.updateOne({ userId, teamId }, { $set: update });
}
createOneByTeamIdAndUserId(
teamId: string,
userId: string,
createdBy: Pick<IUser, '_id' | 'username'>,
): Promise<InsertOneResult<ITeamMember>> {
return this.insertOne({
teamId,
userId,
createdAt: new Date(),
createdBy,
_updatedAt: new Date(),
});
}
updateRolesByTeamIdAndUserId(teamId: string, userId: string, roles: Array<IRole['_id']>): Promise<UpdateResult> {
return this.updateOne(
{
teamId,
userId,
},
{
$addToSet: {
roles: { $each: roles },
},
},
);
}
removeRolesByTeamIdAndUserId(teamId: string, userId: string, roles: Array<IRole['_id']>): Promise<UpdateResult> {
return this.updateOne(
{
teamId,
userId,
},
{
$pull: {
roles: { $in: roles },
},
},
);
}
deleteByUserIdAndTeamId(userId: string, teamId: string): Promise<DeleteResult> {
return this.col.deleteOne({
teamId,
userId,
});
}
deleteByTeamId(teamId: string): Promise<DeleteResult> {
return this.col.deleteMany({
teamId,
});
}
}

View File

@@ -0,0 +1,22 @@
import type { RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { Db, IndexDescription } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class TrashRaw extends BaseRaw<RocketChatRecordDeleted<any>> {
constructor(db: Db) {
super(db, 'rocketchat__trash', undefined, {
collectionNameResolver(name) {
return name;
},
});
}
protected override modelIndexes(): IndexDescription[] | undefined {
return [
{ key: { __collection__: 1 } },
{ key: { _deletedAt: 1 }, expireAfterSeconds: 60 * 60 * 24 * 30 },
{ key: { rid: 1, __collection__: 1, _deletedAt: 1 } },
];
}
}

View File

@@ -0,0 +1,41 @@
import { randomBytes } from 'crypto';
import type { ITwoFactorChallenge } from '@rocket.chat/core-typings';
import type { ITwoFactorChallengesModel, DocumentWithProjection, FindOptionsWithProjection } from '@rocket.chat/model-typings';
import type { Db, IndexDescription, Document } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class TwoFactorChallengesRaw extends BaseRaw<ITwoFactorChallenge> implements ITwoFactorChallengesModel {
constructor(db: Db) {
super(db, 'two_factor_challenges');
}
override modelIndexes(): IndexDescription[] {
return [{ key: { expireAt: 1 }, expireAfterSeconds: 0 }];
}
findOneByPendingChallengeId<
T extends Document = ITwoFactorChallenge,
O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>,
>(pendingChallengeId: string, options?: O): Promise<DocumentWithProjection<T, O> | null> {
return this.findOne<T, O>({ _id: pendingChallengeId }, options);
}
removeByPendingChallengeId(pendingChallengeId: string) {
return this.deleteOne({ _id: pendingChallengeId });
}
async createTwoFactorChallenge(userId: string, method: ITwoFactorChallenge['method']): Promise<string> {
const now = new Date();
const challengeId = randomBytes(32).toString('hex');
await this.insertOne({
_id: challengeId,
userId,
method,
createdAt: now,
expireAt: new Date(now.getTime() + 1000 * 60 * 5),
});
return challengeId;
}
}

View File

@@ -0,0 +1,73 @@
import type { IUpload, RocketChatRecordDeleted, IRoom } from '@rocket.chat/core-typings';
import type { FindPaginated, IUploadsModel, DocumentWithProjection, FindOptionsWithProjection } from '@rocket.chat/model-typings';
import type { Collection, FindCursor, Db, IndexDescription, WithId, Filter, FindOptions, UpdateResult, Document } from 'mongodb';
import { BaseUploadModelRaw } from './BaseUploadModel';
export class UploadsRaw extends BaseUploadModelRaw implements IUploadsModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IUpload>>) {
super(db, 'uploads', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [
...super.modelIndexes(),
{ key: { uploadedAt: -1 } },
{ key: { rid: 1, _hidden: 1, typeGroup: 1 } },
{ key: { 'federation.mediaId': 1, 'federation.serverName': 1 }, unique: true, sparse: true },
];
}
findByFederationMediaIdAndServerName(mediaId: string, serverName: string): Promise<IUpload | null> {
return this.findOne({ 'federation.mediaId': mediaId, 'federation.serverName': serverName });
}
setFederationInfo(fileId: IUpload['_id'], info: Required<IUpload>['federation']): Promise<UpdateResult> {
return this.updateOne({ _id: fileId }, { $set: { federation: info } });
}
setFederationRoomInfo(fileId: IUpload['_id'], rid: IRoom['_id'], mrid: string): Promise<UpdateResult> {
return this.updateOne({ _id: fileId }, { $set: { rid, 'federation.mrid': mrid } });
}
findPaginatedWithoutThumbs(query: Filter<IUpload> = {}, options?: FindOptions<IUpload>): FindPaginated<FindCursor<WithId<IUpload>>> {
return this.findPaginated(
{
typeGroup: { $ne: 'thumb' },
...query,
_hidden: { $ne: true },
},
options,
);
}
findImagesByRoomId(
rid: IRoom['_id'],
uploadedAt?: Date,
options: Omit<FindOptions<IUpload>, 'sort'> = {},
): FindPaginated<FindCursor<WithId<IUpload>>> {
return this.findPaginated(
{
rid,
_hidden: { $ne: true },
typeGroup: 'image',
...(Boolean(uploadedAt) && {
uploadedAt: {
$lte: uploadedAt,
},
}),
},
{
...options,
sort: { uploadedAt: -1 },
},
);
}
findAllByOriginalFileId<T extends Document = IUpload, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
originalFileId: string,
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
return this.find<T, O>({ originalFileId }, options);
}
}

View File

@@ -0,0 +1,28 @@
import type { IUserDataFile, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IUserDataFilesModel, DocumentWithProjection, FindOptionsWithProjection } from '@rocket.chat/model-typings';
import type { Collection, Db, IndexDescription, Document } from 'mongodb';
import { BaseUploadModelRaw } from './BaseUploadModel';
export class UserDataFilesRaw extends BaseUploadModelRaw implements IUserDataFilesModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IUserDataFile>>) {
super(db, 'user_data_files', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [...super.modelIndexes(), { key: { userId: 1 } }];
}
findLastFileByUser<T extends Document = IUserDataFile, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
userId: string,
options?: O,
): Promise<DocumentWithProjection<T, O> | null> {
const query = {
userId,
};
// merging into `O` is a lie only about `sort`; `DocumentWithProjection` reads `O['projection']`
// alone, and that comes straight from `options`, so the declared return type stays accurate
return this.findOne<T, O>(query, { ...options, sort: { _updatedAt: -1 } } as unknown as O);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,119 @@
import type { IUserSession, IUserSessionConnection, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IUsersSessionsModel, DocumentWithProjection, FindOptionsWithProjection } from '@rocket.chat/model-typings';
import type { FindCursor, Collection, Db, Document } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class UsersSessionsRaw extends BaseRaw<IUserSession> implements IUsersSessionsModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IUserSession>>) {
super(db, 'usersSessions', trash, {
preventSetUpdatedAt: true,
collectionNameResolver(name) {
return name;
},
});
}
updateConnectionStatusById(uid: string, connectionId: string, status: string): ReturnType<BaseRaw<IUserSession>['updateOne']> {
const query = {
'_id': uid,
'connections.id': connectionId,
};
const update = {
$set: {
'connections.$.status': status,
'connections.$._updatedAt': new Date(),
},
};
return this.updateOne(query, update);
}
async removeConnectionsFromInstanceId(instanceId: string): ReturnType<BaseRaw<IUserSession>['updateMany']> {
return this.updateMany(
{
'connections.instanceId': instanceId,
},
{
$pull: {
connections: {
instanceId,
},
},
},
);
}
removeConnectionsFromOtherInstanceIds(instanceIds: string[]): ReturnType<BaseRaw<IUserSession>['updateMany']> {
return this.updateMany(
{},
{
$pull: {
connections: {
instanceId: {
$nin: instanceIds,
},
},
},
},
);
}
async removeConnectionByConnectionId(connectionId: string): ReturnType<BaseRaw<IUserSession>['updateMany']> {
return this.updateMany(
{
'connections.id': connectionId,
},
{
$pull: {
connections: {
id: connectionId,
},
},
},
);
}
findByInstanceId(instanceId: string): FindCursor<IUserSession> {
return this.find({
'connections.instanceId': instanceId,
});
}
addConnectionById(
userId: string,
{ id, instanceId, status }: Pick<IUserSessionConnection, 'id' | 'instanceId' | 'status'>,
): ReturnType<BaseRaw<IUserSession>['updateOne']> {
const now = new Date();
const update = {
$push: {
connections: {
id,
instanceId,
status,
_createdAt: now,
_updatedAt: now,
},
},
};
return this.updateOne({ _id: userId }, update, { upsert: true });
}
findByOtherInstanceIds<T extends Document = IUserSession, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
instanceIds: string[],
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
return this.find<T, O>(
{
'connections.instanceId': {
$exists: true,
$nin: instanceIds,
},
},
options,
);
}
}

View File

@@ -0,0 +1,307 @@
import type {
VideoConference,
IGroupVideoConference,
ILivechatVideoConference,
IUser,
IRoom,
RocketChatRecordDeleted,
IVoIPVideoConference,
} from '@rocket.chat/core-typings';
import { VideoConferenceStatus } from '@rocket.chat/core-typings';
import type { FindPaginated, InsertionModel, IVideoConferenceModel } from '@rocket.chat/model-typings';
import type {
FindCursor,
UpdateOptions,
UpdateFilter,
UpdateResult,
IndexDescription,
Collection,
Db,
CountDocumentsOptions,
} from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class VideoConferenceRaw extends BaseRaw<VideoConference> implements IVideoConferenceModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<VideoConference>>) {
super(db, 'video_conference', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [
{ key: { rid: 1, createdAt: 1 }, unique: false },
{ key: { type: 1, status: 1 }, unique: false },
{ key: { discussionRid: 1 }, unique: false },
];
}
public findPaginatedByRoomId(
rid: IRoom['_id'],
{ offset, count }: { offset?: number; count?: number } = {},
): FindPaginated<FindCursor<VideoConference>> {
// No data is lost — `providerData` is optional — but `Omit` over the `VideoConference` union collapses it into a single
// object type, so the explicit type argument opts out of projection inference to preserve the discriminated union.
return this.findPaginated<VideoConference>(
{ rid },
{
sort: { createdAt: -1 },
skip: offset,
limit: count,
projection: {
providerData: 0,
},
},
);
}
public async findAllLongRunning(minDate: Date): Promise<FindCursor<Pick<VideoConference, '_id'>>> {
return this.find(
{
createdAt: {
$lte: minDate,
},
endedAt: {
$exists: false,
},
},
{
projection: {
_id: 1,
},
},
);
}
public async countByTypeAndStatus(
type: VideoConference['type'],
status: VideoConferenceStatus,
options: CountDocumentsOptions,
): Promise<number> {
return this.countDocuments(
{
type,
status,
},
options,
);
}
public async createDirect({
providerName,
...callDetails
}: Pick<VideoConference, 'rid' | 'createdBy' | 'providerName'>): Promise<string> {
const call: InsertionModel<VideoConference> = {
type: 'direct',
users: [],
messages: {},
status: VideoConferenceStatus.CALLING,
createdAt: new Date(),
providerName: providerName.toLowerCase(),
ringing: true,
...callDetails,
};
return (await this.insertOne(call)).insertedId;
}
public async createGroup({
providerName,
...callDetails
}: Required<Pick<IGroupVideoConference, 'rid' | 'title' | 'createdBy' | 'providerName' | 'ringing'>>): Promise<string> {
const call: InsertionModel<IGroupVideoConference> = {
type: 'videoconference',
users: [],
messages: {},
status: VideoConferenceStatus.STARTED,
anonymousUsers: 0,
createdAt: new Date(),
providerName: providerName.toLowerCase(),
...callDetails,
};
return (await this.insertOne(call)).insertedId;
}
public async createLivechat({
providerName,
...callDetails
}: Required<Pick<ILivechatVideoConference, 'rid' | 'createdBy' | 'providerName'>>): Promise<string> {
const call: InsertionModel<ILivechatVideoConference> = {
type: 'livechat',
users: [],
messages: {},
status: VideoConferenceStatus.STARTED,
createdAt: new Date(),
providerName: providerName.toLowerCase(),
...callDetails,
};
return (await this.insertOne(call)).insertedId;
}
public async createVoIP(call: InsertionModel<IVoIPVideoConference>): Promise<string | undefined> {
const { externalId, ...data } = call;
const doc = await this.findOneAndUpdate({ externalId }, { $set: data }, { upsert: true, returnDocument: 'after' });
return doc?._id;
}
public updateOneById(
_id: string,
update: UpdateFilter<VideoConference> | Partial<VideoConference>,
options?: UpdateOptions,
): Promise<UpdateResult> {
return this.updateOne({ _id }, update, options);
}
public async setEndedById(callId: string, endedBy?: { _id: string; name: string; username: string }, endedAt?: Date): Promise<void> {
await this.updateOneById(callId, {
$set: {
endedBy,
endedAt: endedAt || new Date(),
},
});
}
public async setDataById(callId: string, data: Partial<Omit<VideoConference, '_id'>>): Promise<void> {
await this.updateOneById(callId, {
$set: data,
});
}
public async setRingingById(callId: string, ringing: boolean): Promise<void> {
await this.updateOneById(callId, {
$set: {
ringing,
},
});
}
public async setStatusById(callId: string, status: VideoConference['status']): Promise<void> {
await this.updateOneById(callId, {
$set: {
status,
},
});
}
public async setUrlById(callId: string, url: string): Promise<void> {
await this.updateOneById(callId, {
$set: {
url,
},
});
}
public async setProviderDataById(callId: string, providerData: Record<string, any> | undefined): Promise<void> {
await this.updateOneById(callId, {
...(providerData
? {
$set: {
providerData,
},
}
: {
$unset: {
providerData: 1 as const,
},
}),
});
}
public async addUserById(
callId: string,
user: Required<Pick<IUser, '_id' | 'name' | 'username' | 'avatarETag'>> & { ts?: Date },
): Promise<void> {
await this.updateOneById(callId, {
$addToSet: {
users: {
_id: user._id,
username: user.username,
name: user.name,
avatarETag: user.avatarETag,
ts: user.ts || new Date(),
},
},
});
}
public async setMessageById(callId: string, messageType: keyof VideoConference['messages'], messageId: string): Promise<void> {
await this.updateOneById(callId, {
$set: {
[`messages.${messageType}`]: messageId,
},
}); // TODO: Remove this cast when TypeScript is updated
// TypeScript is not smart enough to infer that `messages.${'start' | 'end'}` matches two keys of `VideoConference`
}
public async updateUserReferences(userId: IUser['_id'], username: IUser['username'], name: IUser['name']): Promise<void> {
await this.updateMany(
{
'users._id': userId,
},
{
$set: {
'users.$.name': name,
'users.$.username': username,
},
},
);
await this.updateMany(
{
'createdBy._id': userId,
},
{
$set: {
'createdBy.name': name,
'createdBy.username': username,
},
},
);
await this.updateMany(
{
'endedBy._id': userId,
},
{
$set: {
'endedBy.name': name,
'endedBy.username': username,
},
},
);
}
public async increaseAnonymousCount(callId: IGroupVideoConference['_id']): Promise<void> {
await this.updateOne(
{ _id: callId },
{
$inc: {
anonymousUsers: 1,
},
},
);
}
public async setDiscussionRidById(callId: string, discussionRid: IRoom['_id']): Promise<void> {
await this.updateOne({ _id: callId }, { $set: { discussionRid } });
}
public async unsetDiscussionRidById(callId: string): Promise<void> {
await this.updateOne({ _id: callId }, { $unset: { discussionRid: true } });
}
public async unsetDiscussionRid(discussionRid: IRoom['_id']): Promise<void> {
await this.updateMany(
{
discussionRid,
},
{
$unset: {
discussionRid: 1,
},
},
);
}
}

View File

@@ -0,0 +1,53 @@
import type { IWebdavAccount, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IWebdavAccountsModel, DocumentWithProjection, FindOptionsWithProjection } from '@rocket.chat/model-typings';
import type { Collection, FindCursor, Db, DeleteResult, IndexDescription, Document } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class WebdavAccountsRaw extends BaseRaw<IWebdavAccount> implements IWebdavAccountsModel {
constructor(db: Db, trash?: Collection<RocketChatRecordDeleted<IWebdavAccount>>) {
super(db, 'webdav_accounts', trash);
}
protected override modelIndexes(): IndexDescription[] {
return [{ key: { userId: 1 } }];
}
findOneByIdAndUserId<T extends Document = IWebdavAccount, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
_id: string,
userId: string,
options?: O,
): Promise<DocumentWithProjection<T, O> | null> {
return this.findOne<T, O>({ _id, userId }, options);
}
findOneByUserIdServerUrlAndUsername<
T extends Document = IWebdavAccount,
O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>,
>(
{
userId,
serverURL,
username,
}: {
userId: string;
serverURL: string;
username: string;
},
options?: O,
): Promise<DocumentWithProjection<T, O> | null> {
return this.findOne<T, O>({ userId, serverURL, username }, options);
}
findWithUserId<T extends Document = IWebdavAccount, O extends FindOptionsWithProjection<T> = FindOptionsWithProjection<T>>(
userId: string,
options?: O,
): FindCursor<DocumentWithProjection<T, O>> {
const query = { userId };
return this.find<T, O>(query, options);
}
removeByUserAndId(_id: string, userId: string): Promise<DeleteResult> {
return this.deleteOne({ _id, userId });
}
}

View File

@@ -0,0 +1,47 @@
import type { IWorkspaceCredentials } from '@rocket.chat/core-typings';
import type { IWorkspaceCredentialsModel } from '@rocket.chat/model-typings';
import type { Db, DeleteResult, Filter, IndexDescription, UpdateResult } from 'mongodb';
import { BaseRaw } from './BaseRaw';
export class WorkspaceCredentialsRaw extends BaseRaw<IWorkspaceCredentials> implements IWorkspaceCredentialsModel {
constructor(db: Db) {
super(db, 'workspace_credentials');
}
protected override modelIndexes(): IndexDescription[] {
return [{ key: { scope: 1, expirationDate: 1, accessToken: 1 }, unique: true }];
}
getCredentialByScope(scope = ''): Promise<IWorkspaceCredentials | null> {
const query: Filter<IWorkspaceCredentials> = { scope };
return this.findOne(query);
}
updateCredentialByScope({
scope,
accessToken,
expirationDate,
}: {
scope: string;
accessToken: string;
expirationDate: Date;
}): Promise<UpdateResult> {
const record = {
$set: {
scope,
accessToken,
expirationDate,
},
};
const query: Filter<IWorkspaceCredentials> = { scope };
return this.updateOne(query, record, { upsert: true });
}
removeAllCredentials(): Promise<DeleteResult> {
return this.col.deleteMany({});
}
}

View File

@@ -0,0 +1,8 @@
export function setUpdatedAt(record: Record<string, any>): void {
if (/(^|,)\$/.test(Object.keys(record).join(','))) {
record.$set = record.$set || {};
record.$set._updatedAt = new Date();
} else {
record._updatedAt = new Date();
}
}

View File

@@ -0,0 +1,91 @@
import { proxify, registerModel } from './proxify';
type MockedModel = {
method: () => MockedModel;
};
describe('non lazy proxify', () => {
it('should keep this inside functions', () => {
const collectionMocked = proxify('collection') as MockedModel;
const collection = {
method() {
return this;
},
};
registerModel<any>('collection', collection);
expect(collectionMocked.method()).toBe(collection);
});
it('should throw an error if the model is not found', () => {
const collectionMocked = proxify('collection-not-found') as MockedModel;
expect(() => collectionMocked.method()).toThrow('Model collection-not-found not found');
});
it('should return a proxified property', () => {
const collectionMocked = proxify('collection-prop') as {
prop: string;
};
const collection = {
prop: 'value',
};
registerModel<any>('collection-prop', collection);
expect(collectionMocked.prop).toBe('value');
});
it('should throw an error if trying to set a property from the proxified object', () => {
const collectionMocked = proxify('collection-prop') as {
prop: string;
};
const collection = {
prop: 'value',
};
registerModel<any>('collection-prop', collection);
expect(() => {
collectionMocked.prop = 'new value';
}).toThrow('Models accessed via proxify are read-only, use the model instance directly to modify it.');
});
});
describe('lazy proxify', () => {
it('should keep this inside functions', () => {
const collectionMocked = proxify('collection-lazy') as MockedModel;
const collection = {
method() {
return this;
},
};
registerModel<any>('collection-lazy', () => collection);
expect(collectionMocked.method()).toBe(collection);
});
it('should throw an error if the model is not found', () => {
const collectionMocked = proxify('collection-not-found') as MockedModel;
expect(() => collectionMocked.method()).toThrow('Model collection-not-found not found');
});
it('should return a proxified property', () => {
const collectionMocked = proxify('collection-prop') as {
prop: string;
};
const collection = {
prop: 'value',
};
registerModel<any>('collection-prop', () => collection);
expect(collectionMocked.prop).toBe('value');
});
it('should throw an error if trying to set a property from the proxified object', () => {
const collectionMocked = proxify('collection-prop') as {
prop: string;
};
const collection = {
prop: 'value',
};
registerModel<any>('collection-prop', () => collection);
expect(() => {
collectionMocked.prop = 'new value';
}).toThrow('Models accessed via proxify are read-only, use the model instance directly to modify it.');
});
});

View File

@@ -0,0 +1,59 @@
import type { IBaseModel } from '@rocket.chat/model-typings';
const lazyModels = new Map<string, () => IBaseModel<any>>();
const models = new Map<string, IBaseModel<any>>();
function handler<T extends object>(namespace: string): ProxyHandler<T> {
return {
get: (_target: T, nameProp: keyof IBaseModel<any>): any => {
if (!models.has(namespace) && lazyModels.has(namespace)) {
const getModel = lazyModels.get(namespace);
if (getModel) {
models.set(namespace, getModel());
}
}
const model = models.get(namespace);
if (!model) {
throw new Error(`Model ${namespace} not found`);
}
const prop = model[nameProp];
if (typeof prop === 'function') {
return prop.bind(model);
}
return prop;
},
set() {
if (process.env.NODE_ENV !== 'production') {
throw new Error('Models accessed via proxify are read-only, use the model instance directly to modify it.');
}
/* istanbul ignore next */
return true;
},
};
}
export function registerModel<TModel extends IBaseModel<any, any, any>>(
name: string,
instance: TModel | (() => TModel),
overwriteExisting = true,
): void {
if (!overwriteExisting && (lazyModels.has(name) || models.has(name))) {
return;
}
if (typeof instance === 'function') {
lazyModels.set(name, instance);
} else {
models.set(name, instance);
}
}
export function proxify<T>(namespace: string): T {
return new Proxy({}, handler(namespace)) as unknown as T;
}

View File

@@ -0,0 +1,18 @@
import type { Db, ReadPreferenceLike } from 'mongodb';
import { ReadPreference } from 'mongodb';
export function readSecondaryPreferred(db?: Db, tags: any[] = []): ReadPreferenceLike {
const { readPreference } = db?.options || {};
if (tags.length) {
return new ReadPreference(ReadPreference.SECONDARY_PREFERRED, tags);
}
if (readPreference && readPreference instanceof ReadPreference && readPreference.tags?.length) {
return new ReadPreference(ReadPreference.SECONDARY_PREFERRED, readPreference.tags);
}
// For some reason the new ReadPreference definition requires the tags parameter even not been
// required by the code implementation and, for some reason, when passing an empty array it
// doesn't ignore the tags and stop using the secondaries.
return ReadPreference.SECONDARY_PREFERRED;
}

View File

@@ -0,0 +1,191 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { UpdaterImpl } from './updater';
test('updater typings', () => {
const updater = new UpdaterImpl<{
_id: string;
t: 'l';
a: {
b: string;
};
c?: number;
d?: {
e: string;
};
e: string[];
}>();
// @ts-expect-error: it should not allow any string to `t` only `l` is allowed
updater.set('t', 'a');
// `l` is allowed
updater.set('t', 'l');
// `a` is { b: string }
updater.set('a', { b: 'test' });
updater.set('a.b', 'test');
// @ts-expect-error: it should not allow strings to `a`, a is an object containing `b: string`
updater.set('a', 'b');
// @ts-expect-error: `a` is not optional so unset is not allowed
updater.unset('a');
// @ts-expect-error: strings cannot be incremented
updater.inc('a', 1);
// `c` is number but it should be optional, so unset is allowed
updater.unset('c');
updater.set('c', 1);
// @ts-expect-error: `c` is a number
updater.set('c', 'b');
// inc is allowed for numbers
updater.inc('c', 1);
// `d` is { e: string } but it should be optional, so unset is allowed
updater.unset('d');
updater.set('d', { e: 'a' });
// @ts-expect-error: `d` is an object
updater.set('d', 'a');
// @ts-expect-error: it should not allow numbers, since e is a string
updater.addToSet('e', 1);
// @ts-expect-error: it should not allow strings, since a is an object
updater.addToSet('a', 'b');
updater.addToSet('e', 'a');
// @ts-expect-error: it should not allow `njame` its not specified in the model
updater.set('njame', 1);
// `d` is { e: string } and also it should be optional, so unset is allowed
updater.unset('d.e');
// @ts-expect-error: `d` is an object cannot be incremented
updater.inc('d', 1);
// `activity` is a string
const omnichannel = new UpdaterImpl<IOmnichannelRoom>();
omnichannel.addToSet('v.activity', 'asd');
// @ts-expect-error: it should not allow numbers, since activity is a string
omnichannel.addToSet('v.activity', 1);
// @ts-expect-error: it should not allow objects, since activity is a string
omnichannel.addToSet('v.activity', {
asdas: 1,
});
// @ts-expect-error: it should not allow sub properties, since activity is a string
omnichannel.addToSet('v.activity.asd', {
asdas: 1,
});
});
test('updater supports nested paths up to 3 levels deep', () => {
const updater = new UpdaterImpl<{
_id: string;
level1: {
level2: {
level3: string;
};
};
}>();
// 1-level deep path
updater.set('level1', { level2: { level3: 'test' } });
// 2-level deep path
updater.set('level1.level2', { level3: 'test' });
// 3-level deep path
updater.set('level1.level2.level3', 'test');
});
test('updater validates keys on real schemas with nested paths', () => {
const updater = new UpdaterImpl<IOmnichannelRoom>();
// valid 1-level paths
updater.set('fname', 'test');
// valid 2-level paths
updater.set('responseBy.lastMessageTs', new Date());
updater.set('v.activity', ['period1']);
// valid 3-level paths
updater.inc('metrics.response.total', 1);
updater.set('metrics.response.avg', 42);
updater.set('metrics.reaction.ft', 100);
// @ts-expect-error: non-existent top-level key
updater.set('nonExistentField', 'hello');
// @ts-expect-error: non-existent nested key
updater.set('responseBy.nonExistent', new Date());
// @ts-expect-error: 'fname' is a string, not a number — inc should reject it
updater.inc('fname', 1);
});
test('updater $set operations', async () => {
const updater = new UpdaterImpl<{
_id: string;
t: 'l';
a: {
b: string;
};
c?: number;
}>();
updater.set('a', {
b: 'set',
});
expect(updater.getUpdateFilter()).toEqual({ $set: { a: { b: 'set' } } });
});
test('updater $unset operations', async () => {
const updater = new UpdaterImpl<{
_id: string;
t: 'l';
a: {
b: string;
};
c?: number;
}>();
updater.unset('c');
expect(updater.getUpdateFilter()).toEqual({ $unset: { c: 1 } });
});
test('updater inc multiple operations', async () => {
const updater = new UpdaterImpl<{
_id: string;
t: 'l';
a: {
b: string;
};
c?: number;
}>();
updater.inc('c', 1);
updater.inc('c', 1);
expect(updater.getUpdateFilter()).toEqual({ $inc: { c: 2 } });
});
test('it should add items to array', async () => {
const updater = new UpdaterImpl<{
_id: string;
a: string[];
}>();
updater.addToSet('a', 'b');
updater.addToSet('a', 'c');
expect(updater.getUpdateFilter()).toEqual({ $addToSet: { a: { $each: ['b', 'c'] } } });
});
test('it should getUpdateFilter only once', async () => {
const updater = new UpdaterImpl<{
_id: string;
t: 'l';
a: {
b: string;
};
c?: number;
}>();
updater.set('a', {
b: 'set',
});
expect(updater.getUpdateFilter()).toEqual({ $set: { a: { b: 'set' } } });
expect(() => updater.getUpdateFilter()).toThrow();
});

View File

@@ -0,0 +1,78 @@
import type { Updater, SetProps, UnsetProps, IncProps, AddToSetProps } from '@rocket.chat/model-typings';
import type { UpdateFilter } from 'mongodb';
type ArrayElementType<T> = T extends (infer E)[] ? E : T;
type Keys<T extends { _id: string }> = keyof SetProps<T>;
export class UpdaterImpl<T extends { _id: string }> implements Updater<T> {
private _set: Map<Keys<T>, any> | undefined;
private _unset: Set<keyof UnsetProps<T>> | undefined;
private _inc: Map<keyof IncProps<T>, number> | undefined;
private _addToSet: Map<keyof AddToSetProps<T>, any[]> | undefined;
private dirty = false;
set<K extends keyof SetProps<T>>(key: K, value: SetProps<T>[K]) {
this._set = this._set ?? new Map<Keys<T>, any>();
this._set.set(key, value);
return this;
}
unset<K extends keyof UnsetProps<T>>(key: K): Updater<T> {
this._unset = this._unset ?? new Set<keyof UnsetProps<T>>();
this._unset.add(key);
return this;
}
inc<K extends keyof IncProps<T>>(key: K, value: number): Updater<T> {
this._inc = this._inc ?? new Map<keyof IncProps<T>, number>();
const prev = this._inc.get(key) ?? 0;
this._inc.set(key, prev + value);
return this;
}
addToSet<K extends keyof AddToSetProps<T>>(key: K, value: ArrayElementType<AddToSetProps<T>[K]>): Updater<T> {
this._addToSet = this._addToSet ?? new Map<keyof AddToSetProps<T>, any[]>();
const prev = this._addToSet.get(key) ?? [];
this._addToSet.set(key, [...prev, value]);
return this;
}
hasChanges() {
const filter = this.getRawUpdateFilter();
return this._hasChanges(filter);
}
private _hasChanges(filter: UpdateFilter<T>) {
return Object.keys(filter).length > 0;
}
public getRawUpdateFilter() {
return {
...(this._set && { $set: Object.fromEntries(this._set) }),
...(this._unset && { $unset: Object.fromEntries([...this._unset.values()].map((k) => [k, 1])) }),
...(this._inc && { $inc: Object.fromEntries(this._inc) }),
...(this._addToSet && { $addToSet: Object.fromEntries([...this._addToSet.entries()].map(([k, v]) => [k, { $each: v }])) }),
} as unknown as UpdateFilter<T>;
}
getUpdateFilter() {
if (this.dirty) {
throw new Error('Updater is dirty');
}
this.dirty = true;
const filter = this.getRawUpdateFilter();
if (!this._hasChanges(filter)) {
throw new Error('No changes to update');
}
return filter;
}
}
export type { Updater };

View File

@@ -0,0 +1,9 @@
{
"extends": "@rocket.chat/tsconfig/server.json",
"compilerOptions": {
"declaration": true,
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["./src/**/*"]
}