From 407c52ca437011ab2bb988c2cf7d103d6b83ff8a Mon Sep 17 00:00:00 2001 From: Kristina Date: Mon, 25 Aug 2025 09:50:39 +0400 Subject: [PATCH] Init directs (#95) Signed-off-by: Kristina Fefelova --- packages/cockroach/src/adapter.ts | 45 +++++- packages/cockroach/src/db/mapping.ts | 33 ++++- packages/cockroach/src/db/message.ts | 60 ++++++-- packages/cockroach/src/db/peer.ts | 135 ++++++++++++++++++ packages/cockroach/src/init.ts | 52 +++++-- packages/cockroach/src/schema.ts | 22 ++- packages/sdk-types/src/db.ts | 21 ++- packages/sdk-types/src/domain.ts | 3 +- packages/sdk-types/src/events/card.ts | 2 - packages/sdk-types/src/events/event.ts | 6 +- packages/sdk-types/src/events/peer.ts | 44 ++++++ packages/sdk-types/src/serverApi.ts | 6 +- packages/server/src/index.ts | 14 +- packages/server/src/middleware/base.ts | 28 +++- packages/server/src/middleware/broadcast.ts | 4 + packages/server/src/middleware/date.ts | 2 + packages/server/src/middleware/db.ts | 37 ++++- packages/server/src/middleware/peer.ts | 60 ++++++++ packages/server/src/middleware/permissions.ts | 3 + packages/server/src/middleware/triggers.ts | 3 + packages/server/src/middleware/validate.ts | 32 ++++- packages/server/src/middlewares.ts | 26 +++- .../server/src/notification/notification.ts | 2 + packages/server/src/triggers/card.ts | 2 +- packages/server/src/triggers/message.ts | 55 ++++++- packages/server/src/types.ts | 15 +- packages/types/src/index.ts | 1 + packages/types/src/peer.ts | 43 ++++++ packages/types/src/query.ts | 16 ++- 29 files changed, 711 insertions(+), 61 deletions(-) create mode 100644 packages/cockroach/src/db/peer.ts create mode 100644 packages/sdk-types/src/events/peer.ts create mode 100644 packages/server/src/middleware/peer.ts create mode 100644 packages/types/src/peer.ts diff --git a/packages/cockroach/src/adapter.ts b/packages/cockroach/src/adapter.ts index 0b83dbaefe..7a2b3ade0a 100644 --- a/packages/cockroach/src/adapter.ts +++ b/packages/cockroach/src/adapter.ts @@ -45,7 +45,8 @@ import { type BlobID, type AttachmentData, type AttachmentID, - type AttachmentUpdateData, WithTotal + type AttachmentUpdateData, WithTotal, PeerKind, PeerExtra, + FindPeersParams, Peer, FindThreadParams } from '@hcengineering/communication-types' import type { DbAdapter, @@ -66,11 +67,13 @@ import { formatName } from './utils' import { initSchema } from './init' import { LabelsDb } from './db/label' import { SqlClient } from './client' +import { PeersDb } from './db/peer' export class CockroachAdapter implements DbAdapter { private readonly message: MessagesDb private readonly notification: NotificationsDb private readonly label: LabelsDb + private readonly peer: PeersDb constructor ( private readonly sql: SqlClient, @@ -81,6 +84,7 @@ export class CockroachAdapter implements DbAdapter { this.message = new MessagesDb(this.sql, this.workspace, logger, options) this.notification = new NotificationsDb(this.sql, this.workspace, logger, options) this.label = new LabelsDb(this.sql, this.workspace, logger, options) + this.peer = new PeersDb(this.sql, this.workspace, logger, options) } async createMessage ( @@ -201,8 +205,8 @@ export class CockroachAdapter implements DbAdapter { return await this.message.findMessagesGroups(params) } - async findThread (thread: CardID): Promise { - return await this.message.findThread(thread) + async findThreads (params: FindThreadParams): Promise { + return await this.message.findThreads(params) } async addCollaborators ( @@ -316,6 +320,28 @@ export class CockroachAdapter implements DbAdapter { return this.label.updateLabels(card, updates) } + async createPeer ( + workspaceId: WorkspaceID, + cardId: CardID, + kind: PeerKind, + value: string, + extra: PeerExtra, + date: Date + ): Promise { + await this.peer.createPeer(workspaceId, cardId, kind, value, extra, date) + } + + async removePeer (workspaceId: WorkspaceID, + cardId: CardID, + kind: PeerKind, + value: string): Promise { + await this.peer.removePeer(workspaceId, cardId, kind, value) + } + + findPeers (params: FindPeersParams): Promise { + return this.peer.findPeers(params) + } + async getAccountsByPersonIds (ids: string[]): Promise { if (ids.length === 0) return [] const sql = `SELECT data ->> 'personUuid' AS "personUuid" @@ -350,6 +376,19 @@ export class CockroachAdapter implements DbAdapter { return result[0]?.title } + // TODO: remove later + async getCardSpaceMembers (cardId: CardID): Promise { + const sql = `SELECT s.members +FROM public.space AS s +JOIN public.card AS c ON c.space = s._id + WHERE c."workspaceId" = $1::uuid + AND c."_id" = $2::text + LIMIT 1` + + const result = await this.sql.execute(sql, [this.workspace, cardId]) + return result[0]?.members ?? [] + } + async getMessageCreated (cardId: CardID, messageId: MessageID): Promise { return await this.message.getMessageCreated(cardId, messageId) } diff --git a/packages/cockroach/src/db/mapping.ts b/packages/cockroach/src/db/mapping.ts index 34cf72e2c4..c7a98b6391 100644 --- a/packages/cockroach/src/db/mapping.ts +++ b/packages/cockroach/src/db/mapping.ts @@ -35,7 +35,10 @@ import { type AccountID, type MessageExtra, AttachmentID, - Attachment + Attachment, + Peer, + WorkspaceID, + PeerExtra } from '@hcengineering/communication-types' import { Domain } from '@hcengineering/communication-sdk-types' import { applyPatches } from '@hcengineering/communication-shared' @@ -296,3 +299,31 @@ export function toLabel (raw: DbModel): Label { created: new Date(raw.created) } } + +export function toPeer ( + raw: DbModel & { members?: { workspace_id: WorkspaceID, card_id: CardID, extra?: PeerExtra }[] } +): Peer { + const peer: Peer = { + workspaceId: raw.workspace_id, + cardId: raw.card_id, + kind: raw.kind, + value: raw.value, + extra: raw.extra, + created: new Date(raw.created) + } + + if (peer.kind === 'card') { + return { + ...peer, + kind: 'card', + members: + raw.members?.map((it) => ({ + workspaceId: it.workspace_id, + cardId: it.card_id, + extra: it.extra ?? {} + })) ?? [] + } + } + + return peer +} diff --git a/packages/cockroach/src/db/message.ts b/packages/cockroach/src/db/message.ts index d0cb09218a..4c5c16361c 100644 --- a/packages/cockroach/src/db/message.ts +++ b/packages/cockroach/src/db/message.ts @@ -25,6 +25,7 @@ import { type CardType, type FindMessagesGroupsParams, type FindMessagesParams, + FindThreadParams, type Markdown, type Message, type MessageExtra, @@ -753,21 +754,52 @@ export class MessagesDb extends BaseDb { return { where: `WHERE ${where.join(' AND ')}`, values } } - // Find thread - async findThread (thread: CardID): Promise { - const sql = `SELECT t.card_id, - t.message_id::text, - t.thread_id, - t.thread_type, - t.replies_count::int, - t.last_reply - FROM ${Domain.Thread} t - WHERE t.workspace_id = $1::uuid - AND t.thread_id = $2::varchar - LIMIT 1;` + // Find threads + async findThreads (params: FindThreadParams): Promise { + const { where, values } = this.buildThreadWhere(params) + const select = ` + SELECT * + FROM ${Domain.Thread} t + ` - const result = await this.execute(sql, [this.workspace, thread], 'find thread') - return result.map((it: any) => toThread(it))[0] + const limit = params.limit != null ? ` LIMIT ${params.limit}` : '' + const orderBy = + params.order != null ? `ORDER BY t.date ${params.order === SortingOrder.Ascending ? 'ASC' : 'DESC'}` : '' + + const sql = [select, where, orderBy, limit].join(' ') + const result = await this.execute(sql, values, 'find threads') + + return result.map((it: any) => toThread(it)) + } + + private buildThreadWhere ( + params: FindThreadParams, + startIndex: number = 0, + prefix: string = 't.' + ): { where: string, values: any[] } { + const where: string[] = [] + const values: any[] = [] + let index = startIndex + 1 + + where.push(`${prefix}workspace_id = $${index++}::uuid`) + values.push(this.workspace) + + if (params.cardId != null) { + where.push(`${prefix}card_id = $${index++}::varchar`) + values.push(params.cardId) + } + + if (params.messageId != null) { + where.push(`${prefix}message_id = $${index++}::varchar`) + values.push(params.messageId) + } + + if (params.threadId != null) { + where.push(`${prefix}thread_id = $${index++}::varchar`) + values.push(params.threadId) + } + + return { where: `WHERE ${where.join(' AND ')}`, values } } // Find messages groups diff --git a/packages/cockroach/src/db/peer.ts b/packages/cockroach/src/db/peer.ts new file mode 100644 index 0000000000..d4a7b3869d --- /dev/null +++ b/packages/cockroach/src/db/peer.ts @@ -0,0 +1,135 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { + WorkspaceID, + type CardID, + PeerKind, + PeerExtra, + FindPeersParams, + SortingOrder, + Peer +} from '@hcengineering/communication-types' +import { Domain } from '@hcengineering/communication-sdk-types' + +import { BaseDb } from './base' +import { DbModel, DbModelFilter } from '../schema' +import { toPeer } from './mapping' + +export class PeersDb extends BaseDb { + async createPeer ( + workspaceId: WorkspaceID, + cardId: CardID, + kind: PeerKind, + value: string, + extra: PeerExtra, + date: Date + ): Promise { + const db: DbModel = { + workspace_id: workspaceId, + card_id: cardId, + kind, + value, + extra, + created: date + } + const { sql, values } = this.getInsertSql(Domain.Peer, db, []) + await this.execute(sql, values, 'insert peer') + } + + async removePeer (workspaceId: WorkspaceID, cardId: CardID, kind: PeerKind, value: string): Promise { + const filter: DbModelFilter = [ + { + column: 'workspace_id', + value: workspaceId + }, + { + column: 'card_id', + value: cardId + }, + { + column: 'kind', + value: kind + }, + { + column: 'value', + value + } + ] + + if (filter.length === 0) return + + const { sql, values } = this.getDeleteSql(Domain.Peer, filter) + + await this.execute(sql, values, 'remove peer') + } + + async findPeers (params: FindPeersParams): Promise { + const select = `SELECT *, COALESCE(members.members, '[]') AS members + FROM ${Domain.Peer} p` + + const { where, values } = this.buildWhere(params) + + const limit = params.limit != null ? `LIMIT ${params.limit}` : '' + const orderBy = + params.order != null ? `ORDER BY p.created ${params.order === SortingOrder.Ascending ? 'ASC' : 'DESC'}` : '' + const join = `LEFT JOIN LATERAL ( + SELECT json_agg( + json_build_object( + 'workspace_id', p2.workspace_id, + 'card_id', p2.card_id, + 'extra', p2.extra + ) + ) AS members + FROM ${Domain.Peer} AS p2 + WHERE p2.value = p.value + AND p2.kind = 'card' + AND NOT (p2.workspace_id = p.workspace_id AND p2.card_id = p.card_id) + ) members ON true` + const sql = [select, join, where, orderBy, limit].join(' ') + + const result = await this.execute(sql, values, 'find peers') + + return result.map((it: any) => toPeer(it)) + } + + buildWhere (params: FindPeersParams, startIndex: number = 0, prefix = 'p.'): { where: string, values: any[] } { + const where: string[] = [] + const values: any[] = [] + let index = startIndex + 1 + + if (params.workspaceId != null) { + where.push(`${prefix}workspace_id = $${index++}::uuid`) + values.push(params.workspaceId) + } + + if (params.cardId != null) { + where.push(`${prefix}card_id = $${index++}::varchar`) + values.push(params.cardId) + } + + if (params.kind != null) { + where.push(`${prefix}kind = $${index++}::varchar`) + values.push(params.kind) + } + + if (params.value != null) { + where.push(`${prefix}value = $${index++}::varchar`) + values.push(params.value) + } + + return { where: where.length > 0 ? `WHERE ${where.join(' AND ')}` : '', values } + } +} diff --git a/packages/cockroach/src/init.ts b/packages/cockroach/src/init.ts index b23a025225..b7af31c51f 100644 --- a/packages/cockroach/src/init.ts +++ b/packages/cockroach/src/init.ts @@ -129,7 +129,8 @@ function getMigrations (): [string, string][] { migrationV7_3(), migrationV8_1(), migrationV8_2(), - migrationV8_3() + migrationV8_3(), + migrationV9_1() ] } @@ -497,7 +498,8 @@ function migrationV6_7 (): [string, string] { CREATE INDEX IF NOT EXISTS idx_reactions_workspace_card_message ON communication.reactions (workspace_id, card_id, message_id); - ALTER TABLE communication.thread ADD CONSTRAINT thread_unique_constraint UNIQUE (workspace_id, card_id, message_id); + ALTER TABLE communication.thread + ADD CONSTRAINT thread_unique_constraint UNIQUE (workspace_id, card_id, message_id); CREATE INDEX IF NOT EXISTS idx_thread_workspace_card_message ON communication.thread (workspace_id, card_id, message_id); @@ -541,9 +543,8 @@ function migrationV7_2 (): [string, string] { FROM communication.notification_context AS nc JOIN communication.messages_groups AS mg ON mg.workspace_id = nc.workspace_id - AND mg.card_id = nc.card_id - WHERE - n.context_id = nc.id + AND mg.card_id = nc.card_id + WHERE n.context_id = nc.id AND n.message_created BETWEEN mg.from_date AND mg.to_date AND n.blob_id IS NULL; ` @@ -552,12 +553,12 @@ function migrationV7_2 (): [string, string] { function migrationV7_3 (): [string, string] { const sql = ` - UPDATE communication.notification_context - SET last_notify = last_update - WHERE last_notify IS NULL; + UPDATE communication.notification_context + SET last_notify = last_update + WHERE last_notify IS NULL; - ALTER TABLE communication.notification_context - ALTER COLUMN last_notify SET NOT NULL; + ALTER TABLE communication.notification_context + ALTER COLUMN last_notify SET NOT NULL; ` return ['make_last_notify_not_null-v7_3', sql] } @@ -600,7 +601,34 @@ function migrationV8_2 (): [string, string] { function migrationV8_3 (): [string, string] { const sql = ` - CREATE INDEX IF NOT EXISTS attachment_workspace_card_message_idx ON ${Domain.Attachment} (workspace_id, card_id, message_id) - ` + CREATE INDEX IF NOT EXISTS attachment_workspace_card_message_idx ON ${Domain.Attachment} (workspace_id, card_id, message_id) + ` return ['add_attachment_indexes-v8_3', sql] } + +// CREATE TABLE ${Domain.CardPeerGroup} +// ( +// group_id UUID NOT NULL, +// workspace_id UUID NOT NULL, +// card_id VARCHAR(255) NOT NULL, +// created TIMESTAMPTZ NOT NULL DEFAULT now(), +// PRIMARY KEY (group_id,workspace_id, card_id) +// ); + +function migrationV9_1 (): [string, string] { + const sql = ` + CREATE TABLE IF NOT EXISTS ${Domain.Peer} + ( + workspace_id UUID NOT NULL, + card_id VARCHAR(255) NOT NULL, + kind TEXT NOT NULL, + value TEXT NOT NULL, + extra JSONB NOT NULL DEFAULT '{}', + created TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (workspace_id, card_id, kind, value) + ); + + CREATE INDEX IF NOT EXISTS peer_workspace_card_kind ON ${Domain.Peer} (workspace_id, card_id, kind); + CREATE INDEX IF NOT EXISTS peer_kind_value ON ${Domain.Peer} (kind, value);` + return ['init_peer_tables-v9_1', sql] +} diff --git a/packages/cockroach/src/schema.ts b/packages/cockroach/src/schema.ts index 3e850acd14..761c5f71ca 100644 --- a/packages/cockroach/src/schema.ts +++ b/packages/cockroach/src/schema.ts @@ -28,7 +28,8 @@ import { type LabelID, type CardType, NotificationContent, - NotificationType, AttachmentID + NotificationType, AttachmentID, + PeerKind, PeerExtra } from '@hcengineering/communication-types' import { Domain } from '@hcengineering/communication-sdk-types' @@ -130,6 +131,14 @@ export const schemas = { last_view: 'timestamptz', last_update: 'timestamptz', last_notify: 'timestamptz' + }, + [Domain.Peer]: { + workspace_id: 'uuid', + card_id: 'varchar', + kind: 'varchar', + value: 'varchar', + extra: 'jsonb', + created: 'timestamptz' } } as const @@ -141,11 +150,11 @@ export interface DomainDbModel { [Domain.Reaction]: ReactionDbModel [Domain.Thread]: ThreadDbModel [Domain.Attachment]: AttachmentDbModel - // [Domain.LinkPreview]: LinkPreviewDbModel [Domain.Notification]: NotificationDbModel [Domain.NotificationContext]: ContextDbModel [Domain.Collaborator]: CollaboratorDbModel [Domain.Label]: LabelDbModel + [Domain.Peer]: PeerDbModel } export type DbModel = DomainDbModel[D] @@ -274,3 +283,12 @@ interface LabelDbModel { account: AccountID created: Date } + +interface PeerDbModel { + workspace_id: WorkspaceID + card_id: CardID + kind: PeerKind + value: string + extra: PeerExtra + created: Date +} diff --git a/packages/sdk-types/src/db.ts b/packages/sdk-types/src/db.ts index 23c53e9d6a..73f4178aaf 100644 --- a/packages/sdk-types/src/db.ts +++ b/packages/sdk-types/src/db.ts @@ -44,7 +44,7 @@ import { NotificationType, AttachmentData, AttachmentID, - AttachmentUpdateData, WithTotal + AttachmentUpdateData, WithTotal, WorkspaceID, PeerKind, PeerExtra, FindPeersParams, Peer, FindThreadParams } from '@hcengineering/communication-types' export interface DbAdapter { @@ -82,9 +82,25 @@ export interface DbAdapter { removeThreads: (query: ThreadQuery) => Promise updateThread: (cardId: CardID, messageId: MessageID, thread: CardID, update: ThreadUpdates, socialId: SocialID, date: Date) => Promise + createPeer: ( + workspaceId: WorkspaceID, + cardId: CardID, + kind: PeerKind, + value: string, + extra: PeerExtra, + date: Date + ) => Promise + + removePeer: (workspaceId: WorkspaceID, + cardId: CardID, + kind: PeerKind, + value: string) => Promise + + findPeers: (params: FindPeersParams) => Promise + findMessages: (params: FindMessagesParams) => Promise findMessagesGroups: (params: FindMessagesGroupsParams) => Promise - findThread: (threadId: CardID) => Promise + findThreads: (params: FindThreadParams) => Promise addCollaborators: (cardId: CardID, cardType: CardType, collaborators: AccountID[], date: Date) => Promise removeCollaborators: (cardId: CardID, accounts: AccountID[], unsafe?: boolean) => Promise @@ -126,6 +142,7 @@ export interface DbAdapter { updateLabels: (cardId: CardID, update: LabelUpdates) => Promise getCardTitle: (cardId: CardID) => Promise + getCardSpaceMembers: (cardId: CardID) => Promise getAccountsByPersonIds: (ids: string[]) => Promise getNameByAccount: (id: AccountID) => Promise getMessageCreated: (cardId: CardID, messageId: MessageID) => Promise diff --git a/packages/sdk-types/src/domain.ts b/packages/sdk-types/src/domain.ts index bf9ca8510e..86e4f34ab7 100644 --- a/packages/sdk-types/src/domain.ts +++ b/packages/sdk-types/src/domain.ts @@ -26,7 +26,8 @@ export enum Domain { Collaborator = 'communication.collaborator', Label = 'communication.label', - // LinkPreview = 'communication.link_preview' + + Peer = 'communication.peer' } export const Domains = Object.values(Domain) diff --git a/packages/sdk-types/src/events/card.ts b/packages/sdk-types/src/events/card.ts index d96c862790..46d5a794cd 100644 --- a/packages/sdk-types/src/events/card.ts +++ b/packages/sdk-types/src/events/card.ts @@ -18,14 +18,12 @@ import type { CardID, CardType, SocialID } from '@hcengineering/communication-ty import type { BaseEvent } from './common' export enum CardEventType { - // Internal UpdateCardType = 'updateCardType', RemoveCard = 'removeCard' } export type CardEvent = UpdateCardTypeEvent | RemoveCardEvent -// Internal export interface UpdateCardTypeEvent extends BaseEvent { type: CardEventType.UpdateCardType cardId: CardID diff --git a/packages/sdk-types/src/events/event.ts b/packages/sdk-types/src/events/event.ts index a92d51b402..506d4df622 100644 --- a/packages/sdk-types/src/events/event.ts +++ b/packages/sdk-types/src/events/event.ts @@ -15,13 +15,15 @@ import type { LabelEvent, LabelEventType } from './label' import type { MessageEventResult, MessageEventType, MessageEvent } from './message' import type { NotificationEventResult, NotificationEvent, NotificationEventType } from './notification' import type { CardEvent, CardEventType } from './card' +import { PeerEvent, PeerEventType } from './peer' export * from './message' export * from './notification' export * from './label' export * from './card' +export * from './peer' -export type EventType = MessageEventType | NotificationEventType | LabelEventType | CardEventType -export type Event = MessageEvent | NotificationEvent | LabelEvent | CardEvent +export type EventType = MessageEventType | NotificationEventType | LabelEventType | CardEventType | PeerEventType +export type Event = MessageEvent | NotificationEvent | LabelEvent | CardEvent | PeerEvent // eslint-disable-next-line @typescript-eslint/ban-types export type EventResult = MessageEventResult | NotificationEventResult | {} diff --git a/packages/sdk-types/src/events/peer.ts b/packages/sdk-types/src/events/peer.ts new file mode 100644 index 0000000000..331afa41dc --- /dev/null +++ b/packages/sdk-types/src/events/peer.ts @@ -0,0 +1,44 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { BaseEvent } from './common' +import { CardID, PeerKind, PeerExtra, WorkspaceID } from '@hcengineering/communication-types' + +// Peer events only for system +export enum PeerEventType { + CreatePeer = 'createPeer', + RemovePeer = 'removePeer' +} + +export type PeerEvent = CreatePeerEvent | RemovePeerEvent + +export interface CreatePeerEvent extends BaseEvent { + type: PeerEventType.CreatePeer + workspaceId: WorkspaceID + cardId: CardID + kind: PeerKind + value: string + extra?: PeerExtra + date?: Date +} + +export interface RemovePeerEvent extends BaseEvent { + type: PeerEventType.RemovePeer + workspaceId: WorkspaceID + cardId: CardID + kind: PeerKind + value: string + date?: Date +} diff --git a/packages/sdk-types/src/serverApi.ts b/packages/sdk-types/src/serverApi.ts index ec1dde3371..848a0140ea 100644 --- a/packages/sdk-types/src/serverApi.ts +++ b/packages/sdk-types/src/serverApi.ts @@ -25,7 +25,8 @@ import type { FindLabelsParams, Label, FindCollaboratorsParams, - Collaborator + Collaborator, + FindThreadParams, Thread, FindPeersParams, Peer } from '@hcengineering/communication-types' import type { Account, MeasureContext } from '@hcengineering/core' @@ -61,6 +62,9 @@ export interface ServerApi { findLabels: (session: SessionData, params: FindLabelsParams) => Promise findCollaborators: (session: SessionData, params: FindCollaboratorsParams) => Promise + findThreads: (session: SessionData, params: FindThreadParams) => Promise + findPeers: (session: SessionData, params: FindPeersParams) => Promise + event: (session: SessionData, event: Event) => Promise unsubscribeQuery: (session: SessionData, id: number) => Promise diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 46c481f4fd..82dc68a1be 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -27,7 +27,8 @@ import type { FindLabelsParams, Label, FindCollaboratorsParams, - Collaborator + Collaborator, FindPeersParams, Peer, Thread, + FindThreadParams } from '@hcengineering/communication-types' import { createDbAdapter } from '@hcengineering/communication-cockroach' import type { EventResult, Event, ServerApi, SessionData } from '@hcengineering/communication-sdk-types' @@ -52,8 +53,9 @@ export class Api implements ServerApi { withLogs: process.env.COMMUNICATION_TIME_LOGGING_ENABLED === 'true' }) + const peers = await db.findPeers({ workspaceId: workspace }) const metadata = getMetadata() - const middleware = await buildMiddlewares(ctx, workspace, metadata, db, callbacks) + const middleware = await buildMiddlewares(ctx, workspace, metadata, db, callbacks, peers) return new Api(ctx, middleware) } @@ -90,6 +92,14 @@ export class Api implements ServerApi { return await this.middlewares.findCollaborators(session, params) } + async findPeers (session: SessionData, params: FindPeersParams): Promise { + return await this.middlewares.findPeers(session, params) + } + + async findThreads (session: SessionData, params: FindThreadParams): Promise { + return await this.middlewares.findThreads(session, params) + } + async unsubscribeQuery (session: SessionData, id: number): Promise { await this.middlewares.unsubscribeQuery(session, id) } diff --git a/packages/server/src/middleware/base.ts b/packages/server/src/middleware/base.ts index 7233adf33c..00209b02c8 100644 --- a/packages/server/src/middleware/base.ts +++ b/packages/server/src/middleware/base.ts @@ -26,7 +26,11 @@ import type { FindLabelsParams, Label, FindCollaboratorsParams, - Collaborator + Collaborator, + FindPeersParams, + Peer, + FindThreadParams, + Thread } from '@hcengineering/communication-types' import type { Enriched, Middleware, MiddlewareContext, QueryId } from '../types' @@ -73,6 +77,14 @@ export class BaseMiddleware implements Middleware { return await this.provideFindCollaborators(session, params) } + async findPeers (session: SessionData, params: FindPeersParams): Promise { + return await this.provideFindPeers(session, params) + } + + async findThreads (session: SessionData, params: FindThreadParams): Promise { + return await this.provideFindThreads(session, params) + } + async event (session: SessionData, event: Enriched, derived: boolean): Promise { return await this.provideEvent(session, event, derived) } @@ -162,6 +174,20 @@ export class BaseMiddleware implements Middleware { return [] } + protected async provideFindPeers (session: SessionData, params: FindPeersParams): Promise { + if (this.next !== undefined) { + return await this.next.findPeers(session, params) + } + return [] + } + + protected async provideFindThreads (session: SessionData, params: FindThreadParams): Promise { + if (this.next !== undefined) { + return await this.next.findThreads(session, params) + } + return [] + } + protected provideHandleBroadcast (session: SessionData, events: Enriched[]): void { if (this.next !== undefined) { this.next.handleBroadcast(session, events) diff --git a/packages/server/src/middleware/broadcast.ts b/packages/server/src/middleware/broadcast.ts index 40879be320..8824ed7d0a 100644 --- a/packages/server/src/middleware/broadcast.ts +++ b/packages/server/src/middleware/broadcast.ts @@ -20,6 +20,7 @@ import { LabelEventType, MessageEventType, NotificationEventType, + PeerEventType, type SessionData } from '@hcengineering/communication-sdk-types' import type { @@ -223,6 +224,9 @@ export class BroadcastMiddleware extends BaseMiddleware implements Middleware { case CardEventType.UpdateCardType: case CardEventType.RemoveCard: return true + case PeerEventType.RemovePeer: + case PeerEventType.CreatePeer: + return false } } diff --git a/packages/server/src/middleware/date.ts b/packages/server/src/middleware/date.ts index 726f7948cf..c6ffb213d1 100644 --- a/packages/server/src/middleware/date.ts +++ b/packages/server/src/middleware/date.ts @@ -34,6 +34,8 @@ export class DateMiddleware extends BaseMiddleware implements Middleware { event.date = new Date() } + event._eventExtra = {} + return await this.provideEvent(session, event, derived) } diff --git a/packages/server/src/middleware/db.ts b/packages/server/src/middleware/db.ts index 1abe45d834..c75d436ecf 100644 --- a/packages/server/src/middleware/db.ts +++ b/packages/server/src/middleware/db.ts @@ -23,6 +23,8 @@ import { type FindMessagesParams, type FindNotificationContextParams, type FindNotificationsParams, + FindPeersParams, + FindThreadParams, type Label, type Message, MessageID, @@ -30,7 +32,9 @@ import { type Notification, type NotificationContext, PatchType, + Peer, SocialID, + Thread, UpdatePatchData } from '@hcengineering/communication-types' import { @@ -62,7 +66,10 @@ import { ThreadPatchEvent, EventResult, AttachmentPatchEvent, - BlobPatchEvent + BlobPatchEvent, + PeerEventType, + CreatePeerEvent, + RemovePeerEvent } from '@hcengineering/communication-sdk-types' import type { Enriched, Middleware, MiddlewareContext } from '../types' @@ -109,11 +116,21 @@ export class DatabaseMiddleware extends BaseMiddleware implements Middleware { return await this.db.findCollaborators(params) } - async event (session: SessionData, event: Enriched): Promise { + async findPeers (_: SessionData, params: FindPeersParams): Promise { + return await this.db.findPeers(params) + } + + async findThreads (_: SessionData, params: FindThreadParams): Promise { + return await this.db.findThreads(params) + } + + async event (session: SessionData, event: Enriched, derived: boolean): Promise { const result = await this.processEvent(session, event) if (result.skipPropagate === true) { event.skipPropagate = true + } else { + await this.provideEvent(session, event, derived) } return result.result ?? {} @@ -153,6 +170,12 @@ export class DatabaseMiddleware extends BaseMiddleware implements Middleware { case CardEventType.RemoveCard: return await this.removeCard(event) + // Peers + case PeerEventType.RemovePeer: + return await this.removePeer(event) + case PeerEventType.CreatePeer: + return await this.createPeer(event) + // Collaborators case NotificationEventType.AddCollaborators: return await this.addCollaborators(event) @@ -460,6 +483,16 @@ export class DatabaseMiddleware extends BaseMiddleware implements Middleware { return {} } + private async createPeer (event: Enriched): Promise { + await this.db.createPeer(event.workspaceId, event.cardId, event.kind, event.value, event.extra ?? {}, event.date) + return {} + } + + private async removePeer (event: Enriched): Promise { + await this.db.removePeer(event.workspaceId, event.cardId, event.kind, event.value) + return {} + } + private async updateCardType (event: Enriched): Promise { return {} } diff --git a/packages/server/src/middleware/peer.ts b/packages/server/src/middleware/peer.ts new file mode 100644 index 0000000000..b9efe1777a --- /dev/null +++ b/packages/server/src/middleware/peer.ts @@ -0,0 +1,60 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { + type Event, + EventResult, + MessageEventType, + PeerEventType, + type SessionData +} from '@hcengineering/communication-sdk-types' + +import type { Enriched, Middleware, MiddlewareContext } from '../types' +import { BaseMiddleware } from './base' + +export class PeerMiddleware extends BaseMiddleware implements Middleware { + constructor ( + readonly context: MiddlewareContext, + next?: Middleware + ) { + super(context, next) + } + + async event (session: SessionData, event: Enriched, derived: boolean): Promise { + switch (event.type) { + case PeerEventType.CreatePeer: + this.context.cadsWithPeers.add(event.cardId) + break + case MessageEventType.CreateMessage: + case MessageEventType.UpdatePatch: + case MessageEventType.RemovePatch: + case MessageEventType.AttachmentPatch: + case MessageEventType.ReactionPatch: + case MessageEventType.ThreadPatch: + case MessageEventType.BlobPatch: { + if (this.context.cadsWithPeers.has(event.cardId)) { + event._eventExtra.peers = + (await this.context.head?.findPeers(session, { + workspaceId: this.context.workspace, + cardId: event.cardId + })) ?? [] + } + break + } + } + + return await this.provideEvent(session, event, derived) + } +} diff --git a/packages/server/src/middleware/permissions.ts b/packages/server/src/middleware/permissions.ts index a9d46a3a96..9401a952fe 100644 --- a/packages/server/src/middleware/permissions.ts +++ b/packages/server/src/middleware/permissions.ts @@ -19,6 +19,7 @@ import { EventResult, MessageEventType, NotificationEventType, + PeerEventType, type SessionData } from '@hcengineering/communication-sdk-types' import { AccountRole, systemAccountUuid } from '@hcengineering/core' @@ -66,6 +67,8 @@ export class PermissionsMiddleware extends BaseMiddleware implements Middleware this.checkAccount(session, event.account) break } + case PeerEventType.CreatePeer: + case PeerEventType.RemovePeer: case MessageEventType.CreateMessagesGroup: case MessageEventType.RemoveMessagesGroup: { this.onlySystemAccount(session) diff --git a/packages/server/src/middleware/triggers.ts b/packages/server/src/middleware/triggers.ts index 6c7b9f3cbd..5c3174c530 100644 --- a/packages/server/src/middleware/triggers.ts +++ b/packages/server/src/middleware/triggers.ts @@ -23,6 +23,7 @@ import { notify } from '../notification/notification' export class TriggersMiddleware extends BaseMiddleware implements Middleware { private ctx: MeasureContext + private processedPeersEvents = new Set() constructor ( private readonly callbacks: CommunicationCallbacks, @@ -60,6 +61,7 @@ export class TriggersMiddleware extends BaseMiddleware implements Middleware { registeredCards: this.context.registeredCards, accountBySocialID: this.context.accountBySocialID, removedContexts: this.context.removedContexts, + processedPeersEvents: this.processedPeersEvents, derived, execute: async (event: Event) => { // Will be enriched in head @@ -90,6 +92,7 @@ export class TriggersMiddleware extends BaseMiddleware implements Middleware { (session.asyncData as Enriched[]).sort((a, b) => a.date.getTime() - b.date.getTime()) ) session.asyncData = [] + this.processedPeersEvents = new Set() } } } diff --git a/packages/server/src/middleware/validate.ts b/packages/server/src/middleware/validate.ts index 18c0b825c5..1547dea4e4 100644 --- a/packages/server/src/middleware/validate.ts +++ b/packages/server/src/middleware/validate.ts @@ -18,7 +18,8 @@ import { MessageEventType, NotificationEventType, type Event, - type SessionData + type SessionData, + PeerEventType } from '@hcengineering/communication-sdk-types' import { type Collaborator, @@ -151,11 +152,18 @@ export class ValidateMiddleware extends BaseMiddleware implements Middleware { case NotificationEventType.UpdateNotificationContext: this.validate(event, UpdateNotificationContextEventSchema) break + case PeerEventType.CreatePeer: + this.validate(event, CreatePeerEventSchema) + break + case PeerEventType.RemovePeer: + this.validate(event, RemovePeerEventSchema) + break } return await this.provideEvent(session, deserializeEvent(event), derived) } } +const WorkspaceIDSchema = z.string().uuid() const AccountIDSchema = z.string() const BlobIDSchema = z.string().uuid() const AttachmentIDSchema = z.string().uuid() @@ -290,7 +298,8 @@ const FindCollaboratorsParamsSchema = FindParamsSchema.extend({ const BaseEventSchema = z .object({ - _id: z.string().optional() + _id: z.string().optional(), + _eventExtra: z.record(z.any()).optional() }) .strict() @@ -472,6 +481,25 @@ const RemoveCollaboratorsEventSchema = BaseEventSchema.extend({ date: DateSchema }).strict() +const CreatePeerEventSchema = BaseEventSchema.extend({ + type: z.literal(PeerEventType.CreatePeer), + workspaceId: WorkspaceIDSchema, + cardId: CardIDSchema, + kind: z.string().nonempty(), + value: z.string().nonempty(), + extra: z.record(z.any()).optional(), + date: DateSchema +}).strict() + +const RemovePeerEventSchema = BaseEventSchema.extend({ + type: z.literal(PeerEventType.RemovePeer), + workspaceId: WorkspaceIDSchema, + cardId: CardIDSchema, + kind: z.string().nonempty(), + value: z.string().nonempty(), + date: DateSchema +}).strict() + function deserializeEvent (event: Enriched): Enriched { switch (event.type) { case MessageEventType.CreateMessagesGroup: diff --git a/packages/server/src/middlewares.ts b/packages/server/src/middlewares.ts index 3617a9f7b4..a95bbb98b8 100644 --- a/packages/server/src/middlewares.ts +++ b/packages/server/src/middlewares.ts @@ -13,7 +13,7 @@ // limitations under the License. // -import type { MeasureContext } from '@hcengineering/core' +import { MeasureContext } from '@hcengineering/core' import type { DbAdapter, EventResult, Event, SessionData } from '@hcengineering/communication-sdk-types' import type { Collaborator, @@ -23,11 +23,14 @@ import type { FindMessagesParams, FindNotificationContextParams, FindNotificationsParams, + FindPeersParams, + FindThreadParams, Label, Message, MessagesGroup, Notification, - NotificationContext, + NotificationContext, Peer, + Thread, WorkspaceID } from '@hcengineering/communication-types' @@ -48,13 +51,14 @@ import { ValidateMiddleware } from './middleware/validate' import { DateMiddleware } from './middleware/date' import { IdentityMiddleware } from './middleware/indentity' import { IdMiddleware } from './middleware/id' +import { PeerMiddleware } from './middleware/peer' export async function buildMiddlewares ( ctx: MeasureContext, workspace: WorkspaceID, metadata: Metadata, db: DbAdapter, - callbacks: CommunicationCallbacks + callbacks: CommunicationCallbacks, peers: Peer[] ): Promise { const createFns: MiddlewareCreateFn[] = [ // Enrich events @@ -69,7 +73,8 @@ export async function buildMiddlewares ( // Process events async (context, next) => new TriggersMiddleware(callbacks, db, context, next), async (context, next) => new BroadcastMiddleware(callbacks, context, next), - async (context, next) => new DatabaseMiddleware(db, context, next) + async (context, next) => new DatabaseMiddleware(db, context, next), + async (context, next) => new PeerMiddleware(context, next) ] const context: MiddlewareContext = { @@ -78,7 +83,8 @@ export async function buildMiddlewares ( workspace, registeredCards: new Set(), accountBySocialID: new Map(), - removedContexts: new Map() + removedContexts: new Map(), + cadsWithPeers: new Set(peers.map(it => it.cardId)) } return await Middlewares.create(ctx, context, createFns) @@ -169,6 +175,16 @@ export class Middlewares { return await this.head.findCollaborators(session, params) } + async findPeers (session: SessionData, params: FindPeersParams): Promise { + if (this.head === undefined) return [] + return await this.head.findPeers(session, params) + } + + async findThreads (session: SessionData, params: FindThreadParams): Promise { + if (this.head === undefined) return [] + return await this.head.findThreads(session, params) + } + async unsubscribeQuery (session: SessionData, id: number): Promise { if (this.head === undefined) return this.head?.unsubscribeQuery(session, id) diff --git a/packages/server/src/notification/notification.ts b/packages/server/src/notification/notification.ts index 0ce78b8cd5..35fb4853e2 100644 --- a/packages/server/src/notification/notification.ts +++ b/packages/server/src/notification/notification.ts @@ -220,6 +220,7 @@ async function notifyMessage ( date: Date ): Promise { const cursor = ctx.db.getCollaboratorsCursor(cardId, date, BATCH_SIZE) + const spaceMembers = await ctx.db.getCardSpaceMembers(cardId) const creatorAccount = await findAccount(ctx, socialId) const result: Event[] = [] @@ -235,6 +236,7 @@ async function notifyMessage ( }) for (const collaborator of collaborators) { + if (!spaceMembers.includes(collaborator)) continue try { const context = contexts.find((it) => it.account === collaborator) const res = await processCollaborator( diff --git a/packages/server/src/triggers/card.ts b/packages/server/src/triggers/card.ts index 296a7b9643..43b0c51bc1 100644 --- a/packages/server/src/triggers/card.ts +++ b/packages/server/src/triggers/card.ts @@ -52,7 +52,7 @@ async function onCardTypeUpdates (ctx: TriggerCtx, event: Enriched): Promise { const { cardId } = event - const thread = await ctx.db.findThread(cardId) + const thread = (await ctx.db.findThreads({ threadId: cardId, limit: 1 }))[0] if (thread === undefined) return [] return [ @@ -126,7 +126,7 @@ async function addThreadReply (ctx: TriggerCtx, event: Enriched): Promise { + if (ctx.processedPeersEvents.has(event._id)) return [] + if (event.type === MessageEventType.CreateMessage) { + if (event.messageType === MessageType.Activity) { + return [] + } + } + + if (event.type === MessageEventType.ThreadPatch) { + return [] + } + + const cardPeers = new Set( + (((event._eventExtra.peers ?? []) as Peer[]).filter((it) => it.kind === 'card') as CardPeer[]) + .flatMap((it) => it.members) + .filter((it) => it.workspaceId === ctx.workspace && it.cardId !== event.cardId) + .map((it) => it.cardId) + ) + + if (cardPeers.size === 0) return [] + const res: Event[] = [] + + for (const peer of cardPeers) { + const ev = { + ...event, + _id: generateId(), + cardId: peer + } + + ctx.processedPeersEvents.add(ev._id) + + res.push(ev) + } + + return res +} + const triggers: Triggers = [ ['add_collaborators_on_message_created', MessageEventType.CreateMessage, addCollaborators as TriggerFn], ['add_thread_reply_on_message_created', MessageEventType.CreateMessage, addThreadReply as TriggerFn], @@ -214,7 +251,15 @@ const triggers: Triggers = [ ['on_messages_group_created', MessageEventType.CreateMessagesGroup, onMessagesGroupCreated as TriggerFn], ['remove_reply_on_messages_removed', MessageEventType.RemovePatch, onMessageRemoved as TriggerFn], - ['on_thread_created', MessageEventType.ThreadPatch, onThreadAttached as TriggerFn] + ['on_thread_created', MessageEventType.ThreadPatch, onThreadAttached as TriggerFn], + + ['check_peers_on_message_created', MessageEventType.CreateMessage, checkPeers as TriggerFn], + ['check_peers_on_update_patch', MessageEventType.UpdatePatch, checkPeers as TriggerFn], + ['check_peers_on_remove_patch', MessageEventType.RemovePatch, checkPeers as TriggerFn], + ['check_peers_on_reaction_patch', MessageEventType.ReactionPatch, checkPeers as TriggerFn], + ['check_peers_on_blob_patch', MessageEventType.BlobPatch, checkPeers as TriggerFn], + ['check_peers_on_attachment_patch', MessageEventType.AttachmentPatch, checkPeers as TriggerFn], + ['check_peers_on_thread_patch', MessageEventType.ThreadPatch, checkPeers as TriggerFn] ] export default triggers diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 5c9f98893e..97338a9421 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -23,19 +23,23 @@ import type { import type { AccountID, CardID, - Collaborator, ContextID, + Collaborator, + ContextID, FindCollaboratorsParams, FindLabelsParams, FindMessagesGroupsParams, FindMessagesParams, FindNotificationContextParams, FindNotificationsParams, + FindPeersParams, + FindThreadParams, Label, Message, MessagesGroup, Notification, NotificationContext, - SocialID, + Peer, + SocialID, Thread, WorkspaceID } from '@hcengineering/communication-types' @@ -69,6 +73,8 @@ export interface Middleware { findLabels: (session: SessionData, params: FindLabelsParams, queryId?: QueryId) => Promise findCollaborators: (session: SessionData, params: FindCollaboratorsParams) => Promise + findPeers: (session: SessionData, params: FindPeersParams) => Promise + findThreads: (session: SessionData, params: FindThreadParams) => Promise event: (session: SessionData, event: Enriched, derived: boolean) => Promise @@ -88,6 +94,8 @@ export interface MiddlewareContext { accountBySocialID: Map removedContexts: Map + cadsWithPeers: Set + derived?: Middleware head?: Middleware } @@ -110,6 +118,7 @@ export interface TriggerCtx { accountBySocialID: Map removedContexts: Map derived: boolean + processedPeersEvents: Set execute: (event: Event) => Promise } @@ -117,6 +126,8 @@ export type TriggerFn = (ctx: TriggerCtx, event: Enriched) => Promise = T & { + _id: string skipPropagate?: boolean date: Date + _eventExtra: Record } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 84cdd62068..a6d3ee6fdf 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -20,3 +20,4 @@ export * from './notification' export * from './query' export * from './label' export * from './patch' +export * from './peer' diff --git a/packages/types/src/peer.ts b/packages/types/src/peer.ts new file mode 100644 index 0000000000..d74755ada1 --- /dev/null +++ b/packages/types/src/peer.ts @@ -0,0 +1,43 @@ +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CardID, WorkspaceID } from './core' + +export type PeerKind = 'card' | string +export type PeerExtra = Record + +interface BasePeer { + workspaceId: WorkspaceID + cardId: CardID + kind: PeerKind + value: string + extra: PeerExtra + created: Date +} + +export interface CardPeer extends BasePeer { + kind: 'card' + members: CardPeerMember[] +} + +export interface ExternalPeer extends BasePeer { + kind: string +} + +export type Peer = CardPeer | ExternalPeer + +export interface CardPeerMember { + workspaceId: WorkspaceID + cardId: CardID + extra: PeerExtra +} diff --git a/packages/types/src/query.ts b/packages/types/src/query.ts index d2b739af0e..2789601d30 100644 --- a/packages/types/src/query.ts +++ b/packages/types/src/query.ts @@ -17,8 +17,9 @@ import { SortingOrder } from '@hcengineering/core' import type { MessageID } from './message' import type { ContextID, NotificationID, NotificationType } from './notification' -import type { AccountID, BlobID, CardID, CardType } from './core' +import type { AccountID, BlobID, CardID, CardType, WorkspaceID } from './core' import type { LabelID } from './label' +import { PeerKind } from './peer' export { SortingOrder } @@ -101,4 +102,17 @@ export interface FindLabelsParams extends FindParams { account?: AccountID } +export interface FindThreadParams extends FindParams { + cardId?: CardID + messageId?: MessageID + threadId?: CardID +} + +export interface FindPeersParams extends FindParams { + workspaceId?: WorkspaceID + cardId?: CardID + kind?: PeerKind + value?: string +} + export type WithTotal = T[] & { total: number }