🧹 Propose to remove channels, notifications, knowledge-graph and phpnode (#55)

* Propose to remove channels and notifications

* Propose to remove knowledge graph too

* Put back tags test

* FIxing tests

* FIx query builder test
This commit is contained in:
Romaric Mourgues
2023-05-16 14:36:39 +02:00
committed by GitHub
parent 65dc733855
commit a378cd13e1
102 changed files with 327 additions and 9224 deletions
@@ -24,9 +24,6 @@
"root": "STATIC_ROOT"
}
},
"phpnode": {
"secret": "PHP_NODE_API_SECRET"
},
"websocket": {
"auth": {
"jwt": {
@@ -44,15 +41,6 @@
"secret": "AUTH_JWT_SECRET"
}
},
"notifications": {
"push": {
"type": "PUSH_DRIVER",
"fcm": {
"endpoint": "PUSH_FCM_ENDPOINT",
"key": "PUSH_FCM_KEY"
}
}
},
"database": {
"secret": "DB_SECRET",
"type": "DB_DRIVER",
-11
View File
@@ -127,12 +127,6 @@
"path": "/storage/"
}
},
"knowledge-graph": {
"endpoint": "http://host-gateway:8888",
"callback_token": "secret",
"use": false,
"forwarded_companies": []
},
"email-pusher": {
"endpoint": "https://api.smtp2go.com/v3/email/send",
"api_key": "secret",
@@ -153,12 +147,9 @@
"search",
"message-queue",
"realtime",
"phpnode",
"tracker",
"general",
"user",
"channels",
"notifications",
"files",
"workspaces",
"console",
@@ -166,8 +157,6 @@
"statistics",
"cron",
"online",
"knowledge-graph",
"knowledge-graph-web",
"email-pusher",
"documents",
"applications",
-3
View File
@@ -19,8 +19,5 @@
"amqp": {
"urls": ["amqp://guest:guest@rabbitmq:5672"]
}
},
"phpnode": {
"php_endpoint": false
}
}
@@ -9,7 +9,6 @@ export interface authenticateDecorator {
declare module "fastify" {
interface FastifyInstance {
phpnodeAuthenticate(): void;
authenticate(): void;
authenticateOptional(): void;
io: SocketIO.Server;
@@ -3,8 +3,6 @@ import tdrive from "../../../tdrive";
import { mkdirSync, writeFileSync } from "fs";
import { Pagination } from "../../../core/platform/framework/api/crud-service";
import WorkspaceUser from "../../../services/workspaces/entities/workspace_user";
import { ChannelVisibility } from "../../../services/channels/types";
import { Channel, ChannelMember } from "../../../services/channels/entities";
import { formatCompany } from "../../../services/user/utils";
import gr from "../../../services/global-resolver";
import { formatUser } from "../../../utils/users";
@@ -105,91 +103,6 @@ const command: yargs.CommandModule<unknown, CLIArgs> = {
}
writeFileSync(`${output}/users.json`, JSON.stringify(users));
//Channels
console.log("- Create channels json file");
const directChannels: Channel[] = [];
let allPublicChannels: Channel[] = [];
let pagination = new Pagination();
do {
const page = await gr.services.channels.channels.getDirectChannelsInCompany(
pagination,
company.id,
undefined,
);
for (const channel of page.getEntities()) {
const channelDetail = await gr.services.channels.channels.get(
{
company_id: channel.company_id,
workspace_id: "direct",
id: channel.id,
},
undefined,
);
directChannels.push(channelDetail);
}
pagination = page.nextPage as Pagination;
} while (pagination.page_token);
for (const workspace of workspaces) {
let pagination = new Pagination();
let publicChannels: Channel[] = [];
pagination = new Pagination();
do {
const page = await gr.services.channels.channels.list(
pagination,
{},
{
user: { id: "", server_request: true },
workspace: { workspace_id: workspace.id, company_id: company.id },
},
);
const chans = page.getEntities().filter(c => c.visibility == ChannelVisibility.PUBLIC);
allPublicChannels = [...allPublicChannels, ...chans];
publicChannels = [...publicChannels, ...chans];
pagination = page.nextPage as Pagination;
} while (pagination.page_token);
mkdirSync(`${output}/workspaces/${workspace.id}`, { recursive: true });
writeFileSync(
`${output}/workspaces/${workspace.id}/channels.json`,
JSON.stringify(publicChannels),
);
}
writeFileSync(`${output}/direct_channels.json`, JSON.stringify(directChannels));
//Channels users
console.log("- Create channels users json file");
for (const channel of [...allPublicChannels /*, ...directChannels*/]) {
let members: ChannelMember[] = [];
let pagination = new Pagination();
do {
const page = await gr.services.channels.members.list(
pagination,
{},
{
user: { id: "", server_request: true },
channel: {
company_id: channel.company_id,
workspace_id: channel.workspace_id,
id: channel.id,
},
},
);
members = [...members, ...page.getEntities()] as ChannelMember[];
pagination = page.nextPage as Pagination;
} while (pagination.page_token);
mkdirSync(`${output}/workspaces/${channel.workspace_id}/channels/${channel.id}`, {
recursive: true,
});
writeFileSync(
`${output}/workspaces/${channel.workspace_id}/channels/${channel.id}/members.json`,
JSON.stringify(members),
);
}
await platform.stop();
},
};
@@ -6,7 +6,6 @@ import { DatabaseServiceAPI } from "../../../core/platform/services/database/api
import { Pagination } from "../../../core/platform/framework/api/crud-service";
import User, { TYPE as UserTYPE } from "../../../services/user/entities/user";
import { Channel } from "../../../services/channels/entities";
import Repository from "../../../core/platform/services/database/services/orm/repository/repository";
import { SearchServiceAPI } from "../../../core/platform/services/search/api";
import CompanyUser, { TYPE as CompanyUserTYPE } from "../../../services/user/entities/company_user";
@@ -29,7 +28,6 @@ class SearchIndexAll {
public async run(options: Options = {}): Promise<void> {
const repositories: Map<string, Repository<any>> = new Map();
repositories.set("users", await this.database.getRepository(UserTYPE, User));
repositories.set("channels", await this.database.getRepository("channels", Channel));
const repository = repositories.get(options.repository);
if (!repository) {
@@ -1,4 +1,3 @@
import { Channel } from "../../../../services/channels/entities/channel";
import Company from "../../../../services/user/entities/company";
import Workspace from "../../../../services/workspaces/entities/workspace";
import User from "../../../../services/user/entities/user";
@@ -7,7 +6,6 @@ export type EmailBuilderDataPayload = {
user: User;
company: Company;
notifications: {
channel: Channel;
workspace: Workspace;
}[];
};
@@ -1,151 +0,0 @@
import axios, { AxiosInstance } from "axios";
import { KnowledgeGraphCreateBodyRequest, KnowledgeGraphCreateMessageObjectData } from "./types";
import { md5 } from "../../../../core/crypto";
import { Channel } from "../../../../services/channels/entities";
import gr from "../../../../services/global-resolver";
import Company from "../../../../services/user/entities/company";
import User from "../../../../services/user/entities/user";
import Workspace from "../../../../services/workspaces/entities/workspace";
import { getLogger, TdriveLogger } from "../../framework";
export default class KnowledgeGraphAPIClient {
protected readonly version = "1.0.0";
protected readonly axiosInstance: AxiosInstance = axios.create();
readonly apiUrl: string;
readonly logger: TdriveLogger = getLogger("knowledge-graph-api-client");
constructor(apiUrl: string) {
this.apiUrl = apiUrl;
}
private async getUserKGId(id: string, provider_id?: string) {
provider_id = provider_id || (await gr.services.users.get({ id }))?.identity_provider_id;
return provider_id;
}
private async getUserKGMailId(id: string, email?: string) {
email = email || (await gr.services.users.get({ id }))?.email_canonical;
return md5(email.trim().toLocaleLowerCase());
}
private async getCompanyKGId(id: string, identity_provider_id?: string) {
identity_provider_id =
identity_provider_id ||
(await gr.services.companies.getCompany({ id }))?.identity_provider_id ||
id;
return identity_provider_id;
}
public async onCompanyCreated(company: Partial<Company>): Promise<void> {
this.send({
records: [
{
key: "null",
value: {
id: "Company",
properties: {
_kg_company_id: await this.getCompanyKGId(company.id, company.identity_provider_id),
company_id: company.id,
company_name: company.displayName || company.name,
},
},
},
],
});
}
public async onWorkspaceCreated(workspace: Partial<Workspace>): Promise<void> {
const response = await this.send({
records: [
{
key: "null",
value: {
id: "Workspace",
properties: {
_kg_company_id: await this.getCompanyKGId(workspace.company_id),
company_id: workspace.company_id,
workspace_name: workspace.name,
workspace_id: workspace.id,
},
},
},
],
});
if (response.statusText === "OK") {
this.logger.info("onWorkspaceCreated %o", response.config.data);
}
}
public async onUserCreated(companyId: string, user: Partial<User>): Promise<void> {
const response = await this.send({
records: [
{
key: "null",
value: {
id: "User",
properties: {
_kg_user_id: await this.getUserKGId(user.id, user.identity_provider_id),
_kg_email_id: await this.getUserKGMailId(user.id, user.email_canonical),
_kg_company_all_id: await Promise.all(
user.cache.companies.map(async c => await this.getCompanyKGId(c)),
),
user_id: user.id,
email: user.email_canonical,
username: user.username_canonical,
user_last_activity: user.last_activity,
first_name: user.first_name,
user_created_at: user.creation_date,
last_name: user.last_name,
company_id: companyId,
},
},
},
],
});
if (response.statusText === "OK") {
this.logger.info("onUserCreated %o", response.config.data);
}
}
public async onChannelCreated(channel: Partial<Channel>): Promise<void> {
const response = await this.send({
records: [
{
key: "null",
value: {
id: "Channel",
properties: {
_kg_user_id: await this.getUserKGId(channel.owner),
_kg_email_id: await this.getUserKGMailId(channel.owner),
_kg_company_id: await this.getCompanyKGId(channel.company_id),
channel_id: channel.id,
channel_name: channel.name,
channel_owner: channel.owner,
workspace_id: channel.workspace_id,
company_id: channel.company_id,
},
},
},
],
});
if (response.statusText === "OK") {
this.logger.info("onChannelCreated %o", response.config.data);
}
}
private async send(data: any) {
return await this.axiosInstance.post<
KnowledgeGraphCreateBodyRequest<KnowledgeGraphCreateMessageObjectData[]>
>(`${this.apiUrl}/topics/tdrive`, data, {
headers: {
"Content-Type": "application/vnd.kafka.json.v2+json",
Accept: "application/vnd.kafka.v2+json",
},
});
}
}
@@ -1,144 +0,0 @@
import { Configuration, Consumes, getLogger, TdriveLogger, TdriveService } from "../../framework";
import { localEventBus } from "../../framework/event-bus";
import KnowledgeGraphAPI from "./provider";
import Workspace from "../../../../services/workspaces/entities/workspace";
import Company from "../../../../services/user/entities/company";
import User from "../../../../services/user/entities/user";
import { Channel } from "../../../../services/channels/entities";
import {
KnowledgeGraphGenericEventPayload,
KnowledgeGraphEvents,
KnowledgeGraphCallbackEvent,
} from "./types";
import KnowledgeGraphAPIClient from "./api-client";
import gr from "../../../../services/global-resolver";
@Consumes([])
export default class KnowledgeGraphService
extends TdriveService<KnowledgeGraphAPI>
implements KnowledgeGraphAPI
{
readonly name = "knowledge-graph";
readonly version = "1.0.0";
protected kgAPIClient: KnowledgeGraphAPIClient = this.getKnowledgeGraphApiClient();
logger: TdriveLogger = getLogger("knowledge-graph-service");
async doInit(): Promise<this> {
const use = this.getConfigurationEntry<boolean>("use");
if (!use) {
this.logger.warn("Knowledge graph is not used");
return this;
}
localEventBus.subscribe<KnowledgeGraphGenericEventPayload<Company>>(
KnowledgeGraphEvents.COMPANY_UPSERT,
this.onCompanyCreated.bind(this),
);
localEventBus.subscribe<KnowledgeGraphGenericEventPayload<Workspace>>(
KnowledgeGraphEvents.WORKSPACE_UPSERT,
this.onWorkspaceCreated.bind(this),
);
localEventBus.subscribe<KnowledgeGraphGenericEventPayload<Channel>>(
KnowledgeGraphEvents.CHANNEL_UPSERT,
this.onChannelCreated.bind(this),
);
localEventBus.subscribe<KnowledgeGraphGenericEventPayload<User>>(
KnowledgeGraphEvents.USER_UPSERT,
this.onUserCreated.bind(this),
);
return this;
}
/** When the KG service send us new events */
async onCallbackEvent(token: string, data: KnowledgeGraphCallbackEvent): Promise<void> {
if (token === this.getConfigurationEntry<string>("callback_token")) {
this.logger.info("Unimplemented: KnowledgeGraph - Callback event", data);
} else {
throw new Error("Invalid token");
}
}
async onCompanyCreated(data: KnowledgeGraphGenericEventPayload<Company>): Promise<void> {
this.logger.info(`${KnowledgeGraphEvents.COMPANY_UPSERT} %o`, data);
if (this.kgAPIClient && (await this.shouldForwardEvent([data.resource.id]))) {
this.kgAPIClient.onCompanyCreated(data.resource);
}
}
async onWorkspaceCreated(data: KnowledgeGraphGenericEventPayload<Workspace>): Promise<void> {
this.logger.info(`${KnowledgeGraphEvents.WORKSPACE_UPSERT} %o`, data);
if (this.kgAPIClient && (await this.shouldForwardEvent([data.resource.company_id]))) {
this.kgAPIClient.onWorkspaceCreated(data.resource);
}
}
async onChannelCreated(data: KnowledgeGraphGenericEventPayload<Channel>): Promise<void> {
this.logger.info(`${KnowledgeGraphEvents.CHANNEL_UPSERT} %o`, data);
if (this.kgAPIClient && (await this.shouldForwardEvent([data.resource.company_id]))) {
this.kgAPIClient.onChannelCreated(data.resource);
}
}
async onUserCreated(data: KnowledgeGraphGenericEventPayload<User>): Promise<void> {
this.logger.info(`${KnowledgeGraphEvents.USER_UPSERT} %o`, data);
if (
this.kgAPIClient &&
(await this.shouldForwardEvent(data.resource.cache?.companies || [], data.resource.id))
) {
for (const companyId of data.resource.cache?.companies || []) {
this.kgAPIClient.onUserCreated(companyId, data.resource);
}
}
}
private getConfigurationEntry<T>(key: string): T {
const configuration = new Configuration("knowledge-graph");
return configuration.get(key);
}
private getKnowledgeGraphApiClient(): KnowledgeGraphAPIClient {
const endpoint = this.getConfigurationEntry<string>("endpoint");
if (endpoint && endpoint.length) {
this.kgAPIClient = new KnowledgeGraphAPIClient(endpoint);
} else {
this.logger.info("KnowledgeGraph - No endpoint defined in default.json");
}
return this.kgAPIClient;
}
async shouldForwardEvent(
companyIds: string[] | null,
userId?: string,
): Promise<false | "all" | "metadata"> {
const user = userId ? await gr.services.users.get({ id: userId }) : null;
const forwardedCompanies = this.getConfigurationEntry<string[]>("forwarded_companies");
const isCompanyForwarded = !!(companyIds || []).find(v => forwardedCompanies.includes(v));
if (user?.preferences && !user.preferences.knowledge_graph)
user.preferences.knowledge_graph = "metadata";
return (!userId || (user && user.preferences?.knowledge_graph !== "nothing")) &&
(!companyIds ||
companyIds.length === 0 ||
isCompanyForwarded ||
forwardedCompanies.length === 0)
? user
? (user.preferences.knowledge_graph as "all" | "metadata")
: "all"
: false;
}
api(): KnowledgeGraphAPI {
return this;
}
}
@@ -1,13 +0,0 @@
import Company from "../../../../services/user/entities/company";
import { Channel } from "../../../../services/channels/entities";
import { TdriveServiceProvider } from "../../framework";
import { KnowledgeGraphGenericEventPayload } from "./types";
import Workspace from "../../../../services/workspaces/entities/workspace";
import User from "../../../../services/user/entities/user";
export default interface KnowledgeGraphAPI extends TdriveServiceProvider {
onCompanyCreated(data: KnowledgeGraphGenericEventPayload<Company>): void;
onWorkspaceCreated(data: KnowledgeGraphGenericEventPayload<Workspace>): void;
onChannelCreated(data: KnowledgeGraphGenericEventPayload<Channel>): void;
onUserCreated(data: KnowledgeGraphGenericEventPayload<User>): void;
}
@@ -1,109 +0,0 @@
export type KnowledgeGraphCreateCompanyObjectData = {
key: string;
value: {
id: string;
properties: {
company_id: string;
company_name: string;
};
};
};
export type KnowledgeGraphCreateWorkspaceObjectData = {
key: string;
value: {
id: string;
properties: {
company_id: string;
workspace_name: string;
workspace_id: string;
};
};
};
export type KnowledgeGraphCreateUserObjectData = {
key: string;
value: {
id: string;
properties: {
user_id: string;
email: string;
username: string;
user_last_activity: string;
first_name: string;
user_created_at: string;
last_name: string;
company_id: string;
};
};
};
export type KnowledgeGraphCreateChannelObjectData = {
key: string;
value: {
id: string;
properties: {
channel_id: string;
channel_name: string;
channel_owner: string;
workspace_id: string;
};
};
};
export type KnowledgeGraphCreateMessageObjectData = {
key: string;
value: {
id: string;
properties: {
message_thread_id: string;
message_created_at: string;
message_content: string;
type_message: string;
message_updated_at: string;
user_id: string;
channel_id: string;
};
};
};
export type KnowledgeGraphCreateBodyRequest<T> = {
records: T;
};
export type KnowledgeGraphRelationLinkObject = {
relation: "mention" | "sender" | "parent" | "children" | "owner";
type: "user" | "channel" | "workspace" | "company" | "message";
id: string;
};
export type KnowledgeGraphGenericEventPayload<T> = {
id: string;
resource: Partial<T>;
links: KnowledgeGraphRelationLinkObject[];
};
export enum KnowledgeGraphEvents {
COMPANY_UPSERT = "kg:company:upsert",
WORKSPACE_UPSERT = "kg:workspace:upsert",
CHANNEL_UPSERT = "kg:channel:upsert",
MESSAGE_UPSERT = "kg:message:upsert",
USER_UPSERT = "kg:user:upsert",
}
export type KnowledgeGraphCallbackEvent = {
recipients: {
type: "user";
id: string; // KG user id which is a md5 of the email
}[];
event: {
type: "user_tags"; //More events will be added later
data: {
//For user_tags event only
tags?: {
value: string;
weight: number;
}[];
};
};
};
@@ -1,189 +0,0 @@
import { FastifyRequest, RouteHandlerMethod } from "fastify";
import { FastifyInstance } from "fastify/types/instance";
import { IncomingMessage, Server, ServerResponse } from "http";
import {
ChannelCrudController,
ChannelMemberCrudController,
} from "../../../../services/channels/web/controllers";
import {
ChannelMemberParameters,
ChannelParameters,
CreateChannelBody,
} from "../../../../services/channels/web/types";
import { Consumes, TdriveService } from "../../framework";
import WebServerAPI from "../webserver/provider";
import WebSocketAPI from "../websocket/provider";
import PhpNodeAPI from "./provider";
import { RealtimeServiceAPI } from "../realtime/api";
import gr from "../../../../services/global-resolver";
@Consumes(["webserver", "websocket", "user", "channels"])
export default class PhpNodeService extends TdriveService<PhpNodeAPI> implements PhpNodeAPI {
name = "phpnode";
version = "1";
private server: FastifyInstance<Server, IncomingMessage, ServerResponse>;
private ws: WebSocketAPI;
private realtime: RealtimeServiceAPI;
api(): PhpNodeAPI {
return this;
}
async accessControl(
request: FastifyRequest,
server: FastifyInstance<Server, IncomingMessage, ServerResponse>,
) {
const token = (request.headers.authorization || "").trim().split("Token ").pop();
const secret = this.configuration.get<string>("secret", "");
let authorized = false;
if (secret && token === secret) {
authorized = true;
}
if (!authorized) {
throw server.httpErrors.badRequest("Internal Access Refused");
}
}
register(paremeters: {
method: "DELETE" | "GET" | "POST" | "PUT";
url: string;
handler: RouteHandlerMethod;
}) {
this.server.register((instance, _opts, next) => {
instance.register(
(internalServer, _, next) => {
internalServer.route({
method: paremeters.method,
url: paremeters.url,
preValidation: [request => this.accessControl(request, internalServer)],
handler: paremeters.handler,
});
next();
},
{ prefix: "/private" },
);
next();
});
}
async doStart(): Promise<this> {
return this;
}
async doInit(): Promise<this> {
this.server = this.context.getProvider<WebServerAPI>("webserver").getServer();
this.ws = this.context.getProvider<WebSocketAPI>("websocket");
this.realtime = this.context.getProvider<RealtimeServiceAPI>("realtime");
/**
* Register private calls from php for websockets
*/
this.register({
method: "POST",
url: "/pusher",
handler: (request, reply) => {
const body = request.body as { room: string; data: any };
const room = body.room;
const data = body.data;
this.ws.getIo().to(room).emit("realtime:event", { name: room, data: data });
reply.send({});
},
});
/**
* Register private calls from php channels
*/
this.register({
method: "GET",
url: "/companies/:company_id/workspaces/:workspace_id/channels/:id/members/:member_id/exists",
handler: (request: FastifyRequest<{ Params: ChannelMemberParameters }>, reply) => {
if (!gr.services) {
reply.code(500).send(); //Server is not ready
return;
}
const membersController = new ChannelMemberCrudController();
membersController.exists(request, reply);
},
});
/**
* Register private calls from php channels
*/
this.register({
method: "GET",
url: "/companies/:company_id/workspaces/:workspace_id/channels/:id",
handler: (request: FastifyRequest<{ Params: ChannelParameters }>, reply) => {
if (!gr.services) {
reply.code(500).send(); //Server is not ready
return;
}
const channelsController = new ChannelCrudController();
channelsController.getForPHP(request, reply);
},
});
/**
* Register private calls from php channels
*/
this.register({
method: "GET",
url: "/companies/:company_id/channels/:id",
handler: async (
request: FastifyRequest<{ Params: { company_id: string; id: string } }>,
reply,
) => {
if (!gr.services) {
reply.code(500).send(); //Server is not ready
return;
}
const workspaces = await gr.services.workspaces.getAllForCompany(request.params.company_id);
for (const w of workspaces) {
const channel = await gr.services.channels.channels.get(
{
company_id: request.params.company_id,
workspace_id: w.id,
id: request.params.id,
},
undefined,
);
if (channel) {
reply.send(channel);
return;
}
}
reply.code(404).send();
},
});
/**
* Register private calls from php channels
*/
this.register({
method: "POST",
url: "/companies/:company_id/workspaces/:workspace_id/channels/defaultchannel",
handler: (
request: FastifyRequest<{
Body: CreateChannelBody;
Params: ChannelParameters;
Querystring: { include_users: boolean };
}>,
reply,
) => {
if (!gr.services) {
reply.code(500).send(); //Server is not ready
return;
}
const channelsController = new ChannelCrudController();
request.currentUser = {
id: (request.body as any).user_id,
};
channelsController.save(request, reply);
},
});
return this;
}
}
@@ -1,11 +0,0 @@
import { FastifyInstance, FastifyRequest, RouteHandlerMethod } from "fastify";
import { IncomingMessage, ServerResponse, Server } from "http";
import { TdriveServiceProvider } from "../../framework";
export default interface PhpNodeAPI extends TdriveServiceProvider {
accessControl(
request: FastifyRequest,
server: FastifyInstance<Server, IncomingMessage, ServerResponse>,
): Promise<void>;
register(parameters: { method: string; url: string; handler: RouteHandlerMethod }): void;
}
@@ -43,49 +43,9 @@ export default class Tracker extends TdriveService<TrackerAPI> implements Tracke
);
});
const channelCreatedEvent = "channel:created";
localEventBus.subscribe<ResourceEventsPayload>(channelCreatedEvent, data => {
logger.debug(`Tracker - New ${channelCreatedEvent} event`);
this.track(
{
user: data.user,
event: channelCreatedEvent,
properties: this.getVisibilityObject(data.channel.visibility),
},
(err: Error) =>
err
? logger.error({ err }, "Tracker - Error while tracking", channelCreatedEvent)
: false,
);
});
const channelMemberCreatedEvent = "channel:member:created";
localEventBus.subscribe<ResourceEventsPayload>(channelMemberCreatedEvent, data => {
logger.debug(`Tracker - New ${channelMemberCreatedEvent} event`);
this.track(
{
user: data.user,
event: data.user.id !== data.member.user_id ? "channel:invite" : "channel:join",
properties: this.getVisibilityObject(data.channel.visibility),
},
(err: Error) =>
err
? logger.error({ err }, "Tracker - Error while tracking", channelMemberCreatedEvent)
: false,
);
});
return this;
}
private getVisibilityObject(visibility: string) {
return {
is_direct: visibility === "direct" ? true : false,
is_private: visibility === "private" ? true : false,
is_public: visibility === "public" ? true : false,
};
}
public async identify(
identity: IdentifyObjectType,
callback?: (err: Error) => void,
@@ -1,59 +0,0 @@
import { Type } from "class-transformer";
import { Channel } from ".";
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
import { ChannelStats } from "../services/channel/types";
import { ChannelType } from "../types";
@Entity("channel_activity", {
primaryKey: [["company_id", "workspace_id"], "channel_id"],
type: "channel_activity",
})
export class ChannelActivity {
// uuid-v4
@Type(() => String)
@Column("company_id", "string", { generator: "uuid" })
company_id: string;
// "uuid-v4" | "direct"
@Type(() => String)
@Column("workspace_id", "string", { generator: "uuid" })
workspace_id: string | ChannelType.DIRECT;
// uuid-v4
@Type(() => String)
@Column("channel_id", "string", { generator: "uuid" })
channel_id: string;
@Column("last_activity", "number")
last_activity: number;
@Column("stats", "encoded_json")
stats: ChannelStats;
@Column("last_message", "encoded_json")
last_message:
| null
| Record<string, unknown>
| {
date: number;
sender: string;
sender_name: string;
title: string;
text: string;
};
/* Not stored in database */
channel: Channel;
public getChannelPrimaryKey(): Channel {
if (this.channel) {
return this.channel;
}
const channel = new Channel();
channel.id = this.channel_id;
channel.workspace_id = this.workspace_id;
channel.company_id = this.company_id;
return channel;
}
}
@@ -1,35 +0,0 @@
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
export const TYPE = "channel_counters";
@Entity(TYPE, {
primaryKey: [["id"], "counter_type", "company_id", "workspace_id"],
type: TYPE,
})
export class ChannelCounterEntity {
@Column("id", "uuid")
id: string;
@Column("counter_type", "string")
counter_type: string;
@Column("company_id", "uuid")
company_id: string;
@Column("workspace_id", "string")
workspace_id: string;
@Column("value", "counter")
value: number;
}
export type ChannelCounterPrimaryKey = Pick<
ChannelCounterEntity,
"id" | "counter_type" | "company_id" | "workspace_id"
>;
export enum ChannelUserCounterType {
MEMBERS = "members",
MESSAGES = "messages",
GUESTS = "guests",
}
@@ -1,34 +0,0 @@
import { Type } from "class-transformer";
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
@Entity("channel_member_read_cursors", {
primaryKey: [["company_id"], "channel_id", "user_id"],
type: "channel_member_read_cursors",
})
export class ChannelMemberReadCursors {
/**
* Primary key
*/
@Type(() => String)
@Column("company_id", "uuid", { generator: "uuid" })
company_id: string;
/**
* Primary key
*/
@Type(() => String)
@Column("user_id", "uuid")
user_id: string;
/**
* Primary key
*/
@Type(() => String)
@Column("channel_id", "uuid")
channel_id: string;
@Column("read_section", "encoded_json")
read_section: ReadSection;
}
export type ReadSection = [string, string];
@@ -1,138 +0,0 @@
import { Type } from "class-transformer";
import { merge } from "lodash";
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
import { ChannelMemberNotificationLevel, ChannelMemberType } from "../types";
import { UserObject } from "../../../services/user/web/types";
/**
* Defines the member <-> channel link and member settings in channel
* Table name is `user_channels`
*/
@Entity("user_channels", {
primaryKey: [["company_id", "workspace_id"], "user_id", "channel_id"],
type: "user_channels",
})
export class ChannelMember {
/**
* Primary key
*/
@Type(() => String)
@Column("company_id", "uuid", { generator: "uuid" })
company_id: string;
/**
* Primary key
* Null for direct channels
*/
@Type(() => String)
@Column("workspace_id", "string")
workspace_id: string;
/**
* Primary key
*/
@Type(() => String)
@Column("user_id", "uuid")
user_id: string;
/**
* Primary key
*/
@Type(() => String)
@Column("channel_id", "uuid")
channel_id: string;
@Column("type", "string")
type: ChannelMemberType = ChannelMemberType.MEMBER;
/**
* Timestamp in secconds
*/
@Column("last_access", "number")
last_access: number;
/**
* When last updated
*/
@Column("last_increment", "number")
last_increment: number;
/**
* Member marked this channel as favorite?
*/
@Column("favorite", "boolean")
favorite = false;
/**
* Member defined the notification level for the channel
* Defaults to all
*/
@Column("notification_level", "string")
notification_level: ChannelMemberNotificationLevel = ChannelMemberNotificationLevel.ALL;
/**
* Member expiration in channel (only for guests)
*/
@Column("expiration", "number")
expiration: boolean | number;
/**
* Every collection entity must have an id, here it is the user_id
*/
@Type(() => String)
@Column("id", "string")
id: string;
@Column("updated_at", "number", { onUpsert: _ => new Date().getTime() })
updated_at: number;
@Column("created_at", "number", { onUpsert: d => d || new Date().getTime() })
created_at: number;
}
export type ChannelMemberPrimaryKey = Pick<
ChannelMember,
"channel_id" | "company_id" | "user_id" | "workspace_id"
>;
export function getChannelMemberInstance(member: Partial<ChannelMember>): ChannelMember {
return merge(new ChannelMember(), member);
}
export type ChannelMemberWithUser = ChannelMember & { user: UserObject };
/**
* The channel_members table allows to get all the members of a channel.
* Table name is `channel_members`
*/
@Entity("channel_members", {
primaryKey: [["company_id", "workspace_id"], "channel_id", "user_id"],
type: "channel_members",
})
export class MemberOfChannel {
/**
* Primary key
*/
@Type(() => String)
@Column("company_id", "uuid", { generator: "uuid" })
company_id: string;
@Type(() => String)
@Column("workspace_id", "string")
workspace_id: string;
@Type(() => String)
@Column("user_id", "uuid")
user_id: string;
@Type(() => String)
@Column("channel_id", "uuid")
channel_id: string;
@Column("type", "string")
type: ChannelMemberType = ChannelMemberType.MEMBER;
}
export function getMemberOfChannelInstance(member: Partial<MemberOfChannel>): MemberOfChannel {
return merge(new MemberOfChannel(), member);
}
@@ -1,46 +0,0 @@
import { Type } from "class-transformer";
import { merge } from "lodash";
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
@Entity("channel_pending_emails", {
primaryKey: [["company_id", "workspace_id"], "channel_id", "email"],
type: "channel_pending_emails",
})
export class ChannelPendingEmails {
/**
* Primary key
*/
@Type(() => String)
@Column("company_id", "uuid", { generator: "uuid" })
company_id: string;
/**
* Primary key
*/
@Type(() => String)
@Column("workspace_id", "uuid")
workspace_id: string;
/**
* Primary key
*/
@Type(() => String)
@Column("channel_id", "uuid")
channel_id: string;
/**
* Pending email
*/
@Type(() => String)
@Column("email", "string")
email: string;
}
export type ChannelPendingEmailsPrimaryKey = Pick<
ChannelPendingEmails,
"channel_id" | "company_id" | "workspace_id" | "email"
>;
export function getInstance(pendingEmail: ChannelPendingEmails): ChannelPendingEmails {
return merge(new ChannelPendingEmails(), pendingEmail);
}
@@ -1,24 +0,0 @@
import { Channel } from "./channel";
export default {
index: "channels",
source: (entity: Channel) => {
return {
workspace_id: entity.workspace_id,
company_id: entity.company_id,
name: entity.channel_group + " " + entity.name,
};
},
mongoMapping: {
text: {
name: "text",
},
},
esMapping: {
properties: {
name: { type: "text", index_prefixes: { min_chars: 1 } },
workspace_id: { type: "keyword" },
company_id: { type: "keyword" },
},
},
};
@@ -1,108 +0,0 @@
import { Type } from "class-transformer";
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
import { ChannelType, ChannelVisibility } from "../types";
import { ChannelMember } from "./channel-member";
import { UserObject } from "../../user/web/types";
import { merge } from "lodash";
import search from "./channel.search";
import { ChannelActivity } from "./channel-activity";
@Entity("channels", {
primaryKey: [["company_id", "workspace_id"], "id"],
type: "channels",
search,
})
export class Channel {
// uuid-v4
@Type(() => String)
@Column("company_id", "uuid", { generator: "uuid" })
company_id: string;
@Type(() => String)
@Column("workspace_id", "string", { generator: "uuid" })
workspace_id: string | ChannelType.DIRECT;
@Type(() => String)
@Column("id", "uuid", { generator: "uuid" })
id: string;
@Column("name", "encoded_string")
name: string;
@Column("icon", "encoded_string")
icon: string;
@Column("description", "encoded_string")
description: string;
@Column("channel_group", "encoded_string")
channel_group: string;
@Column("is_readonly", "boolean")
is_readonly = false;
@Column("visibility", "encoded_string")
visibility: ChannelVisibility;
@Column("is_default", "boolean")
is_default = false;
@Column("archived", "boolean")
archived = false;
@Column("archivation_date", "number")
archivation_date: number;
// uuid
@Column("owner", "uuid")
@Type(() => String)
owner: string;
//This is only used for direct channels
@Column("members", "encoded_json")
members: string[] = [];
@Column("connectors", "encoded_json")
connectors: string[] = []; //list of app-ids
@Column("updated_at", "number", { onUpsert: _ => new Date().getTime() })
updated_at: number;
@Column("created_at", "number", { onUpsert: d => d || new Date().getTime() })
created_at: number;
static isPrivateChannel(channel: Channel): boolean {
return channel.visibility === ChannelVisibility.PRIVATE;
}
static isPublicChannel(channel: Channel): boolean {
return channel.visibility === ChannelVisibility.PUBLIC;
}
static isDirectChannel(channel: Channel): boolean {
return (
channel.visibility === ChannelVisibility.DIRECT ||
channel.workspace_id === ChannelVisibility.DIRECT
);
}
static isDefaultChannel(channel: Channel): boolean {
return channel?.is_default;
}
}
export class UserChannel extends Channel {
user_member: ChannelMember;
last_activity: ChannelActivity["last_activity"];
last_message: ChannelActivity["last_message"];
}
export class UsersIncludedChannel extends Channel {
users: UserObject[];
}
export function getInstance(channel: Partial<Channel>): Channel {
return merge(new Channel(), channel);
}
export type ChannelPrimaryKey = Pick<Channel, "company_id" | "workspace_id" | "id">;
@@ -1,34 +0,0 @@
import { Type } from "class-transformer";
import { merge } from "lodash";
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
import { ChannelType } from "../types";
@Entity("default_channels", {
primaryKey: [["company_id", "workspace_id"], "channel_id"],
type: "default_channels",
})
export class DefaultChannel {
// uuid-v4
@Type(() => String)
@Column("company_id", "string", { generator: "uuid" })
company_id: string;
// "uuid-v4" | "direct"
@Type(() => String)
@Column("workspace_id", "string", { generator: "uuid" })
workspace_id: string | ChannelType.DIRECT;
// uuid-v4
@Type(() => String)
@Column("channel_id", "string", { generator: "uuid" })
channel_id: string;
}
export type DefaultChannelPrimaryKey = Pick<
DefaultChannel,
"channel_id" | "company_id" | "workspace_id"
>;
export function getInstance(channel: DefaultChannel): DefaultChannel {
return merge(new DefaultChannel(), channel);
}
@@ -1,47 +0,0 @@
import { Type } from "class-transformer";
import { merge } from "lodash";
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
/**
* Direct Channel information.
* A direct channel is a channel in a company composed of a defined set of users.
*/
@Entity("direct_channels", {
primaryKey: [["company_id"], "users", "channel_id"],
type: "direct_channels",
})
export class DirectChannel {
/**
* The company identifier of this channel
* uuid-v4
*/
@Type(() => String)
@Column("company_id", "uuid", { generator: "uuid" })
company_id: string;
/**
* The link to Channel.id
* uuid-v4
*/
@Type(() => String)
@Column("channel_id", "string")
channel_id: string;
/**
* CSV list of ordered user ids
*/
@Column("users", "string")
users: string;
static getUsersAsString(users: string[] = []): string {
return users.sort().join(",");
}
static getUsersFromString(identifier: string = ""): string[] {
return identifier.split(",");
}
}
export function getInstance(channel: DirectChannel): DirectChannel {
return merge(new DirectChannel(), channel);
}
@@ -1,16 +0,0 @@
export * from "./channel";
export * from "./member";
export * from "./tab";
export * from "./channel-member";
export * from "./channel-member-read-cursors";
export {
ChannelPendingEmails,
ChannelPendingEmailsPrimaryKey,
getInstance as getChannelPendingEmailsInstance,
} from "./channel-pending-emails";
export {
DefaultChannel,
DefaultChannelPrimaryKey,
getInstance as getDefaultChannelInstance,
} from "./default-channel";
@@ -1,6 +0,0 @@
import { Type } from "class-transformer";
export class Member {
@Type(() => String)
id: string;
}
@@ -1,55 +0,0 @@
import { Type } from "class-transformer";
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
import { ChannelType } from "../types";
@Entity("channel_tabs", {
primaryKey: [["company_id", "workspace_id"], "channel_id", "id"],
type: "channel_tabs",
})
export class ChannelTab {
// uuid-v4
@Type(() => String)
@Column("company_id", "string", { generator: "uuid" })
company_id: string;
// "uuid-v4" | "direct"
@Type(() => String)
@Column("workspace_id", "string", { generator: "uuid" })
workspace_id: string | ChannelType.DIRECT;
// uuid-v4
@Type(() => String)
@Column("channel_id", "string", { generator: "uuid" })
channel_id: string;
// uuid-v4
@Type(() => String)
@Column("id", "string", { generator: "uuid" })
id: string;
@Column("name", "encoded_string")
name: string;
@Column("configuration", "encoded_json")
configuration: string;
@Column("application_id", "encoded_string")
application_id: string;
@Column("owner", "encoded_string")
owner: string;
@Column("col_order", "encoded_string")
order: string;
@Column("updated_at", "number", { onUpsert: _ => new Date().getTime() })
updated_at: number;
@Column("created_at", "number", { onUpsert: d => d || new Date().getTime() })
created_at: number;
}
export type ChannelTabPrimaryKey = Pick<
ChannelTab,
"company_id" | "workspace_id" | "channel_id" | "id"
>;
@@ -1,23 +0,0 @@
import { Prefix, TdriveService } from "../../core/platform/framework";
import WebServerAPI from "../../core/platform/services/webserver/provider";
import web from "./web";
@Prefix("/internal/services/channels/v1")
export default class ChannelService extends TdriveService<undefined> {
version = "1";
name = "channels";
public async doInit(): Promise<this> {
const fastify = this.context.getProvider<WebServerAPI>("webserver").getServer();
fastify.register((instance, _opts, next) => {
web(instance, { prefix: this.prefix });
next();
});
return this;
}
// TODO: remove
api(): undefined {
return undefined;
}
}
@@ -1,223 +0,0 @@
import { getLogger, Initializable } from "../../../../../core/platform/framework";
import { localEventBus } from "../../../../../core/platform/framework/event-bus";
import { Channel as ChannelEntity, ChannelMember } from "../../../entities/";
import { ResourceEventsPayload } from "../../../../../utils/types";
import { ActivityObjectType, ActivityPublishedType } from "./types";
import _, { sortBy } from "lodash";
import { MessageQueueServiceAPI } from "../../../../../core/platform/services/message-queue/api";
import { ChannelParameters } from "../../../web/types";
import { ChannelVisibility } from "../../../types";
const logger = getLogger("channel.activities");
export default class Activities implements Initializable {
messageQueue: MessageQueueServiceAPI;
async init(): Promise<this> {
const channelConnectorCreatedEvent = "channel:connector:created";
const channelConnectorDeletedEvent = "channel:connector:deleted";
const channelMemberCreatedEvent = "channel:member:created";
const channelMemberDeletedEvent = "channel:member:deleted";
const channelTabCreatedEvent = "channel:tab:created";
const channelTabDeletedEvent = "channel:tab:deleted";
const channelUpdatedEvent = "channel:updated";
localEventBus.subscribe<ResourceEventsPayload>(channelMemberCreatedEvent, data => {
if (data.channel.visibility === ChannelVisibility.DIRECT) {
return;
}
//Fixme: We don't show activity from user himself,
// in the future when we aggregate activities then we could keep it,
// but for now it is polution in the channel
if ((data.resourcesAfter[0] as ChannelMember).user_id === data.actor.id) {
return;
}
this.notify(
channelMemberCreatedEvent,
{
type: "channel:activity:member:created",
actor: {
type: "user",
id: data.actor.id,
},
context: {
type: "add",
array: [
// data.actor.id !== data.member.user_id ? "invite" : "join"
{ type: "member", resource: data.resourcesAfter[0] },
],
},
},
data.channel,
);
});
localEventBus.subscribe<ResourceEventsPayload>(channelMemberDeletedEvent, data => {
if (data.channel.visibility === ChannelVisibility.DIRECT) {
return;
}
//Do not notify when user leave the channel by themselves
if (data.resourcesBefore[0].id === data.actor.id) {
return;
}
this.notify(
channelMemberDeletedEvent,
{
type: "channel:activity:member:deleted",
actor: {
type: "user",
id: data.actor.id,
},
context: {
type: "remove",
array: [{ type: "member", resource: data.resourcesBefore[0] }],
},
},
data.channel,
);
});
localEventBus.subscribe<ResourceEventsPayload>(channelUpdatedEvent, data => {
const interestedChanges = ["channel_group", "name", "description", "visibility", "icon"];
const channelChanged = !_.isEqual(
_.pick(data.resourcesBefore[0], interestedChanges),
_.pick(data.resourcesAfter[0], interestedChanges),
);
const connectorsBefore: Partial<ChannelEntity> = _.pick(data.resourcesBefore[0], [
"connectors",
]);
const connectorsAfter: Partial<ChannelEntity> = _.pick(data.resourcesAfter[0], [
"connectors",
]);
const isConnectorCreated: boolean =
connectorsBefore?.connectors.length < connectorsAfter?.connectors.length;
const channelConnectorsChanged = !_.isEqual(
{ connectors: sortBy(connectorsBefore.connectors) },
{ connectors: sortBy(connectorsAfter.connectors) },
);
if (channelConnectorsChanged) {
return localEventBus.publish(
isConnectorCreated ? "channel:connector:created" : "channel:connector:deleted",
data,
);
}
if (channelChanged) {
return this.notify(
channelUpdatedEvent,
{
type: "channel:activity:updated",
actor: {
type: "user",
id: data.actor.id,
},
context: {
type: "diff",
previous: { type: "channel", resource: data.resourcesBefore[0] },
next: { type: "channel", resource: data.resourcesAfter[0] },
},
},
data.channel,
);
}
});
localEventBus.subscribe<ResourceEventsPayload>(channelTabCreatedEvent, data =>
this.notify(
channelTabCreatedEvent,
{
type: "channel:activity:tab:created",
actor: {
type: "user",
id: data.actor.id,
},
context: {
type: "add",
array: [{ type: "tab", resource: data.resourcesAfter[0] }],
},
},
data.channelParameters,
),
);
localEventBus.subscribe<ResourceEventsPayload>(channelTabDeletedEvent, data =>
this.notify(
channelTabDeletedEvent,
{
type: "channel:activity:tab:deleted",
actor: {
type: "user",
id: data.actor.id,
},
context: {
type: "remove",
array: [{ type: "tab", resource: data.resourcesAfter[0] }],
},
},
data.channelParameters,
),
);
localEventBus.subscribe<ResourceEventsPayload>(channelConnectorCreatedEvent, data => {
return this.notify(
channelConnectorCreatedEvent,
{
type: "channel:activity:connector:created",
actor: {
type: "user",
id: data.actor.id,
},
context: {
type: "add",
array: [{ type: "connector", resource: data.resourcesAfter[0] }],
},
},
data.channel,
);
});
localEventBus.subscribe<ResourceEventsPayload>(channelConnectorDeletedEvent, data => {
this.notify(
channelConnectorDeletedEvent,
{
type: "channel:activity:connector:deleted",
actor: {
type: "user",
id: data.actor.id,
},
context: {
type: "remove",
array: [{ type: "connector", resource: data.resourcesBefore[0] }],
},
},
data.channel,
);
});
return this;
}
async notify(
event: string,
data: ActivityObjectType,
channel: ChannelEntity | ChannelParameters,
): Promise<void> {
logger.debug(`Activities - New ${event} event %o`, data);
try {
await this.messageQueue.publish<ActivityPublishedType>("channel:activity_message", {
data: {
channel_id: channel.id,
workspace_id: channel.workspace_id,
company_id: channel.company_id,
activity: data,
},
});
} catch (err) {
logger.warn({ err }, `Activities - Error while publishing to ${event}`);
}
}
}
@@ -1,29 +0,0 @@
import { Channel, User } from "../../../../../utils/types";
import { ChannelMember } from "../../../../../services/channels/entities";
export type ActorType = { type: string; id: User["id"] };
export type ResourceObjectType = {
type: string;
resource: Channel | User | ChannelMember | [];
};
export type ContextType = {
type: "add" | "remove" | "diff";
array?: ResourceObjectType[];
previous?: ResourceObjectType;
next?: ResourceObjectType;
};
export type ActivityObjectType = {
type: string;
actor: ActorType;
context: ContextType;
};
export type ActivityPublishedType = {
channel_id: Channel["id"];
workspace_id: Channel["workspace_id"];
company_id: Channel["company_id"];
activity: ActivityObjectType;
};
@@ -1,113 +0,0 @@
import { Channel, getDefaultChannelInstance } from "../../../entities";
import { getLogger, Initializable } from "../../../../../core/platform/framework";
import { localEventBus } from "../../../../../core/platform/framework/event-bus";
import { ResourceEventsPayload } from "../../../../../utils/types";
import DefaultChannelServiceImpl from "./service";
const logger = getLogger("channel:default:listener");
export default class DefaultChannelListener implements Initializable {
constructor(private service: DefaultChannelServiceImpl) {}
async init(): Promise<this> {
localEventBus.subscribe<ResourceEventsPayload>(
"channel:created",
this.onChannelCreated.bind(this),
);
localEventBus.subscribe<ResourceEventsPayload>(
"channel:updated",
this.onChannelUpdated.bind(this),
);
localEventBus.subscribe<ResourceEventsPayload>(
"channel:deleted",
this.onChannelDeleted.bind(this),
);
return this;
}
onChannelDeleted(event: ResourceEventsPayload): void {
logger.debug("Channel has been deleted %o", event.channel);
if (!event.channel || !Channel.isDefaultChannel(event.channel)) {
return;
}
this.service
.delete(
getDefaultChannelInstance({
channel_id: event.channel.id,
company_id: event.channel.company_id,
workspace_id: event.channel.workspace_id,
}),
)
.catch((err: Error) => {
logger.error({ err }, "Default channel %id can not be deleted", event.channel.id);
});
}
onChannelUpdated(event: ResourceEventsPayload): void {
logger.debug("Channel has been updated %o", event.channel);
if (!event.channel || !event.resourcesBefore || !event.resourcesBefore.length) {
return;
}
const channelUpdatedToDefault =
!Channel.isDefaultChannel(event.resourcesBefore[0] as Channel) &&
Channel.isDefaultChannel(event.channel);
const channelIsNotDefaultAnymore =
Channel.isDefaultChannel(event.resourcesBefore[0] as Channel) &&
!Channel.isDefaultChannel(event.channel);
if (channelUpdatedToDefault) {
logger.debug("Channel %s has been switched to 'default'", event.channel.id);
this.service
.create(
getDefaultChannelInstance({
channel_id: event.channel.id,
company_id: event.channel.company_id,
workspace_id: event.channel.workspace_id,
}),
undefined,
)
.catch((err: Error) => {
logger.error({ err }, "Default channel %id can not be updated", event.channel.id);
});
}
if (channelIsNotDefaultAnymore) {
logger.debug("Channel %s has been switched to !'default'", event.channel.id);
this.service
.delete(
getDefaultChannelInstance({
channel_id: event.channel.id,
company_id: event.channel.company_id,
workspace_id: event.channel.workspace_id,
}),
)
.catch((err: Error) => {
logger.error({ err }, "Default channel %id can not be removed", event.channel.id);
});
}
}
onChannelCreated(event: ResourceEventsPayload): void {
if (!event.channel || !Channel.isDefaultChannel(event.channel)) {
return;
}
logger.debug("Channel %s has been created with 'default' attribute", event.channel.id);
this.service
.create(
getDefaultChannelInstance({
channel_id: event.channel.id,
company_id: event.channel.company_id,
workspace_id: event.channel.workspace_id,
}),
undefined,
)
.catch((err: Error) => {
logger.warn({ err }, "Default channel %id can not be created", event.channel.id);
});
}
}
@@ -1,260 +0,0 @@
import { concat, EMPTY, from, Observable } from "rxjs";
import { filter, mergeMap, toArray } from "rxjs/operators";
import Repository from "../../../../../core/platform/services/database/services/orm/repository/repository";
import {
Channel,
ChannelMember,
DefaultChannel,
DefaultChannelPrimaryKey,
} from "../../../entities";
import {
CreateResult,
CrudException,
DeleteResult,
ExecutionContext,
ListResult,
Paginable,
SaveResult,
UpdateResult,
} from "../../../../../core/platform/framework/api/crud-service";
import { ChannelExecutionContext } from "../../../types";
import DefaultChannelListener from "./listener";
import { getLogger } from "../../../../../core/platform/framework";
import { User } from "../../../../../utils/types";
import WorkspaceUser from "../../../../workspaces/entities/workspace_user";
import gr from "../../../../global-resolver";
const logger = getLogger("channel.default");
export default class DefaultChannelServiceImpl {
version: "1";
repository: Repository<DefaultChannel>;
listener: DefaultChannelListener;
async init(): Promise<this> {
this.repository = await gr.database.getRepository("default_channels", DefaultChannel);
this.listener = new DefaultChannelListener(this);
await this.listener.init();
return this;
}
async create(
channel: DefaultChannel,
context: ExecutionContext,
): Promise<CreateResult<DefaultChannel>> {
await this.repository.save(channel, context);
// Once a default channel has been successfully created, we have to add all the workspace users as member of the channel
// There are several ways to do it: Directly or using pubsub
// Since member service is a channel sub service we can do it directly
// Another good way to be sure that the user is in default channels is to have an endpoint which ensures this...
this.onCreated(channel);
return new CreateResult<DefaultChannel>("default_channel", channel);
}
get(pk: DefaultChannelPrimaryKey, context: ExecutionContext): Promise<DefaultChannel> {
return this.repository.findOne(pk, {}, context);
}
update(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
pk: DefaultChannelPrimaryKey,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
item: DefaultChannel,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
context?: ChannelExecutionContext,
): Promise<UpdateResult<DefaultChannel>> {
throw new Error("Method not implemented.");
}
save?<SaveOptions>(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
item: DefaultChannel,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
options?: SaveOptions,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
context?: ChannelExecutionContext,
): Promise<SaveResult<DefaultChannel>> {
throw new Error("Method not implemented.");
}
async delete(
pk: DefaultChannelPrimaryKey,
context?: ExecutionContext,
): Promise<DeleteResult<DefaultChannel>> {
const defaultChannel = await this.get(pk, context);
if (!defaultChannel) {
throw CrudException.notFound("Default channel has not been found");
}
await this.repository.remove(defaultChannel, context);
return new DeleteResult("default_channel", defaultChannel, true);
}
list<ListOptions>(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
pagination: Paginable,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
options?: ListOptions,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
context?: ChannelExecutionContext,
): Promise<ListResult<DefaultChannel>> {
throw new Error("Method not implemented.");
}
/**
* @param channel
*/
onCreated(channel: DefaultChannel): void {
logger.debug("Default channel %s: Adding workspace users as members", channel.channel_id);
const members: ChannelMember[] = [];
const subscription = this.addWorkspaceUsersToChannel(channel).subscribe({
next: member => {
logger.debug(
"Default Channel %s: User %s has been added: %s",
channel.channel_id,
member.user.userId,
member.added,
);
member.added && members.push(member.member);
},
error: (err: Error) => {
logger.error({ err }, "Default Channel %s: Error while adding users", channel.channel_id);
},
complete: () => {
logger.debug(
"Default Channel %s: Workspace users have been added: %o",
channel.channel_id,
members.map(m => m.user_id),
);
subscription.unsubscribe();
},
});
}
addWorkspaceUsersToChannel(
channel: DefaultChannelPrimaryKey,
): Observable<{ user?: WorkspaceUser; member?: ChannelMember; added: boolean; err?: Error }> {
const workspaceUsers$ = gr.services.workspaces.getAllUsers$({
workspaceId: channel.workspace_id,
});
return workspaceUsers$.pipe(
// filter out external workspace users
filter(wsUser => !wsUser.isExternal),
mergeMap(wsUser =>
from(
gr.services.channels.members
.addUserToChannels({ id: wsUser.userId }, [
{
company_id: channel.company_id,
workspace_id: channel.workspace_id,
id: channel.channel_id,
} as Channel,
])
.then(result => ({
user: wsUser,
member: result.getEntities()[0].member,
added: result.getEntities()[0].added,
err: result.getEntities()[0].err,
}))
.catch(err => {
return { user: wsUser, added: false, err };
}),
),
),
);
}
async addUserToDefaultChannels(
user: User,
workspace: Required<Pick<DefaultChannelPrimaryKey, "company_id" | "workspace_id">>,
): Promise<Array<{ channel: Channel; member?: ChannelMember; err?: Error; added: boolean }>> {
if (!user || !workspace) {
throw new Error("Can not add user to default channel (user or workspaces are empty)");
}
try {
const companyUser = await gr.services.companies.getCompanyUser(
{ id: workspace.company_id },
user,
);
if (!companyUser) {
return [];
}
// Do not add guest in default channels
if (companyUser.role === "guest") {
logger.debug(
"CompanyUser role is %s, not added in workspace %s",
companyUser.role,
workspace.workspace_id,
);
return [];
}
const channels = await this.getDefaultChannels({
company_id: workspace.company_id,
workspace_id: workspace.workspace_id,
});
if (!channels || !channels.length) {
logger.debug("No default channels in workspace %s", workspace.workspace_id);
return [];
}
logger.info("Adding user %s to channels %s", user, JSON.stringify(channels));
const regChannels = channels.map(c => ({ id: c.channel_id, ...c } as any as Channel));
return (
await gr.services.channels.members.addUserToChannels(user, regChannels)
).getEntities();
} catch (err) {
logger.error({ err }, "Error while adding user for default channels");
throw new Error("Error while adding user for default channels");
}
}
getDefaultChannels(
workspace: Pick<DefaultChannelPrimaryKey, "company_id" | "workspace_id">,
pagination?: Paginable,
context?: ExecutionContext,
): Promise<DefaultChannel[]> {
return this.getDefaultChannels$(workspace, pagination, context).pipe(toArray()).toPromise();
}
getDefaultChannels$(
workspace: Pick<DefaultChannelPrimaryKey, "company_id" | "workspace_id">,
pagination?: Paginable,
context?: ExecutionContext,
): Observable<DefaultChannel> {
const list = (
workspace: Pick<DefaultChannelPrimaryKey, "company_id" | "workspace_id">,
pagination: Paginable,
) => {
return this.repository.find(
workspace,
{
pagination: { limitStr: pagination?.limitStr, page_token: pagination?.page_token },
},
context,
);
};
return from(list(workspace, pagination)).pipe(
mergeMap(channels => {
const items$ = from(channels.getEntities());
const next$ = channels?.nextPage?.page_token
? this.getDefaultChannels$(workspace, channels.nextPage)
: EMPTY;
return concat(items$, next$);
}),
);
}
}
@@ -1,180 +0,0 @@
import Repository from "../../../../../core/platform/services/database/services/orm/repository/repository";
import {
CreateResult,
CrudException,
DeleteResult,
ExecutionContext,
ListResult,
Pagination,
SaveResult,
UpdateResult,
} from "../../../../../core/platform/framework/api/crud-service";
import { ChannelExecutionContext } from "../../../types";
import { getLogger } from "../../../../../core/platform/framework";
import {
Channel,
ChannelPendingEmails,
ChannelPendingEmailsPrimaryKey,
ChannelPrimaryKey,
getChannelPendingEmailsInstance,
} from "../../../entities";
import { ChannelPendingEmailsListQueryParameters } from "../../../web/types";
import { plainToClass } from "class-transformer";
import { NewUserInWorkspaceNotification } from "../types";
import gr from "../../../../global-resolver";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const logger = getLogger("channel.pending_emails");
export default class ChannelPendingEmailServiceImpl {
version: "1";
repository: Repository<ChannelPendingEmails>;
async init(): Promise<this> {
this.repository = await gr.database.getRepository(
"channel_pending_emails",
ChannelPendingEmails,
);
return this;
}
async create(
pendingEmail: ChannelPendingEmails,
context: ExecutionContext,
): Promise<CreateResult<ChannelPendingEmails>> {
await this.repository.save(pendingEmail, context);
// Once a channel pending email has been successfully created, we have to add him to all channels of the company that he was invited
//this.onCreated(pendingEmail);
return new CreateResult<ChannelPendingEmails>("channel_pending_emails", pendingEmail);
}
get(
pk: ChannelPendingEmailsPrimaryKey,
context: ExecutionContext,
): Promise<ChannelPendingEmails> {
return this.repository.findOne(pk, {}, context);
}
update(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
pk: ChannelPendingEmailsPrimaryKey,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
item: ChannelPendingEmails,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
context?: ChannelExecutionContext,
): Promise<UpdateResult<ChannelPendingEmails>> {
throw new Error("Method not implemented.");
}
save?<SaveOptions>(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
item: ChannelPendingEmails,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
options?: SaveOptions,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
context?: ChannelExecutionContext,
): Promise<SaveResult<ChannelPendingEmails>> {
throw new Error("Method not implemented.");
}
async delete(
pk: ChannelPendingEmailsPrimaryKey,
context?: ExecutionContext,
): Promise<DeleteResult<ChannelPendingEmails>> {
const pendingEmail = await this.get(pk, context);
if (!pendingEmail) {
throw CrudException.notFound("Channel pendingEmail has not been found");
}
await this.repository.remove(pendingEmail, context);
return new DeleteResult("channel_pending_emails", pendingEmail, true);
}
async list<ListOptions>(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
pagination: Pagination,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
options?: ListOptions,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
context?: ChannelExecutionContext,
): Promise<ListResult<ChannelPendingEmails>> {
const pk = {
company_id: context.channel.company_id,
workspace_id: context.channel.workspace_id,
channel_id: context.channel.id,
};
const result = await this.repository.find(pk, { pagination }, context);
return new ListResult<ChannelPendingEmails>(
"channel_pending_emails",
result
.getEntities()
.map(pendingMember =>
plainToClass(ChannelPendingEmails, { email: pendingMember.email, ...pendingMember }),
),
result.nextPage,
);
}
findPendingEmails(
pk: ChannelPendingEmailsListQueryParameters,
context: ExecutionContext,
): Promise<ListResult<ChannelPendingEmails>> {
return this.repository.find(pk, {}, context);
}
async proccessPendingEmails(
user: NewUserInWorkspaceNotification,
workspace: Required<Pick<ChannelPrimaryKey, "company_id" | "workspace_id">>,
context: ExecutionContext,
): Promise<void> {
// Get user object
const userObj = await gr.services.users.get({
id: user.user_id,
});
// All pending emails in workspace
const allPendingEmailsInWorkspace = await this.repository.find(workspace, {}, context);
// Filter pending emails in workspace with user object email
allPendingEmailsInWorkspace.filterEntities(({ email }) => email === userObj.email_canonical);
// Add user to all channel that he is invited then delete pending email entity
allPendingEmailsInWorkspace
.getEntities()
.forEach(async ({ workspace_id, channel_id, company_id, email }) => {
// Add user to channel
const list = await gr.services.channels.members.addUserToChannels(userObj, [
{
workspace_id,
company_id,
id: channel_id,
} as Channel,
]);
// If added to channel, delete pending email
const isAddedToChannel = list.getEntities()[0].added;
if (isAddedToChannel) {
await this.delete(
getChannelPendingEmailsInstance({
workspace_id,
company_id,
email,
channel_id,
}),
);
}
});
}
onCreated(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
pendingEmail: ChannelPendingEmails,
): void {
throw new Error("Method not implemented.");
}
}
@@ -1,52 +0,0 @@
import { User, WebsocketMetadata, Workspace } from "../../../../utils/types";
import { Channel } from "../../entities";
import { WorkspaceExecutionContext } from "../../types";
import { isDirectChannel } from "../../utils";
export function getWebsocketInformation(channel: Channel): WebsocketMetadata {
return {
name: channel.name,
room: getChannelPath(channel),
};
}
export function getWorkspaceRooms(workspace: Workspace, user: User): WebsocketMetadata[] {
return isDirectChannel(workspace)
? [{ room: getDirectChannelRoomName(workspace, user.id) }]
: [{ room: getPublicRoomName(workspace) }, { room: getPrivateRoomName(workspace, user) }];
}
export function getDirectChannelRoomName(workspace: Workspace, userId: string): string {
return `/companies/${workspace.company_id}/workspaces/direct/channels?type=direct&user=${userId}`;
}
export function getDirectChannelRooms(workspace: Workspace, channel: Channel): string[] {
return (channel.members || []).map(member => getDirectChannelRoomName(workspace, member));
}
export function getPrivateRoomName(workspace: Workspace, user: User): string {
return `/companies/${workspace.company_id}/workspaces/${workspace.workspace_id}/channels?type=private&user=${user.id}`;
}
export function getPublicRoomName(workspace: Workspace): string {
return `/companies/${workspace?.company_id}/workspaces/${workspace?.workspace_id}/channels?type=public`;
}
export function getRoomName(channel: Channel, context?: WorkspaceExecutionContext): string[] {
return isDirectChannel(channel)
? getDirectChannelRooms(context.workspace, channel)
: [getPublicRoomName(context.workspace)];
}
export function getChannelPath(
channel: Pick<Channel, "id">,
context?: WorkspaceExecutionContext,
): string {
return `${getChannelsPath(context?.workspace)}/${channel.id}`;
}
export function getChannelsPath(workspace?: Workspace): string {
return workspace
? `/companies/${workspace.company_id}/workspaces/${workspace.workspace_id}/channels`
: "/channels";
}
@@ -1,987 +0,0 @@
import _, { cloneDeep, find } from "lodash";
import { diff } from "deep-object-diff";
import {
getLogger,
RealtimeDeleted,
RealtimeSaved,
RealtimeUpdated,
} from "../../../../core/platform/framework";
import {
CrudException,
DeleteResult,
ExecutionContext,
ListResult,
OperationType,
Pagination,
SaveResult,
UpdateResult,
} from "../../../../core/platform/framework/api/crud-service";
import { ChannelActivityMessage, ChannelObject, SearchChannelOptions } from "./types";
import {
Channel,
ChannelMember,
ChannelPrimaryKey,
DefaultChannel,
UserChannel,
UsersIncludedChannel,
} from "../../entities";
import { getChannelPath, getRoomName } from "./realtime";
import { ChannelType, ChannelVisibility, WorkspaceExecutionContext } from "../../types";
import { isWorkspaceAdmin as userIsWorkspaceAdmin } from "../../../../utils/workspace";
import { ResourceEventsPayload, User } from "../../../../utils/types";
import { pick } from "../../../../utils/pick";
import {
DirectChannel,
getInstance as getDirectChannelInstance,
} from "../../entities/direct-channel";
import { ChannelListOptions, ChannelSaveOptions } from "../../web/types";
import { isDirectChannel } from "../../utils";
import { ResourcePath } from "../../../../core/platform/services/realtime/types";
import Repository, {
FindFilter,
} from "../../../../core/platform/services/database/services/orm/repository/repository";
import { ChannelActivity } from "../../entities/channel-activity";
import { localEventBus } from "../../../../core/platform/framework/event-bus";
import DefaultChannelServiceImpl from "./default/service";
import { formatUser } from "../../../../utils/users";
import gr from "../../../global-resolver";
import {
KnowledgeGraphEvents,
KnowledgeGraphGenericEventPayload,
} from "../../../../core/platform/services/knowledge-graph/types";
import { ChannelUserCounterType } from "../../entities/channel-counters";
import { Readable } from "stream";
import sharp from "sharp";
const logger = getLogger("channel.service");
export class ChannelServiceImpl {
version: "1";
activityRepository: Repository<ChannelActivity>;
channelRepository: Repository<Channel>;
directChannelRepository: Repository<DirectChannel>;
defaultChannelService: DefaultChannelServiceImpl;
async init(): Promise<this> {
this.defaultChannelService = new DefaultChannelServiceImpl();
try {
this.activityRepository = await gr.database.getRepository(
"channel_activity",
ChannelActivity,
);
this.channelRepository = await gr.database.getRepository("channels", Channel);
this.directChannelRepository = await gr.database.getRepository(
"direct_channels",
DirectChannel,
);
} catch (err) {
logger.error({ err }, "Can not initialize channel db service");
}
try {
await this.defaultChannelService.init();
} catch (err) {
logger.warn("Can not initialize default channel service", err);
}
localEventBus.subscribe<ResourceEventsPayload>("workspace:user:deleted", async data => {
if (data?.user?.id && data?.company?.id)
gr.services.channels.members.ensureUserNotInWorkspaceIsNotInChannel(
data.user,
data.workspace,
undefined,
);
});
return this;
}
async thumbnail(channelId: string, context?: ExecutionContext): Promise<{ file: Readable }> {
const logoInternalPath = `/channels/${channelId}/thumbnail.jpg`;
const file = await gr.platformServices.storage.read(logoInternalPath, {}, context);
return { file };
}
@RealtimeSaved<Channel>((channel, context) => [
{
room: ResourcePath.get(getRoomName(channel, context as WorkspaceExecutionContext)),
path: getChannelPath(channel, context as WorkspaceExecutionContext),
},
])
async save(
channel: Channel,
options: ChannelSaveOptions,
context: WorkspaceExecutionContext,
): Promise<SaveResult<ChannelObject>> {
let channelToUpdate: Channel;
let channelToSave: Channel;
const mode = channel.id ? OperationType.UPDATE : OperationType.CREATE;
const isDirectChannel = Channel.isDirectChannel(channel);
const isWorkspaceAdmin =
!isDirectChannel && (await userIsWorkspaceAdmin(context.user, context.workspace));
const isPrivateChannel = Channel.isPrivateChannel(channel);
const isDefaultChannel = Channel.isDefaultChannel(channel);
channel.workspace_id = context.workspace.workspace_id || channel.workspace_id;
channel.company_id = context.workspace.company_id || channel.company_id;
if (isDirectChannel) {
channel.visibility = ChannelVisibility.DIRECT;
channel.workspace_id = ChannelVisibility.DIRECT;
}
if (mode === OperationType.UPDATE) {
logger.debug("Updating channel");
channelToUpdate = await this.get(this.getPrimaryKey(channel), context);
if (!channelToUpdate) {
throw CrudException.notFound("Channel not found");
}
const isChannelOwner = this.isChannelOwner(channelToUpdate, context.user);
const updatableParameters: Partial<Record<keyof Channel, boolean>> = {
name: true,
description: !isDirectChannel,
icon: !isDirectChannel,
channel_group: !isDirectChannel,
is_default: (isWorkspaceAdmin || isChannelOwner) && !isDirectChannel && !isPrivateChannel,
visibility: !isDirectChannel && (isWorkspaceAdmin || isChannelOwner),
archived: isWorkspaceAdmin || isChannelOwner,
connectors: !isDirectChannel,
is_readonly: isWorkspaceAdmin || isChannelOwner,
};
// Diff existing channel and input one, cleanup all the undefined fields for all objects
const channelDiff = diff(channelToUpdate, channel);
const fields = Object.keys(channelDiff) as Array<Partial<keyof Channel>>;
if (!fields.length) {
throw CrudException.badRequest("Nothing to update");
}
const updatableFields = fields.filter(field => updatableParameters[field]);
if (!updatableFields.length) {
throw CrudException.badRequest("Current user can not update requested fields");
}
channelToSave = cloneDeep(channelToUpdate);
updatableFields.forEach(field => {
if (channel[field] !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(channelToSave as any)[field] = channel[field];
}
});
localEventBus.publish<ResourceEventsPayload>("channel:updated", {
actor: context.user,
resourcesBefore: [channelToUpdate],
resourcesAfter: [channel],
channel: channel,
user: context.user,
});
}
if (mode === OperationType.CREATE) {
if (isPrivateChannel && isDefaultChannel) {
throw CrudException.badRequest("Private channel can not be default");
}
if (isDirectChannel) {
options.members = Array.from(new Set<string>(options?.members || []).add(context.user.id));
channel.members = options.members;
logger.info("Direct channel creation with members %o", options.members);
if (context.workspace.workspace_id !== ChannelVisibility.DIRECT) {
throw CrudException.badRequest("Direct Channel creation error: bad workspace");
}
const directChannel = await this.getDirectChannelInCompany(
context.workspace.company_id,
options.members,
context,
);
if (directChannel) {
logger.debug("Direct channel already exists %o", directChannel);
const existingChannel = await this.channelRepository.findOne(
{
company_id: context.workspace.company_id,
workspace_id: ChannelType.DIRECT,
id: directChannel.channel_id,
},
{},
context,
);
if (existingChannel) {
const last_activity = await this.getChannelActivity(existingChannel, context);
const saveResult = new SaveResult(
"channels",
ChannelObject.mapTo(existingChannel, { last_activity }),
OperationType.EXISTS,
);
await this.addContextUserToChannel(context, saveResult);
return saveResult;
} else {
//Fixme: remove directChannel instance
throw CrudException.badRequest("table inconsistency");
}
}
} else {
if (!channel.name) {
throw CrudException.badRequest("'name' is required");
}
}
channelToSave = channel;
channelToSave.owner = context.user.id;
}
if (channelToSave.icon && channelToSave.icon.startsWith("data:")) {
const logoInternalPath = `/channels/${channelToSave.id}/thumbnail.jpg`;
const logoPublicPath = `/internal/services/channels/v1/companies/${
channelToSave.company_id
}/workspaces/${channelToSave.workspace_id}/channels/${
channelToSave.id
}/thumbnail?t=${new Date().getTime()}`;
const image = await sharp(Buffer.from(channel.icon.split(",").pop(), "base64"))
.resize(250, 250)
.toBuffer();
const s = new Readable();
s.push(image);
s.push(null);
await gr.platformServices.storage.write(logoInternalPath, s);
channelToSave.icon = logoPublicPath;
}
const channelActivity = await this.activityRepository.findOne(
{
channel_id: channel.id,
},
{},
context,
);
logger.info("Saving channel %o", channelToSave);
await this.channelRepository.save(channelToSave, context);
if (!isDirectChannel) {
channel.members = []; //Members is specific to direct channels
}
const saveResult = new SaveResult<ChannelObject>(
"channel",
{
...ChannelObject.mapTo(channelToSave),
last_activity: channelActivity?.last_activity || 0,
last_message: channelActivity?.last_message || {},
},
mode,
);
await this.addContextUserToChannel(context, saveResult);
await this.onSaved(channelToSave, options, context, saveResult, mode);
// Shortcut to invite members to a channel
if (!isDirectChannel && options.members && options.members.length > 0) {
await gr.services.channels.members.addUsersToChannel(
options.members.map(id => {
return { id };
}),
saveResult.entity,
context,
);
}
return saveResult;
}
async get(pk: ChannelPrimaryKey, _context?: ExecutionContext): Promise<ChannelObject> {
const primaryKey = this.getPrimaryKey(pk);
let channel = await this.channelRepository.findOne(primaryKey);
if (!channel) {
channel = await this.channelRepository.findOne({ ...primaryKey, workspace_id: "direct" });
}
if (!channel) return null;
const last_activity = await this.getChannelActivity(channel);
if (channel.visibility === ChannelVisibility.DIRECT)
channel = await this.includeUsersInDirectChannel(channel);
return ChannelObject.mapTo(channel, { last_activity });
}
@RealtimeUpdated<Channel>((channel, context) => [
{
room: ResourcePath.get(getRoomName(channel, context as WorkspaceExecutionContext)),
path: getChannelPath(channel, context as WorkspaceExecutionContext),
},
])
async update(
pk: ChannelPrimaryKey,
channel: Channel,
context: ExecutionContext,
): Promise<UpdateResult<ChannelObject>> {
// TODO: Do the update by hand then save
if (!pk.id) {
throw CrudException.badRequest("Channel id is required for update");
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mergeChannel: any = { ...channel, ...pk };
await this.channelRepository.save(mergeChannel as Channel, context);
return new UpdateResult<ChannelObject>("channel", ChannelObject.mapTo(mergeChannel));
}
@RealtimeDeleted<Channel>((channel, context) => [
{
room: ResourcePath.get(getRoomName(channel, context as WorkspaceExecutionContext)),
path: getChannelPath(channel, context as WorkspaceExecutionContext),
},
])
async delete(
pk: ChannelPrimaryKey,
context: WorkspaceExecutionContext,
): Promise<DeleteResult<ChannelObject>> {
const channelToDelete = await this.channelRepository.findOne(
this.getPrimaryKey(pk),
{},
context,
);
if (!channelToDelete) {
throw new CrudException("Channel not found", 404);
}
const directChannel = isDirectChannel(channelToDelete);
if (directChannel) {
throw new CrudException("Direct channel can not be deleted", 400);
}
const isWorkspaceAdmin =
!directChannel && (await userIsWorkspaceAdmin(context.user, context.workspace));
const isChannelOwner = this.isChannelOwner(channelToDelete, context.user);
if (!isWorkspaceAdmin && !isChannelOwner) {
throw new CrudException("Channel can not be deleted", 400);
}
await this.channelRepository.remove(channelToDelete, context);
const result = new DeleteResult("channel", ChannelObject.mapTo(pk as Channel), true);
this.onDeleted(channelToDelete, result);
localEventBus.publish<ResourceEventsPayload>("channel:deleted", {
channel: channelToDelete,
user: context.user,
});
return result;
}
@RealtimeUpdated<ChannelActivity>((channelActivity, context) => {
return [
{
room: ResourcePath.get(
getRoomName(channelActivity.getChannelPrimaryKey(), context as WorkspaceExecutionContext),
),
path: getChannelPath(
channelActivity.getChannelPrimaryKey(),
context as WorkspaceExecutionContext,
),
resource: {
company_id: channelActivity.company_id,
workspace_id: channelActivity.workspace_id,
id: channelActivity.channel_id,
last_activity: channelActivity.last_activity,
last_message: channelActivity.last_message,
},
},
];
})
async updateLastActivity(
payload: {
date: number;
channel: ChannelPrimaryKey;
message: ChannelActivityMessage | null;
},
context: WorkspaceExecutionContext,
): Promise<UpdateResult<ChannelActivity>> {
const channelPK = payload.channel;
const channelActivityMessage = payload.message;
const channel = (await this.channelRepository.findOne(
_.pick(channelPK, "company_id", "workspace_id", "id"),
{},
context,
)) as ChannelObject & { stats: ChannelObject["stats"] };
await gr.services.channels.channels.completeWithStatistics([channel]);
const entity = new ChannelActivity();
entity.channel_id = channelPK.id;
entity.company_id = channelPK.company_id;
entity.workspace_id = channelPK.workspace_id;
entity.last_activity = payload.date;
entity.stats = channel.stats;
entity.last_message = channelActivityMessage
? {
date: channelActivityMessage.date,
sender: channelActivityMessage.sender,
sender_name: channelActivityMessage.sender_name,
title: channelActivityMessage.title,
text: channelActivityMessage.text,
}
: null;
entity.channel = channel;
logger.info(`Update activity for channel ${entity.channel_id} to ${entity.last_activity}`);
localEventBus.publish<KnowledgeGraphGenericEventPayload<Channel>>(
KnowledgeGraphEvents.CHANNEL_UPSERT,
{
id: channel.id,
resource: channel,
links: [],
},
);
await this.activityRepository.save(entity, context);
return new UpdateResult<ChannelActivity>("channel_activity", entity);
}
public async getChannelActivity(channel: Channel, context?: ExecutionContext): Promise<number> {
let result = 0;
if (!channel) {
return result;
}
try {
const activity = await this.activityRepository.findOne(
{
company_id: channel.company_id,
workspace_id: channel.workspace_id,
channel_id: channel.id,
} as ChannelActivity,
{},
context,
);
result = activity?.last_activity || 0;
} catch (error) {
logger.debug(`Can not get channel last activity for channel ${channel.id}`);
}
return result;
}
public async fillChannelActivities(
channels: Channel[],
context: ExecutionContext,
): Promise<ChannelObject[]> {
const filledChannels: ChannelObject[] = [];
for (const channel of channels) {
const activity = await this.getChannelActivity(channel, context);
const chObj = ChannelObject.mapTo(channel);
chObj.last_activity = activity;
filledChannels.push(chObj);
}
return filledChannels;
}
async list(
pagination: Pagination,
options: ChannelListOptions,
context: WorkspaceExecutionContext,
): Promise<ListResult<Channel | UserChannel>> {
const isDirectWorkspace = isDirectChannel(context.workspace);
const isWorkspaceAdmin =
!isDirectWorkspace && userIsWorkspaceAdmin(context.user, context.workspace);
const findFilters: FindFilter = {
company_id: context.workspace.company_id,
workspace_id: context.workspace.workspace_id,
};
let user_id = context.user.id;
if (context.user.application_id) {
user_id = user_id || options.user_id;
}
if ((options?.mine || isDirectWorkspace) && user_id) {
if (!context.user.application_id && !context.user.server_request) {
localEventBus.publish<ResourceEventsPayload>("channel:list", {
user: context.user,
company: {
id: context.workspace.company_id,
},
});
}
return this.getChannelsForUsersInWorkspace(
context.workspace.company_id,
context.workspace.workspace_id,
context.user.id,
pagination,
context,
);
}
const channels = await this.channelRepository.find(findFilters, { pagination }, context);
channels.filterEntities(channel => channel.visibility !== ChannelVisibility.DIRECT);
if (!isWorkspaceAdmin && !context.user.server_request) {
channels.filterEntities(channel => channel.visibility === ChannelVisibility.PUBLIC);
}
return channels;
}
async getChannelsForUsersInWorkspace(
companyId: string,
workspaceId: string | "direct",
userId: string,
pagination?: Pagination,
context?: ExecutionContext,
): Promise<ListResult<UserChannel>> {
const isDirectWorkspace = isDirectChannel({ workspace_id: workspaceId });
const findFilters: FindFilter = {
company_id: companyId,
workspace_id: workspaceId,
};
const userChannels = await gr.services.channels.members.listUserChannels(
{ id: userId },
pagination,
{
user: {
id: userId,
},
workspace: {
workspace_id: workspaceId,
company_id: companyId,
},
},
);
let activityPerChannel: Map<string, ChannelActivity>;
const channels = await this.channelRepository.find(
findFilters,
{
$in: [["id", userChannels.getEntities().map(channelMember => channelMember.channel_id)]],
},
context,
);
if (!channels.isEmpty()) {
const activities = await this.activityRepository.find(
findFilters,
{
$in: [["channel_id", channels.getEntities().map(channel => channel.id)]],
},
context,
);
activityPerChannel = new Map<string, ChannelActivity>(
activities.getEntities().map(activity => [activity.channel_id, activity]),
);
} else {
activityPerChannel = new Map<string, ChannelActivity>();
}
channels.mapEntities(<UserChannel>(channel: Channel) => {
const userChannel = find(userChannels.getEntities(), { channel_id: channel.id });
const channelActivity = activityPerChannel.get(channel.id);
return {
...channel,
...{ user_member: userChannel },
last_activity: channelActivity?.last_activity || 0,
last_message: channelActivity?.last_message || {},
} as unknown as UserChannel;
});
if (isDirectWorkspace) {
channels.mapEntities(<UserDirectChannel>(channel: UserChannel) => {
return {
...channel,
...{
members: channel.members || [],
},
} as unknown as UserDirectChannel;
});
}
return new ListResult(
channels.type,
channels.getEntities() as UserChannel[],
userChannels.nextPage,
);
}
async createDirectChannel(
directChannel: DirectChannel,
context: ExecutionContext,
): Promise<DirectChannel> {
const instance = getDirectChannelInstance(directChannel);
await this.directChannelRepository.save(instance, context);
return instance;
}
getDirectChannel(
directChannel: DirectChannel,
context: ExecutionContext,
): Promise<DirectChannel> {
return this.directChannelRepository.findOne(directChannel, {}, context);
}
async getDirectChannelInCompany(
companyId: string,
users: string[],
context: ExecutionContext,
): Promise<DirectChannel> {
const directChannel = await this.directChannelRepository.findOne(
{
company_id: companyId,
users: DirectChannel.getUsersAsString(users),
},
{},
context,
);
return directChannel;
}
async getDirectChannelsInCompany(
pagination: Pagination,
company_id: string,
context: ExecutionContext,
): Promise<ListResult<Channel>> {
return await this.channelRepository.find(
{ company_id, workspace_id: "direct" },
{ pagination },
context,
);
}
async getDirectChannelsForUsersInCompany(
companyId: string,
userId: string,
context: ExecutionContext,
): Promise<DirectChannel[]> {
const list = await this.directChannelRepository.find(
{
company_id: companyId,
},
{
$like: [["users", userId]],
},
context,
);
return list.getEntities();
}
async markAsRead(
pk: ChannelPrimaryKey,
user: Pick<User, "id">,
context: ExecutionContext,
): Promise<boolean> {
const now = Date.now();
const channel = await this.get(pk, context);
if (!channel) {
throw CrudException.notFound("Channel not found");
}
await this.completeWithStatistics([channel]);
const member = await gr.services.channels.members.getChannelMember(
user,
channel,
undefined,
context,
);
if (!member) {
throw CrudException.badRequest("User is not channel member");
}
// Updating the member will also publish a message in the message-queue channel
// This message will be handled in the notification service and will update the notification preferences for the member
// cf this.members.onUpdated
member.last_access = now;
member.last_increment = channel.stats.messages;
const updatedMember = (
await gr.services.channels.members.save(member, {
channel,
user,
})
).entity;
this.onRead(channel, updatedMember);
return true;
}
async markAsUnread(
pk: ChannelPrimaryKey,
user: User,
context: ExecutionContext,
): Promise<boolean> {
const channel = await this.get(pk, context);
if (!channel) {
throw CrudException.notFound("Channel not found");
}
const member = await gr.services.channels.members.getChannelMember(user, channel);
if (!member) {
throw CrudException.badRequest("User is not channel member");
}
// do nothing here but send a notification so that notification service is updated...
this.onUnread(channel, member);
return true;
}
getDefaultChannels(
workspace: Required<Pick<ChannelPrimaryKey, "company_id" | "workspace_id">>,
): Promise<DefaultChannel[]> {
return this.defaultChannelService.getDefaultChannels(workspace);
}
async addUserToDefaultChannels(
user: User,
workspace: Required<Pick<ChannelPrimaryKey, "company_id" | "workspace_id">>,
): Promise<ChannelMember[]> {
const result = await this.defaultChannelService.addUserToDefaultChannels(user, workspace);
return result.filter(e => e.added).map(e => e.member);
}
getPrimaryKey(channelOrPrimaryKey: Channel | ChannelPrimaryKey): ChannelPrimaryKey {
return pick(channelOrPrimaryKey, ...(["company_id", "workspace_id", "id"] as const));
}
isChannelOwner(channel: Channel, user: User): boolean {
return channel.owner && String(channel.owner) === user.id;
}
async addContextUserToChannel(
context: WorkspaceExecutionContext,
result: SaveResult<Channel>,
): Promise<void> {
const savedChannel = result.entity;
//Add requester as member
if (context.user.id) {
try {
await gr.services.channels.members.addUserToChannels({ id: context.user.id }, [
savedChannel,
]);
} catch (err) {
logger.warn({ err }, "Can not add requester as channel member");
}
}
}
public async includeUsersInDirectChannel(
channel: Channel,
excludeUserId?: string,
): Promise<UsersIncludedChannel> {
const channelWithUsers: UsersIncludedChannel = { users: [], ...channel };
if (isDirectChannel(channel)) {
const users = [];
for (const user of channel.members) {
if (user) {
const e = await formatUser(await gr.services.users.getCached({ id: user }));
if (e) users.push(e);
}
}
channelWithUsers.users = users;
channelWithUsers.name = users
.filter(u => u.id != excludeUserId)
.map(u => u.full_name?.trim())
.filter(a => a)
.join(", ");
}
return channelWithUsers;
}
/**
* Called when channel update has been successfully called
*
* @param channel The channel before update has been processed
* @param result The channel update result
*/
async onSaved(
channel: Channel,
options: ChannelSaveOptions,
context: WorkspaceExecutionContext,
result: SaveResult<Channel>,
mode: OperationType.CREATE | OperationType.UPDATE,
): Promise<void> {
const savedChannel = result.entity;
if (mode === OperationType.CREATE) {
if (isDirectChannel(channel)) {
const directChannel = {
channel_id: savedChannel.id,
company_id: savedChannel.company_id,
users: DirectChannel.getUsersAsString(options.members),
} as DirectChannel;
await this.createDirectChannel(directChannel, context);
} else {
if (options.addCreatorAsMember && savedChannel.owner) {
try {
await gr.services.channels.members.addUserToChannels({ id: savedChannel.owner }, [
savedChannel,
]);
} catch (err) {
logger.warn({ err }, "Can not add owner as channel member");
}
}
}
this.updateLastActivity(
{
date: new Date().getTime(),
channel: channel,
message: null,
},
context,
);
localEventBus.publish<ResourceEventsPayload>("channel:created", {
channel,
user: context.user,
});
}
}
/**
* Called when channel delete has been successfully called
*
* @param channel The channel to delete
* @param result The delete result
*/
onDeleted(channel: Channel, result: DeleteResult<Channel>): void {
if (result.deleted) {
logger.debug("Channel %s has been deleted", channel.id);
}
}
/**
* Called when a channel as been marked as read.
* Will publish `channel:read` notification in the message-queue service
*
* @param channel
* @param member
*/
onRead(channel: Channel, member: ChannelMember): void {
logger.info(`Channel ${channel.id} as been marked as read for user ${member.id}`);
gr.platformServices.messageQueue.publish("channel:read", {
data: {
channel,
member,
},
});
}
/**
* Called when a channel as been marked as unread.
* Will publish `channel:unread` notification in the message-queue service
*
* @param channel
* @param member
*/
onUnread(channel: Channel, member: ChannelMember): void {
logger.info(`Channel ${channel.id} as been marked as unread for user ${member.id}`);
gr.platformServices.messageQueue.publish("channel:unread", {
data: {
channel,
member,
},
});
}
async search(
pagination: Pagination,
options: SearchChannelOptions,
context: ExecutionContext,
): Promise<ListResult<Channel>> {
const rep = gr.platformServices.search.getRepository<Channel>("channel", Channel);
return rep.search(
{},
{
pagination,
...(options.companyId ? { $in: [["company_id", [options.companyId]]] } : {}),
$text: {
$search: options.search,
},
},
context,
);
}
async getAllChannelsInWorkspace(
company_id: string,
workspace_id: string,
context?: ExecutionContext,
): Promise<Channel[]> {
let pagination = new Pagination(null, "100");
const channels: Channel[] = [];
do {
const res = await this.channelRepository.find(
{
company_id: company_id,
workspace_id: workspace_id,
},
{
pagination,
},
context,
);
pagination = new Pagination(res.nextPage.page_token, res.nextPage.limitStr);
channels.push(...res.getEntities());
} while (pagination.page_token);
return channels;
}
async completeWithStatistics(
channels: Pick<ChannelObject, "id" | "company_id" | "workspace_id" | "stats">[],
) {
await Promise.all(
channels.map(async a => {
const members = await gr.services.channels.members.getUsersCount({
..._.pick(a, "id", "company_id", "workspace_id"),
counter_type: ChannelUserCounterType.MEMBERS,
});
//Fixme: even if it works strange to use "getUsersCount" to get messages count
const messages = await gr.services.channels.members.getUsersCount({
..._.pick(a, "id", "company_id", "workspace_id"),
counter_type: ChannelUserCounterType.MESSAGES,
});
a.stats = { members, messages };
}),
);
}
}
@@ -1,86 +0,0 @@
import { merge } from "lodash";
import { Channel } from "../../entities/channel";
import { ChannelActivity } from "../../entities/channel-activity";
import { ChannelMember } from "../../entities";
import { UserObject } from "../../../user/web/types";
export type NewUserInWorkspaceNotification = {
user_id: string;
company_id: string;
workspace_id: string;
};
export type ChannelStats = {
members: number;
messages: number;
};
type ChannelType = "workspace" | "direct";
export class ChannelMemberObject extends ChannelMember {
id: string;
user_id: string;
// type: "member" | "guest" | "bot";
last_access: number; //Timestamp in seconds
last_increment: number;
favorite: boolean; //Did the user add this channel to its favorites
// notification_level: "all" | "none" | "group_mentions" | "user_mentions";
static mapTo(
channelMember: ChannelMember,
channelMemberLikeObject: Partial<ChannelMemberObject> = {},
): ChannelMemberObject {
if (!channelMember) {
return null;
}
return merge(new ChannelMemberObject(), {
favorite: false,
last_increment: 0,
last_access: 0,
...channelMember,
...channelMemberLikeObject,
});
}
}
export class ChannelObject extends Channel {
last_activity?: ChannelActivity["last_activity"];
last_message?: ChannelActivity["last_message"];
default: boolean;
type: ChannelType;
user_member: ChannelMemberObject;
users: UserObject[];
stats: ChannelStats;
constructor() {
super();
}
static mapTo(channel: Channel, channelLikeObject: Partial<ChannelObject> = {}): ChannelObject {
if (!channel) {
return null;
}
return merge(new ChannelObject(), {
...{ last_activity: 0 },
...channel,
...channelLikeObject,
...{ members: channel?.members?.length ? channel.members : [] },
default: channel.is_default,
});
}
}
export type SearchChannelOptions = {
search: string;
companyId?: string;
};
export type ChannelActivityMessage = {
date: number;
sender: string;
sender_name: string;
title: string;
text: string;
};
@@ -1,41 +0,0 @@
import { Channel, User, WebsocketMetadata } from "../../../../utils/types";
import { ChannelMember } from "../../entities";
import { getChannelPath, getChannelsPath } from "../channel/realtime";
import { ChannelExecutionContext } from "../../types";
export function getPrivateRoomName(channel: Channel, user: User): string {
return `/companies/${channel.company_id}/workspaces/${channel.workspace_id}/channels/${channel.id}?type=private&user=${user.id}`;
}
export function getPublicRoomName(channel: Channel): string {
return `/companies/${channel.company_id}/workspaces/${channel.workspace_id}/channels/${channel.id}?type=public`;
}
export function getRoomName(channelOrContext: Channel | ChannelExecutionContext): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const channel = (channelOrContext as any).channel
? (channelOrContext as ChannelExecutionContext).channel
: (channelOrContext as Channel);
return getPublicRoomName(channel);
}
export function getMembersPath(channel: Channel): string {
return `${getChannelsPath(channel)}/members`;
}
export function getMemberPath(member: ChannelMember, context?: ChannelExecutionContext): string {
const parentContext = {
workspace: {
company_id: context?.channel?.company_id,
workspace_id: context?.channel?.workspace_id,
},
user: context?.user,
};
return `${getChannelPath(context.channel, parentContext)}/members/${member.user_id}`;
}
export function getChannelRooms(channel: Channel, user: User): WebsocketMetadata[] {
return [{ room: getPublicRoomName(channel) }, { room: getPrivateRoomName(channel, user) }];
}
@@ -1,911 +0,0 @@
import { getLogger, RealtimeDeleted, RealtimeSaved } from "../../../../core/platform/framework";
import {
CreateResult,
CrudException,
DeleteResult,
ExecutionContext,
ListResult,
OperationType,
Pagination,
SaveResult,
UpdateResult,
} from "../../../../core/platform/framework/api/crud-service";
import {
Channel as ChannelEntity,
ChannelMember,
ChannelMemberPrimaryKey,
ChannelMemberReadCursors,
getChannelMemberInstance,
getMemberOfChannelInstance,
MemberOfChannel,
ReadSection,
} from "../../entities";
import {
ChannelExecutionContext,
ChannelMemberType,
ChannelVisibility,
WorkspaceExecutionContext,
} from "../../types";
import { Channel, ResourceEventsPayload, User } from "../../../../utils/types";
import { cloneDeep, isNil, omitBy } from "lodash";
import { updatedDiff } from "deep-object-diff";
import { pick } from "../../../../utils/pick";
import { getMemberPath, getRoomName } from "./realtime";
import { ChannelListOptions } from "../../web/types";
import { ResourcePath } from "../../../../core/platform/services/realtime/types";
import Repository from "../../../../core/platform/services/database/services/orm/repository/repository";
import { localEventBus } from "../../../../core/platform/framework/event-bus";
import { plainToClass } from "class-transformer";
import {
ChannelCounterEntity,
ChannelCounterPrimaryKey,
ChannelUserCounterType,
TYPE as ChannelCounterEntityType,
} from "../../entities/channel-counters";
import { CounterProvider } from "../../../../core/platform/services/counter/provider";
import { countRepositoryItems } from "../../../../utils/counters";
import NodeCache from "node-cache";
import { UserPrimaryKey } from "../../../user/entities/user";
import { WorkspacePrimaryKey } from "../../../workspaces/entities/workspace";
import gr from "../../../global-resolver";
import uuidTime from "uuid-time";
import { ChannelObject } from "../channel/types";
import { CompanyExecutionContext } from "../../../applications/web/types";
const USER_CHANNEL_KEYS = [
"id",
"company_id",
"workspace_id",
"user_id",
"channel_id",
"type",
"last_access",
"last_increment",
"favorite",
"notification_level",
"expiration",
] as const;
const CHANNEL_MEMBERS_KEYS = [
"company_id",
"workspace_id",
"user_id",
"channel_id",
"type",
] as const;
const logger = getLogger("channel.member");
export class MemberServiceImpl {
version: "1";
userChannelsRepository: Repository<ChannelMember>;
channelMembersRepository: Repository<MemberOfChannel>;
channelMembersReadCursorRepository: Repository<ChannelMemberReadCursors>;
private channelCounter: CounterProvider<ChannelCounterEntity>;
private cache: NodeCache;
async init(): Promise<this> {
try {
this.userChannelsRepository = await gr.database.getRepository("user_channels", ChannelMember);
this.channelMembersRepository = await gr.database.getRepository(
"channel_members",
MemberOfChannel,
);
this.channelMembersReadCursorRepository = await gr.database.getRepository(
"channel_members_read_cursors",
ChannelMemberReadCursors,
);
} catch (err) {
logger.error({ err }, "Can not initialize channel member service");
}
const channelCountersRepository = await gr.database.getRepository<ChannelCounterEntity>(
ChannelCounterEntityType,
ChannelCounterEntity,
);
this.channelCounter = await gr.platformServices.counter.getCounter<ChannelCounterEntity>(
channelCountersRepository,
);
this.channelCounter.setReviseCallback(async (pk: ChannelCounterPrimaryKey) => {
if (pk.counter_type === ChannelUserCounterType.MESSAGES) {
return;
}
const type =
ChannelUserCounterType.MEMBERS === pk.counter_type
? ChannelMemberType.MEMBER
: ChannelMemberType.GUEST;
return countRepositoryItems(
this.channelMembersRepository,
{ channel_id: pk.id, company_id: pk.company_id, workspace_id: pk.workspace_id },
{ type },
);
}, 400);
this.cache = new NodeCache({ stdTTL: 1, checkperiod: 120 });
return this;
}
@RealtimeSaved<ChannelMember>((member, context) => {
return [
//Send member preferences update to channels collections
{
room: `/companies/${member.company_id}/workspaces/${member.workspace_id}/channels?type=${
member.workspace_id === ChannelVisibility.DIRECT ? "direct" : "private"
}&user=${member.user_id}`,
resource: {
company_id: member.company_id,
workspace_id: member.workspace_id,
id: member.channel_id,
user_member: member,
},
},
{
room: ResourcePath.get(getRoomName(context as ChannelExecutionContext)),
path: getMemberPath(member, context as ChannelExecutionContext),
},
];
})
async save(
member: ChannelMember,
context: ChannelExecutionContext,
): Promise<SaveResult<ChannelMember>> {
let memberToSave: ChannelMember;
const channel = await gr.services.channels.channels.get(context.channel, context);
if (!channel) {
throw CrudException.notFound("Channel does not exists");
}
const memberToUpdate = await this.userChannelsRepository.findOne(
this.getPrimaryKey(member),
{},
context,
);
const mode = memberToUpdate ? OperationType.UPDATE : OperationType.CREATE;
logger.debug(`MemberService.save - ${mode} member %o`, memberToUpdate);
if (mode === OperationType.UPDATE) {
const isCurrentUser = this.isCurrentUser(memberToUpdate, context.user);
if (!isCurrentUser) {
throw CrudException.badRequest(`Channel member ${member.user_id} can not be updated`);
}
const updatableParameters: Partial<Record<keyof ChannelMember, boolean>> = {
notification_level: isCurrentUser,
favorite: isCurrentUser,
last_access: isCurrentUser,
last_increment: isCurrentUser,
};
// Diff existing channel and input one, cleanup all the undefined fields for all objects
const memberDiff = omitBy(updatedDiff(memberToUpdate, member), isNil);
const fields = Object.keys(memberDiff) as Array<Partial<keyof ChannelMember>>;
if (!fields.length) {
return new SaveResult<ChannelMember>("channel_member", member, mode);
}
const updatableFields = fields.filter(field => updatableParameters[field]);
if (!updatableFields.length) {
throw CrudException.badRequest("Current user can not update requested fields");
}
memberToSave = cloneDeep(memberToUpdate);
//Ensure it is a boolean
memberToSave.favorite = !!memberToSave.favorite;
updatableFields.forEach(field => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(memberToSave as any)[field] = member[field];
});
await this.saveChannelMember(memberToSave, context);
this.onUpdated(
context.channel,
memberToSave,
new UpdateResult<ChannelMember>("channel_member", memberToSave),
);
} else {
const currentUserIsMember = !!(await this.getChannelMember(
context.user,
channel,
undefined,
context,
));
const isPrivateChannel = ChannelEntity.isPrivateChannel(channel);
const isPublicChannel = ChannelEntity.isPublicChannel(channel);
const isDirectChannel = ChannelEntity.isDirectChannel(channel);
const userIsDefinedInChannelUserList = (channel.members || []).includes(
String(member.user_id),
);
const isChannelCreator = context.user && String(channel.owner) === String(context.user.id);
// 1. Private channel: user can not join private channel by themself when it is private
// only member can add other users in channel
// 2. The channel creator check is only here on channel creation
if (
isChannelCreator ||
isPublicChannel ||
context.user.server_request ||
(isPrivateChannel && currentUserIsMember) ||
(isDirectChannel && userIsDefinedInChannelUserList)
) {
const memberToSave = { ...member, ...context.channel };
await this.saveChannelMember(memberToSave, context);
await this.usersCounterIncrease(channel, member.user_id);
this.onCreated(
channel,
member,
context.user,
new CreateResult<ChannelMember>("channel_member", memberToSave),
);
} else {
throw CrudException.badRequest(
`User ${member.user_id} is not allowed to join this channel`,
);
}
}
return new SaveResult<ChannelMember>("channel_member", member, mode);
}
async get(pk: ChannelMemberPrimaryKey, context: ExecutionContext): Promise<ChannelMember> {
// FIXME: Who can fetch a single member?
return await this.userChannelsRepository.findOne(this.getPrimaryKey(pk), {}, context);
}
@RealtimeDeleted<ChannelMember>((member, context) => [
{
room: `/companies/${member.company_id}/workspaces/${member.workspace_id}/channels?type=${
member.workspace_id === ChannelVisibility.DIRECT ? "direct" : "private"
}&user=${member.user_id}`,
resource: {
company_id: member.company_id,
workspace_id: member.workspace_id,
id: member.channel_id,
user_id: member.user_id,
user_member: null,
},
},
{
room: ResourcePath.get(getRoomName(context as ChannelExecutionContext)),
path: getMemberPath(member, context as ChannelExecutionContext),
},
])
async delete(
pk: ChannelMemberPrimaryKey,
context: ChannelExecutionContext,
): Promise<DeleteResult<ChannelMember>> {
const memberToDelete = await this.userChannelsRepository.findOne(pk, {}, context);
const channel = await gr.services.channels.channels.get(context.channel, context);
if (!channel) {
throw CrudException.notFound("Channel does not exists");
}
if (!memberToDelete) {
throw CrudException.notFound("Channel member not found");
}
if (ChannelEntity.isDirectChannel(channel)) {
if (!this.isCurrentUser(memberToDelete, context.user)) {
throw CrudException.badRequest("User can not remove other users from direct channel");
}
}
if (ChannelEntity.isPrivateChannel(channel)) {
const canLeave = await this.canLeavePrivateChannel(context.user, channel);
if (!canLeave) {
throw CrudException.badRequest("User can not leave the private channel");
}
}
await this.userChannelsRepository.remove(getChannelMemberInstance(pk), context);
await this.channelMembersRepository.remove(getMemberOfChannelInstance(pk), context);
await this.usersCounterIncrease(channel, pk.user_id, -1);
this.onDeleted(memberToDelete, context.user, channel);
return new DeleteResult<ChannelMember>("channel_member", pk as ChannelMember, true);
}
/**
* List given channel members
*/
async list(
pagination: Pagination,
options: ChannelListOptions,
context: ChannelExecutionContext,
): Promise<ListResult<ChannelMember>> {
const channel = await gr.services.channels.channels.get(
{
company_id: context.channel.company_id,
workspace_id: context.channel.workspace_id,
id: context.channel.id,
},
context,
);
if (!channel) {
throw CrudException.notFound("Channel not found");
}
if (ChannelEntity.isDirectChannel(channel) || ChannelEntity.isPrivateChannel(channel)) {
const isMember = await this.getChannelMember(context.user, channel, undefined, context);
if (!isMember && !context.user.application_id && !context.user.server_request) {
throw CrudException.badRequest("User does not have enough rights to get channels");
}
}
const result = await this.channelMembersRepository.find(
{
company_id: context.channel.company_id,
workspace_id: context.channel.workspace_id,
channel_id: context.channel.id,
},
{ pagination },
context,
);
const companyUsers = await gr.services.companies.getUsers(
{
group_id: context.channel.company_id,
},
new Pagination(),
{ userIds: result.getEntities().map(member => member.user_id) },
);
if (options.company_role) {
companyUsers.filterEntities(entity => entity.role === options.company_role);
}
const companyUserIds = companyUsers.getEntities().map(entity => entity.user_id);
result.filterEntities(cm => companyUserIds.includes(cm.user_id));
return new ListResult<ChannelMember>(
"channel_member",
result
.getEntities()
.map(member => plainToClass(ChannelMember, { id: member.user_id, ...member })),
result.nextPage,
);
}
async listAllUserChannelsIds(
user_id: string,
company_id: string,
workspace_id: string,
context: ExecutionContext,
): Promise<string[]> {
let pagination = new Pagination(null);
const channels: string[] = [];
let result: ListResult<ChannelMember>;
let hasMoreItems = true;
do {
result = await this.userChannelsRepository.find(
{
company_id,
workspace_id,
user_id,
},
{ pagination },
context,
);
if (result.isEmpty()) {
hasMoreItems = false;
} else {
result.getEntities().forEach(entity => {
channels.push(entity.channel_id);
});
if (!result.nextPage.page_token) hasMoreItems = false;
else pagination = new Pagination(result.nextPage.page_token);
}
} while (hasMoreItems);
return channels;
}
async listUserChannels(
user: User,
pagination: Pagination,
context: WorkspaceExecutionContext,
): Promise<ListResult<ChannelMember>> {
const result = await this.userChannelsRepository.find(
{
company_id: context.workspace.company_id,
workspace_id: context.workspace.workspace_id,
user_id: user.id,
},
{ pagination },
context,
);
return new ListResult<ChannelMember>(
"channel_member",
result
.getEntities()
.map(member => plainToClass(ChannelMember, { id: member.user_id, ...member })),
result.nextPage,
);
}
/**
* Can leave only if the number of members is > 1
* since counting rows in DB takes too much time
* we query a list for 2 members without offset and check the length
*/
async canLeavePrivateChannel(user: User, channel: ChannelEntity): Promise<boolean> {
const list = this.list(new Pagination(undefined, "2"), {}, { channel, user });
return (await list).getEntities().length > 1;
}
async addUsersToChannel(
users: Pick<User, "id">[] = [],
channel: ChannelEntity & { stats: ChannelObject["stats"] },
context?: ExecutionContext,
): Promise<
ListResult<{ channel: ChannelEntity; added: boolean; member?: ChannelMember; err?: Error }>
> {
if (!channel) {
throw CrudException.badRequest("Channel is required");
}
logger.debug(
"Add users %o to channel %o",
users.map(u => u.id),
channel.id,
);
await gr.services.channels.channels.completeWithStatistics([channel]);
const members: Array<{
channel: ChannelEntity;
added: boolean;
member?: ChannelMember;
err?: Error;
}> = await Promise.all(
users.map(async user => {
const channelContext: ChannelExecutionContext = {
channel,
user: context?.user || user,
};
const member: ChannelMember = getChannelMemberInstance({
channel_id: channel.id,
company_id: channel.company_id,
workspace_id: channel.workspace_id,
user_id: user.id,
last_increment: channel.stats.messages,
last_access: Date.now(),
} as ChannelMember);
try {
const isAlreadyMember = await this.getChannelMember(user, channel, undefined, context);
if (isAlreadyMember) {
logger.debug("User %s is already member in channel %s", member.user_id, channel.id);
return { channel, added: false };
}
const result = await this.save(member, channelContext);
return {
channel,
added: true,
member: result.entity,
};
} catch (err) {
logger.warn({ err }, "Member has not been added %o", member);
return {
channel,
err,
added: false,
};
}
}),
);
return new ListResult("channel_member", members);
}
async addUserToChannels(
user: Pick<User, "id">,
channels: ChannelEntity[],
): Promise<
ListResult<{ channel: ChannelEntity; added: boolean; member?: ChannelMember; err?: Error }>
> {
logger.debug(
"Add user %s to channels %o",
user.id,
channels.map(c => c.id),
);
const members: Array<{
channel: ChannelEntity;
added: boolean;
member?: ChannelMember;
err?: Error;
}> = await Promise.all(
channels.map(async channel => {
const context: ChannelExecutionContext = {
channel,
user,
};
const member = getChannelMemberInstance({
channel_id: channel.id,
company_id: channel.company_id,
workspace_id: channel.workspace_id,
user_id: user.id,
});
try {
const isAlreadyMember = await this.getChannelMember(user, channel, undefined, context);
if (isAlreadyMember) {
logger.debug("User %s is already member in channel %s", member.user_id, channel.id);
return { channel, added: false };
}
const result = await this.save(member, context);
return { channel, member: result.entity, added: true };
} catch (err) {
logger.warn({ err }, "Member has not been added %o", member);
return { channel, added: false, err };
}
}),
);
return new ListResult("channel_member", members);
}
onUpdated(
channel: Channel,
member: ChannelMember,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
updateResult: UpdateResult<ChannelMember>,
): void {
logger.debug("Member updated %o", member);
gr.platformServices.messageQueue.publish("channel:member:updated", {
data: {
channel,
member,
},
});
}
onCreated(
channel: ChannelEntity,
member: ChannelMember,
user: User,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
createResult: SaveResult<ChannelMember>,
): void {
logger.debug("Member created %o", member);
gr.platformServices.messageQueue.publish<ResourceEventsPayload>("channel:member:created", {
data: {
channel,
user,
member,
},
});
localEventBus.publish<ResourceEventsPayload>("channel:member:created", {
channel,
user,
member,
actor: user,
resourcesAfter: [member],
});
}
onDeleted(member: ChannelMember, user: User, channel: ChannelEntity): void {
logger.debug("Member deleted %o", member);
gr.platformServices.messageQueue.publish<ResourceEventsPayload>("channel:member:deleted", {
data: {
channel,
user,
member,
},
});
localEventBus.publish<ResourceEventsPayload>("channel:member:deleted", {
actor: user,
resourcesBefore: [member],
channel,
user,
member,
});
}
isCurrentUser(member: ChannelMember | MemberOfChannel, user: User): boolean {
return String(member.user_id) === String(user.id);
}
async getChannelMember(
user: User,
channel: Partial<Pick<Channel, "company_id" | "workspace_id" | "id">>,
cacheTtlSec?: number,
context?: ExecutionContext,
): Promise<ChannelMember> {
if (!user) {
return;
}
if (cacheTtlSec) {
const pk = JSON.stringify({ user, channel });
if (this.cache.has(pk)) return this.cache.get<ChannelMember>(pk);
const entity = await this.getChannelMember(user, channel, undefined, context);
this.cache.set<ChannelMember>(pk, entity, cacheTtlSec);
return entity;
}
return this.get(
{
channel_id: channel.id,
company_id: channel.company_id,
workspace_id: channel.workspace_id,
user_id: user.id,
},
context,
);
}
getPrimaryKey(
memberOrPrimaryKey: ChannelMember | ChannelMemberPrimaryKey,
): ChannelMemberPrimaryKey {
return pick(
memberOrPrimaryKey,
...(["company_id", "workspace_id", "channel_id", "user_id"] as const),
);
}
private async usersCounterIncrease(channel: Channel, userId: string, increaseValue: number = 1) {
// const isMember = await this.isCompanyMember(channel.company_id, userId);
const isMember = true; // Since actually all users are now added to the channel as members, we are removing the guest counter for now.
const counter_type = isMember ? ChannelUserCounterType.MEMBERS : ChannelUserCounterType.GUESTS;
return this.channelCounter.increase(
{
id: channel.id,
company_id: channel.company_id,
workspace_id: channel.workspace_id,
counter_type,
},
increaseValue,
);
}
getUsersCount(pk: ChannelCounterPrimaryKey): Promise<number> {
return this.channelCounter.get(pk);
}
async isCompanyMember(companyId: string, userId: string): Promise<boolean> {
return (await gr.services.companies.getUserRole(companyId, userId)) != "guest";
}
private async saveChannelMember(
memberToSave: ChannelMember & Channel,
context: ExecutionContext,
) {
const userChannel = getChannelMemberInstance(pick(memberToSave, ...USER_CHANNEL_KEYS));
const channelMember = getMemberOfChannelInstance(pick(memberToSave, ...CHANNEL_MEMBERS_KEYS));
await this.userChannelsRepository.save(userChannel, context);
await this.channelMembersRepository.save(channelMember, context);
}
async ensureUserNotInWorkspaceIsNotInChannel(
userPk: UserPrimaryKey,
workspacePk: WorkspacePrimaryKey,
context: ExecutionContext,
) {
const workspace = await gr.services.workspaces.get(workspacePk);
const member = await gr.services.workspaces.getUser({
workspaceId: workspace.id,
userId: userPk.id,
});
if (!member) {
const result = await this.userChannelsRepository.find(
{
company_id: workspace.company_id,
workspace_id: workspace.id,
user_id: userPk.id,
},
{},
context,
);
for (const channel of result.getEntities()) {
logger.warn(
`User ${userPk.id} is not in workspace ${workspace.id} so it will be removed from channel ${channel.id}`,
);
await this.delete(channel, { user: { id: userPk.id }, channel });
}
}
}
/**
* The list of members read sections of the channel.
*
* @param {ChannelExecutionContext} context - The context of the execution.
* @returns {Promise<ListResult<ChannelMemberReadCursors>>} - The channel members read cursors.
*/
async getChannelMembersReadSections(
context: ChannelExecutionContext,
): Promise<ListResult<ChannelMemberReadCursors>> {
try {
const readCursors = await this.channelMembersReadCursorRepository.find(
{
company_id: context.channel.company_id,
channel_id: context.channel.id,
},
{ pagination: { limitStr: "100" } },
context,
);
return new ListResult<ChannelMemberReadCursors>(
"channel_members_read_cursors",
readCursors.getEntities(),
);
} catch (error) {
logger.error(error);
throw error;
}
}
/**
* The channel member read section of the channel.
*
* @param {ChannelExecutionContext} context - The context of the execution.
* @returns {Promise<ChannelMemberReadCursors>} - The channel member read cursors.
*/
async getChannelMemberReadSections(
member: string,
context: ChannelExecutionContext,
): Promise<ChannelMemberReadCursors> {
try {
const readCursors = await this.channelMembersReadCursorRepository.findOne(
{
company_id: context.channel.company_id,
channel_id: context.channel.id,
user_id: member,
},
{},
context,
);
return readCursors;
} catch (error) {
logger.error(error);
throw error;
}
}
/**
* Updates the channel member read section.
*
* @param {{ start: string; end: string }} section - The section to save the cursor for.
* @param {CompanyExecutionContext & { channel_id: string }} context - The channel execution context.
* @returns {Promise<SaveResult<ChannelMemberReadCursors>>} - The result of the save operation.
*/
async setChannelMemberReadSections(
section: { start: string; end: string },
context: CompanyExecutionContext & { channel_id: string; workspace_id: string },
): Promise<SaveResult<ChannelMemberReadCursors>> {
const member = await this.getChannelMember(
context.user,
{
id: context.channel_id,
company_id: context.company.id,
workspace_id: context.workspace_id,
},
null,
context,
);
if (!member) {
throw CrudException.badRequest(
`User ${context.user.id} is not a member of channel ${context.channel_id}`,
);
}
const existingReadSection = await this.channelMembersReadCursorRepository.findOne(
{
company_id: context.company.id,
channel_id: context.channel_id,
user_id: context.user.id,
},
{},
context,
);
const { start, end } = section;
if (
!existingReadSection ||
!existingReadSection.read_section ||
!existingReadSection.read_section.length
) {
const newMemberReadCursor = new ChannelMemberReadCursors();
newMemberReadCursor.company_id = context.company.id;
newMemberReadCursor.channel_id = context.channel_id;
newMemberReadCursor.user_id = context.user.id;
newMemberReadCursor.read_section = [start, end] as ReadSection;
await this.channelMembersReadCursorRepository.save(newMemberReadCursor, context);
return new SaveResult<ChannelMemberReadCursors>(
"channel_members_read_cursors",
newMemberReadCursor,
OperationType.CREATE,
);
}
const updatedReadSection = { start, end };
const [existingStart, existingEnd] = existingReadSection.read_section;
const existingStartTime = uuidTime.v1(existingStart);
const existingEndTime = uuidTime.v1(existingEnd);
const startTime = uuidTime.v1(start);
const endTime = uuidTime.v1(end);
if (existingStartTime < startTime) {
updatedReadSection.start = existingStart;
}
if (existingEndTime > endTime) {
updatedReadSection.end = existingEnd;
}
if (existingStart === updatedReadSection.start && existingEnd === updatedReadSection.end) {
return new SaveResult<ChannelMemberReadCursors>(
"channel_members_read_cursors",
existingReadSection,
OperationType.EXISTS,
);
}
existingReadSection.read_section = [updatedReadSection.start, updatedReadSection.end];
await this.channelMembersReadCursorRepository.save(existingReadSection, context);
return new SaveResult<ChannelMemberReadCursors>(
"channel_members_read_cursors",
existingReadSection,
OperationType.UPDATE,
);
}
/**
* list users who have seen the message.
*
* @param {String} id - the message id
* @param {ChannelExecutionContext} context - the thread execution context
* @returns { Promise<string[]>} - the promise containing the user id list
*/
async getChannelMessageSeenByUsers(
id: string,
context: ChannelExecutionContext,
): Promise<string[]> {
const channelReadSections = await this.getChannelMembersReadSections(context);
return channelReadSections
.getEntities()
.filter(section => {
const sectionEnd = section.read_section[1];
const sectionEndTime = uuidTime.v1(sectionEnd);
const messageTime = uuidTime.v1(id);
return messageTime <= sectionEndTime;
})
.map(section => section.user_id);
}
}
@@ -1,22 +0,0 @@
import { Initializable } from "../../../../core/platform/framework";
import { NewChannelActivityProcessor } from "./new-channel-activity";
import { NewUserInWorkspaceJoinDefaultChannelsProcessor } from "./new-user-in-workspace-join-default-channels";
import { NewPendingEmailsInWorkspaceJoinChannelsProcessor } from "./new-pending-emails-in-workspace-join-channels";
import { NewWorkspaceProcessor } from "./new-workspace";
import gr from "../../../global-resolver";
export class ChannelsMessageQueueListener implements Initializable {
async init(): Promise<this> {
const channelActivityProcessor = await new NewChannelActivityProcessor().init();
gr.platformServices.messageQueue.processor.addHandler(channelActivityProcessor);
gr.platformServices.messageQueue.processor.addHandler(
new NewUserInWorkspaceJoinDefaultChannelsProcessor(),
);
gr.platformServices.messageQueue.processor.addHandler(
new NewPendingEmailsInWorkspaceJoinChannelsProcessor(),
);
gr.platformServices.messageQueue.processor.addHandler(new NewWorkspaceProcessor());
return this;
}
}
@@ -1,91 +0,0 @@
import { getLogger } from "../../../../core/platform/framework";
import { MessageQueueHandler } from "../../../../core/platform/services/message-queue/api";
import { ChannelActivityNotification } from "../../types";
import { CounterProvider } from "../../../../core/platform/services/counter/provider";
import {
ChannelCounterEntity,
ChannelUserCounterType,
TYPE as ChannelCounterEntityType,
} from "../../entities/channel-counters";
import gr from "../../../global-resolver";
const logger = getLogger("channel.message-queue.new-channel-activity");
export class NewChannelActivityProcessor
implements MessageQueueHandler<ChannelActivityNotification, void>
{
private channelCounter: CounterProvider<ChannelCounterEntity>;
async init() {
const channelCountersRepository = await gr.database.getRepository<ChannelCounterEntity>(
ChannelCounterEntityType,
ChannelCounterEntity,
);
this.channelCounter = await gr.platformServices.counter.getCounter<ChannelCounterEntity>(
channelCountersRepository,
);
return this;
}
readonly topics = {
in: "channel:activity",
};
readonly options = {
unique: true,
ack: true,
};
readonly name = "NewChannelActivityProcessor";
validate(message: ChannelActivityNotification): boolean {
return !!(message && message.channel_id && message.company_id && message.workspace_id);
}
async process(message: ChannelActivityNotification): Promise<void> {
logger.info(`${this.name} - Processing new activity in channel ${message.channel_id}`);
await this.channelCounter.increase(
{
id: message.channel_id,
company_id: message.company_id,
workspace_id: message.workspace_id,
counter_type: ChannelUserCounterType.MESSAGES,
},
1,
);
try {
await gr.services.channels.channels.updateLastActivity(
{
date: message.date,
channel: {
id: message.channel_id,
workspace_id: message.workspace_id,
company_id: message.company_id,
},
message: {
date: message.date,
sender: message.sender,
sender_name: message.sender_name,
title: message.title,
text: message.body,
},
},
{
workspace: {
workspace_id: message.workspace_id,
company_id: message.company_id,
},
user: {
id: message.sender,
},
},
);
} catch (err) {
logger.error(
{ err },
`${this.name} - Error while applying channel new activity in channel ${message?.channel_id}`,
);
}
}
}
@@ -1,58 +0,0 @@
import { getLogger } from "../../../../core/platform/framework";
import { MessageQueueHandler } from "../../../../core/platform/services/message-queue/api";
import { NewUserInWorkspaceNotification } from "../channel/types";
import gr from "../../../global-resolver";
import { ExecutionContext } from "../../../../core/platform/framework/api/crud-service";
const NAME = "Channel::NewPendingEmailsInWorkspaceJoinChannelsProcessor";
const logger = getLogger(
"channel.message-queue.new-pending-emails-in-workspace-join-channels-processor",
);
/**
* When a new pending email is added in a workspace, a `workspace:email:added` event is published.
* In such case, the email must be added to all the channels that he is invited.
*/
export class NewPendingEmailsInWorkspaceJoinChannelsProcessor
implements MessageQueueHandler<NewUserInWorkspaceNotification, void>
{
readonly topics = {
in: "workspace:member:added",
};
readonly options = {
unique: true,
ack: true,
queue: "workspace:user:added:consumer_channel_pending_emails",
};
readonly name = NAME;
validate(message: NewUserInWorkspaceNotification): boolean {
return !!(message && message.company_id && message.workspace_id && message.user_id);
}
async process(
message: NewUserInWorkspaceNotification,
context?: ExecutionContext,
): Promise<void> {
logger.debug("Processing notification for message %o", message);
try {
await gr.services.channelPendingEmail.proccessPendingEmails(
message,
{
workspace_id: message.workspace_id,
company_id: message.company_id,
},
context,
);
} catch (err) {
logger.error(
{ err },
`${this.name} - Error while processing message for pending emails %o`,
message,
);
}
}
}
@@ -1,59 +0,0 @@
import { getLogger } from "../../../../core/platform/framework";
import { MessageQueueHandler } from "../../../../core/platform/services/message-queue/api";
import { NewUserInWorkspaceNotification } from "../channel/types";
import gr from "../../../global-resolver";
const NAME = "Channel::NewUserInWorkspaceJoinDefaultChannelsProcessor";
const logger = getLogger(
"channel.message-queue.new-user-in-workspace-join-default-channels-processor",
);
/**
* When a new user is added in a workspace, a `workspace:user:added` event is published.
* In such case, the user must be added to all the default channels of the workspace.
*/
export class NewUserInWorkspaceJoinDefaultChannelsProcessor
implements MessageQueueHandler<NewUserInWorkspaceNotification, void>
{
readonly topics = {
in: "workspace:member:added",
};
readonly options = {
unique: true,
ack: true,
queue: "workspace:user:added:consumer_default_channels",
};
readonly name = NAME;
validate(message: NewUserInWorkspaceNotification): boolean {
return !!(message && message.company_id && message.workspace_id && message.user_id);
}
async process(message: NewUserInWorkspaceNotification): Promise<void> {
logger.debug("Processing notification for message %o", message);
try {
const channelMembers = await gr.services.channels.channels.addUserToDefaultChannels(
{ id: message.user_id },
{
company_id: message.company_id,
workspace_id: message.workspace_id,
},
);
logger.debug(
"User %s has been added as member to default channels %o",
message.user_id,
(channelMembers || []).map(c => c.channel_id),
);
} catch (err) {
logger.error(
{ err },
`${this.name} - Error while processing message for default channels %o`,
message,
);
}
}
}
@@ -1,57 +0,0 @@
import { getLogger } from "../../../../core/platform/framework";
import { MessageQueueHandler } from "../../../../core/platform/services/message-queue/api";
import { ChannelActivityNotification, ChannelVisibility } from "../../types";
import { getInstance } from "../../../channels/entities/channel";
import gr from "../../../global-resolver";
const logger = getLogger("channel.message-queue.new-channel-activity");
export class NewWorkspaceProcessor
implements MessageQueueHandler<ChannelActivityNotification, void>
{
readonly topics = {
in: "workspace:added",
queue: "workspace:added:consumer1",
};
readonly options = {
unique: true,
ack: true,
};
readonly name = "NewWorkspaceProcessor";
validate(message: ChannelActivityNotification): boolean {
return !!(message && message.company_id && message.workspace_id);
}
async process(message: ChannelActivityNotification): Promise<void> {
logger.info(`${this.name} - Processing new workspace created ${message.workspace_id}`);
try {
await gr.services.channels.channels.save(
getInstance({
icon: "💬",
name: "General",
description: "",
visibility: ChannelVisibility.PUBLIC,
is_default: true,
}),
{},
{
workspace: {
workspace_id: message.workspace_id,
company_id: message.company_id,
},
user: {
id: message.sender,
},
},
);
} catch (err) {
logger.error(
{ err },
`${this.name} - Error while generating default channels for workspace ${message?.workspace_id}`,
);
}
}
}
@@ -1,136 +0,0 @@
import _ from "lodash";
import { RealtimeDeleted, RealtimeSaved } from "../../../core/platform/framework";
import {
DeleteResult,
ExecutionContext,
ListResult,
OperationType,
Pagination,
SaveResult,
UpdateResult,
} from "../../../core/platform/framework/api/crud-service";
import { ChannelTab, ChannelTabPrimaryKey } from "../entities";
import { ChannelExecutionContext } from "../types";
import { Channel } from "../../../utils/types";
import { ResourcePath } from "../../../core/platform/services/realtime/types";
import Repository from "../../../core/platform/services/database/services/orm/repository/repository";
import gr from "../../global-resolver";
export class TabServiceImpl {
version: "1";
repository: Repository<ChannelTab>;
async init(): Promise<this> {
this.repository = await gr.database.getRepository("channel_tabs", ChannelTab);
return this;
}
@RealtimeSaved<ChannelTab>((tab, context) => [
{
room: ResourcePath.get(getTabsRealtimeRoom((context as ChannelExecutionContext).channel)),
path: getTabsRealtimeResourcePath(tab, (context as ChannelExecutionContext).channel),
},
])
async save<SaveOptions>(
tab: ChannelTab,
options: SaveOptions,
context: ChannelExecutionContext,
): Promise<SaveResult<ChannelTab>> {
const pk = {
company_id: context.channel.company_id,
workspace_id: context.channel.workspace_id,
channel_id: context.channel.id,
id: tab.id,
};
let tabEntity = await this.repository.findOne(pk, {}, context);
if (!tabEntity || !tab.id) {
tabEntity = new ChannelTab();
tabEntity = _.merge(tabEntity, pk);
tabEntity.owner = context.user.id;
}
tabEntity = _.merge(
tabEntity,
_.pick(tab, ["name", "configuration", "application_id", "order"]),
);
await this.repository.save(tabEntity, context);
return new SaveResult("channel_tabs", tabEntity, OperationType.CREATE);
}
async get(tabPk: ChannelTabPrimaryKey, context: ChannelExecutionContext): Promise<ChannelTab> {
const pk = {
company_id: context.channel.company_id,
workspace_id: context.channel.workspace_id,
channel_id: context.channel.id,
id: tabPk.id,
};
return await this.repository.findOne(pk, {}, context);
}
@RealtimeDeleted<ChannelTab>((tab, context) => [
{
room: ResourcePath.get(getTabsRealtimeRoom((context as ChannelExecutionContext).channel)),
path: getTabsRealtimeResourcePath(tab, (context as ChannelExecutionContext).channel),
},
])
async delete(
pk: ChannelTabPrimaryKey,
context?: ExecutionContext,
): Promise<DeleteResult<ChannelTab>> {
const tabEntity = await this.repository.findOne(pk, {}, context);
if (tabEntity) {
await this.repository.remove(tabEntity, context);
}
return new DeleteResult("channel_tabs", tabEntity, true);
}
async list<ListOptions>(
pagination: Pagination,
options: ListOptions,
context: ChannelExecutionContext,
): Promise<ListResult<ChannelTab>> {
const pk = {
company_id: context.channel.company_id,
workspace_id: context.channel.workspace_id,
channel_id: context.channel.id,
};
return await this.repository.find(pk, { pagination }, context);
}
onUpdated(
channel: Channel,
tab: ChannelTab,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
updateResult: UpdateResult<ChannelTab>,
): void {
console.log("Tab updated", tab);
}
onCreated(
channel: Channel,
tab: ChannelTab,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
createResult: SaveResult<ChannelTab>,
): void {
console.log("Tab created", tab);
}
onDeleted(channel: Channel, tab: ChannelTab): void {
console.log("Tab deleted", tab);
}
}
export function getTabsRealtimeRoom(channel: ChannelExecutionContext["channel"]): string {
return `/companies/${channel.company_id}/workspaces/${channel.workspace_id}/channels/${channel.id}/tabs`;
}
export function getTabsRealtimeResourcePath(
tab: ChannelTab,
channel: ChannelExecutionContext["channel"],
): string {
return `/companies/${channel.company_id}/workspaces/${channel.workspace_id}/channels/${channel.id}/tabs/${tab.id}`;
}
@@ -1,54 +0,0 @@
import { ExecutionContext } from "../../core/platform/framework/api/crud-service";
import { Channel, uuid, Workspace } from "../../utils/types";
export interface WorkspaceExecutionContext extends ExecutionContext {
workspace: Workspace;
}
export interface ChannelExecutionContext extends ExecutionContext {
channel: Channel;
}
export interface ChannelSystemExecutionContext {
workspace: Workspace;
channel?: Channel;
}
export enum ChannelVisibility {
PRIVATE = "private",
PUBLIC = "public",
DIRECT = "direct",
}
export enum ChannelType {
DIRECT = "direct",
}
export enum ChannelMemberType {
MEMBER = "member",
GUEST = "guest",
BOT = "bot",
}
export enum ChannelMemberNotificationLevel {
// be notified on all messages
ALL = "all",
// Only be notified on @user, @all, @here, @everyone mentions
MENTIONS = "mentions",
// Only be notified on @user mention
ME = "me",
// do not be notified at all even when someone mention user, not on direct channels
NONE = "none",
}
export type ChannelActivityNotification = {
company_id: uuid;
workspace_id: uuid | "direct";
channel_id: uuid;
date: number;
sender: string;
title: string;
text: string;
sender_name: string;
body: string;
};
@@ -1,5 +0,0 @@
import { ChannelType } from "./types";
export function isDirectChannel(channel: { workspace_id: string }): boolean {
return channel?.workspace_id === ChannelType.DIRECT;
}
@@ -1,623 +0,0 @@
import { plainToClass } from "class-transformer";
import { FastifyReply, FastifyRequest } from "fastify";
import {
CrudException,
ExecutionContext,
ListResult,
Pagination,
} from "../../../../core/platform/framework/api/crud-service";
import { CrudController } from "../../../../core/platform/services/webserver/types";
import {
Channel,
ChannelMember,
ChannelPendingEmails,
ChannelPendingEmailsPrimaryKey,
ChannelPrimaryKey,
getChannelPendingEmailsInstance,
UserChannel,
UsersIncludedChannel,
} from "../../entities";
import { getWebsocketInformation, getWorkspaceRooms } from "../../services/channel/realtime";
import {
BaseChannelsParameters,
ChannelListQueryParameters,
ChannelParameters,
ChannelPendingEmailsDeleteQueryParameters,
ChannelSaveOptions,
ChannelSearchQueryParameters,
CreateChannelBody,
ReadChannelBody,
UpdateChannelBody,
} from "../types";
import { ChannelExecutionContext, ChannelVisibility, WorkspaceExecutionContext } from "../../types";
import { handleError } from "../../../../utils/handleError";
import {
ResourceCreateResponse,
ResourceDeleteResponse,
ResourceGetResponse,
ResourceListResponse,
ResourceUpdateResponse,
} from "../../../../utils/types";
import { getLogger } from "../../../../core/platform/framework/logger";
import _ from "lodash";
import { ChannelMemberObject, ChannelObject } from "../../services/channel/types";
import gr from "../../../global-resolver";
import { checkUserBelongsToCompany } from "../../../../utils/company";
const logger = getLogger("channel.controller");
export class ChannelCrudController
implements
CrudController<
ResourceGetResponse<ChannelObject>,
ResourceCreateResponse<ChannelObject>,
ResourceListResponse<ChannelObject>,
ResourceDeleteResponse
>
{
getPrimaryKey(request: FastifyRequest<{ Params: ChannelParameters }>): ChannelPrimaryKey {
return {
id: request.params.id,
company_id: request.params.company_id,
workspace_id: request.params.workspace_id,
};
}
async get(
request: FastifyRequest<{ Querystring: ChannelListQueryParameters; Params: ChannelParameters }>,
_reply: FastifyReply,
): Promise<ResourceGetResponse<ChannelObject>> {
const context = getExecutionContext(request);
let channel: Channel | UsersIncludedChannel = await gr.services.channels.channels.get(
this.getPrimaryKey(request),
context,
);
if (!channel) {
throw CrudException.notFound(`Channel ${request.params.id} not found`);
}
if (Channel.isDirectChannel(channel) || Channel.isPrivateChannel(channel)) {
const isMember = await gr.services.channels.members.getChannelMember(
request.currentUser,
channel,
undefined,
context,
);
if (!isMember && !context.user.application_id && !context.user.server_request) {
throw CrudException.badRequest("User does not have enough rights to get channel");
}
}
if (request.query.include_users)
channel = await gr.services.channels.channels.includeUsersInDirectChannel(channel);
const member = await gr.services.channels.members.get(
_.assign(new ChannelMember(), {
channel_id: channel.id,
workspace_id: channel.workspace_id,
company_id: channel.company_id,
user_id: context.user.id,
}),
context,
);
const channelObject = ChannelObject.mapTo(channel, {
user_member: ChannelMemberObject.mapTo(member),
});
await gr.services.channels.channels.completeWithStatistics([channelObject]);
return {
websocket: gr.platformServices.realtime.sign(
[getWebsocketInformation(channel)],
context.user.id,
)[0],
resource: channelObject,
};
}
async search(
request: FastifyRequest<{
Querystring: ChannelSearchQueryParameters;
Params: { company_id: string };
}>,
_reply: FastifyReply,
): Promise<ResourceListResponse<Channel>> {
if (request.query?.q?.length === 0) {
return this.recent(request);
}
const context = getSimpleExecutionContext(request);
const userId = request.currentUser.id;
await checkUserBelongsToCompany(request.currentUser.id, request.params.company_id);
const limit = request.query.limit || 100;
async function* getNextChannels(): AsyncIterableIterator<Channel> {
let lastPageToken = null;
let channels: Channel[] = [];
let hasMore = true;
do {
channels = await gr.services.channels.channels
.search(
new Pagination(lastPageToken, limit.toString()),
{
search: request.query.q,
companyId: request.params.company_id,
},
context,
)
.then((a: ListResult<Channel>) => {
lastPageToken = a.nextPage.page_token;
if (!lastPageToken) {
hasMore = false;
}
return a.getEntities();
});
if (channels.length) {
for (const channel of channels) {
yield channel;
}
} else {
hasMore = false;
}
} while (hasMore);
}
const channels = [] as (Channel & { user_member: ChannelMemberObject })[];
for await (const ch of getNextChannels()) {
const channelMember = await gr.services.channels.members.getChannelMember(
{ id: request.currentUser.id },
ch,
50,
context,
);
//If not public channel and not member then ignore
if (!channelMember && ch.visibility !== "public") continue;
//If public channel but not in the workspace then ignore
if (
!channelMember &&
!(await gr.services.workspaces.getUser({ workspaceId: ch.workspace_id, userId }))
)
continue;
const chWithUser = await gr.services.channels.channels.includeUsersInDirectChannel(
ch,
userId,
);
channels.push({ ...chWithUser, user_member: channelMember });
if (channels.length == limit) {
break;
}
}
await gr.services.channels.channels.completeWithStatistics(channels as ChannelObject[]);
return { resources: channels };
}
async getForPHP(
request: FastifyRequest<{ Params: ChannelParameters }>,
reply: FastifyReply,
): Promise<void> {
const context = getExecutionContext(request);
const channel = await gr.services.channels.channels.get(this.getPrimaryKey(request), context);
reply.send(channel);
}
async thumbnail(
request: FastifyRequest<{ Params: ChannelParameters }>,
response: FastifyReply,
): Promise<void> {
const data = await gr.services.channels.channels.thumbnail(request.params.id);
const filename = "thumbnail.jpg";
response.header("Content-disposition", `inline; filename="${filename}"`);
response.type("image/jpeg");
response.send(data.file);
}
async save(
request: FastifyRequest<{
Body: CreateChannelBody;
Params: ChannelParameters;
Querystring: { include_users: boolean };
}>,
reply: FastifyReply,
): Promise<ResourceCreateResponse<ChannelObject>> {
const entity = plainToClass(Channel, {
...request.body.resource,
...{
company_id: request.params.company_id,
workspace_id: request.params.workspace_id,
},
});
logger.debug("reqId: %s - save - Channel input %o", request.id, entity);
try {
const options = {
members: request.body.options ? request.body.options.members || [] : [],
addCreatorAsMember: true,
} as ChannelSaveOptions;
const context = getExecutionContext(request);
const channelResult = await gr.services.channels.channels.save(entity, options, context);
logger.debug("reqId: %s - save - Channel %s created", request.id, channelResult.entity.id);
const member = await gr.services.channels.members.get(
_.assign(new ChannelMember(), {
channel_id: channelResult.entity.id,
workspace_id: channelResult.entity.workspace_id,
company_id: channelResult.entity.company_id,
user_id: context.user.id,
}),
context,
);
let entityWithUsers: Channel = channelResult.entity;
if (request.query.include_users)
entityWithUsers = await gr.services.channels.channels.includeUsersInDirectChannel(
entityWithUsers,
context.user.id,
);
const resultEntity = {
...entityWithUsers,
...{ user_member: member },
} as unknown as UserChannel;
if (entity) {
reply.code(201);
}
return {
websocket: gr.platformServices.realtime.sign(
[getWebsocketInformation(channelResult.entity)],
context.user.id,
)[0],
resource: ChannelObject.mapTo(resultEntity),
};
} catch (err) {
handleError(reply, err);
}
}
async update(
request: FastifyRequest<{ Body: UpdateChannelBody; Params: ChannelParameters }>,
reply: FastifyReply,
): Promise<ResourceUpdateResponse<ChannelObject>> {
const entity = plainToClass(Channel, {
...request.body.resource,
...{
company_id: request.params.company_id,
workspace_id: request.params.workspace_id,
id: request.params.id,
},
});
try {
const context = getExecutionContext(request);
const result = await gr.services.channels.channels.save(entity, {}, context);
if (result.entity) {
reply.code(201);
}
return {
websocket: gr.platformServices.realtime.sign(
[getWebsocketInformation(result.entity)],
context.user.id,
)[0],
resource: ChannelObject.mapTo(result.entity),
};
} catch (err) {
handleError(reply, err);
}
}
async list(
request: FastifyRequest<{
Querystring: ChannelListQueryParameters;
Params: BaseChannelsParameters;
}>,
): Promise<ResourceListResponse<ChannelObject>> {
const context = getExecutionContext(request);
//Fixme: this slow down the channel get operation. Once we stabilize the workspace:member:added event we can remove this
if (context.workspace.workspace_id !== ChannelVisibility.DIRECT) {
await gr.services.channelPendingEmail.proccessPendingEmails(
{
user_id: context.user.id,
...context.workspace,
},
context.workspace,
context,
);
}
const list = await gr.services.channels.channels.list(
new Pagination(request.query.page_token, request.query.limit),
{ ...request.query },
context,
);
let entities = [];
if (request.query.include_users) {
entities = [];
for (const e of list.getEntities()) {
entities.push(
await gr.services.channels.channels.includeUsersInDirectChannel(e, context.user.id),
);
}
} else {
entities = list.getEntities();
}
const resources = entities.map(a => ChannelObject.mapTo(a));
await gr.services.channels.channels.completeWithStatistics(resources);
return {
...{
resources: resources,
},
...(request.query.websockets && {
websockets: gr.platformServices.realtime.sign(
getWorkspaceRooms(request.params, request.currentUser),
request.currentUser.id,
),
}),
...(list.page_token && {
next_page_token: list.page_token,
}),
};
}
async delete(
request: FastifyRequest<{ Params: ChannelParameters }>,
reply: FastifyReply,
): Promise<ResourceDeleteResponse> {
try {
const deleteResult = await gr.services.channels.channels.delete(
this.getPrimaryKey(request),
getExecutionContext(request),
);
if (deleteResult.deleted) {
reply.code(204);
return {
status: "success",
};
}
return {
status: "error",
};
} catch (err) {
handleError(reply, err);
}
}
async updateRead(
request: FastifyRequest<{ Body: ReadChannelBody; Params: ChannelParameters }>,
reply: FastifyReply,
): Promise<boolean> {
const read = request.body.value;
const context = getExecutionContext(request);
try {
const result = read
? await gr.services.channels.channels.markAsRead(
this.getPrimaryKey(request),
request.currentUser,
context,
)
: await gr.services.channels.channels.markAsUnread(
this.getPrimaryKey(request),
request.currentUser,
context,
);
return result;
} catch (err) {
handleError(reply, err);
}
}
async findPendingEmails(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
request: FastifyRequest<{
Querystring: ChannelListQueryParameters;
Params: ChannelPendingEmailsPrimaryKey;
}>,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
reply: FastifyReply,
): Promise<ResourceListResponse<ChannelPendingEmails>> {
const list = await gr.services.channelPendingEmail.list(
new Pagination(request.query.page_token, request.query.limit),
{ ...request.query },
getChannelPendingEmailsExecutionContext(request),
);
return {
resources: list.getEntities(),
...(request.query.websockets && {
websockets: gr.platformServices.realtime.sign(
getWorkspaceRooms(request.params, request.currentUser),
request.currentUser.id,
),
}),
...(list.page_token && {
next_page_token: list.page_token,
}),
};
}
async savePendingEmails(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
request: FastifyRequest<{
Body: { resource: ChannelPendingEmails };
Params: ChannelPendingEmailsPrimaryKey;
}>,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
reply: FastifyReply,
): Promise<ResourceCreateResponse<ChannelPendingEmails>> {
const context = getSimpleExecutionContext(request);
const pendingEmail = await gr.services.channelPendingEmail.create(
getChannelPendingEmailsInstance(request.body.resource),
context,
);
logger.debug("reqId: %s - save - PendingEmails input %o", request.id, pendingEmail.entity);
return { resource: pendingEmail.entity };
}
async deletePendingEmails(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
request: FastifyRequest<{ Params: ChannelPendingEmailsDeleteQueryParameters }>,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
reply: FastifyReply,
): Promise<ResourceDeleteResponse> {
const pendingEmail = await gr.services.channelPendingEmail.delete(
getChannelPendingEmailsInstance({
channel_id: request.params.channel_id,
company_id: request.params.company_id,
workspace_id: request.params.workspace_id,
email: request.params.email,
}),
);
return { status: pendingEmail.deleted ? "success" : "error" };
}
async recent(
request: FastifyRequest<{
Querystring: { limit?: string };
Params: Pick<BaseChannelsParameters, "company_id">;
}>,
): Promise<ResourceListResponse<UsersIncludedChannel>> {
const companyId = request.params.company_id;
const userId = request.currentUser.id;
const context = getSimpleExecutionContext(request);
const limit = parseInt(request.query.limit) || 100;
const workspaces = (
await gr.services.workspaces.getAllForUser({ userId }, { id: companyId })
).map(a => a.workspaceId);
let channels: UserChannel[] = await gr.services.channels.channels
.getChannelsForUsersInWorkspace(companyId, "direct", userId, undefined, context)
.then(list => list.getEntities());
for (const workspaceId of workspaces) {
const workspaceChannels = await gr.services.channels.channels
.getChannelsForUsersInWorkspace(companyId, workspaceId, userId, undefined, context)
.then(list => list.getEntities());
channels = [...channels, ...workspaceChannels];
}
channels = channels.sort(
(a, b) =>
(b.last_activity || 0) / 100 +
(b.user_member.last_access || 0) -
((a.user_member.last_access || 0) + (a.last_activity || 0) / 100),
);
channels = channels.slice(0, 100);
if (channels.length < limit) {
let otherChannels: UserChannel[] = [];
for (const workspaceId of workspaces) {
//Include channels we could join in this result
otherChannels = [
...otherChannels,
...(
await gr.services.channels.channels.getAllChannelsInWorkspace(
companyId,
workspaceId,
context,
)
).map(ch => {
return {
...ch,
user_member: null,
} as UserChannel;
}),
];
}
channels = _.unionBy(channels, otherChannels, "id");
}
const userIncludedChannels: UsersIncludedChannel[] = await Promise.all(
channels.map(channel => {
return gr.services.channels.channels.includeUsersInDirectChannel(channel, userId);
}),
);
const resources = userIncludedChannels.slice(0, limit).map(r => ChannelObject.mapTo(r, {}));
await gr.services.channels.channels.completeWithStatistics(resources);
await new Promise(r => setTimeout(r, 2000));
return {
resources,
};
}
}
function getExecutionContext(
request: FastifyRequest<{ Params: BaseChannelsParameters }>,
): WorkspaceExecutionContext {
return {
user: request.currentUser,
url: request.url,
method: request.routerMethod,
reqId: request.id,
transport: "http",
workspace: {
company_id: request.params.company_id,
workspace_id: request.params.workspace_id,
},
};
}
function getChannelPendingEmailsExecutionContext(
request: FastifyRequest<{
Params: ChannelPendingEmailsPrimaryKey | ChannelPendingEmailsDeleteQueryParameters;
}>,
): ChannelExecutionContext {
return {
user: request.currentUser,
url: request.url,
method: request.routerMethod,
reqId: request.id,
transport: "http",
channel: {
id: request.params.channel_id,
company_id: request.params.company_id,
workspace_id: request.params.workspace_id,
},
};
}
function getSimpleExecutionContext(request: FastifyRequest): ExecutionContext {
return {
user: request.currentUser,
url: request.url,
method: request.routerMethod,
reqId: request.id,
transport: "http",
};
}
@@ -1,3 +0,0 @@
export * from "./channel";
export * from "./member";
export * from "./tab";
@@ -1,319 +0,0 @@
import { CrudController } from "../../../../core/platform/services/webserver/types";
import { CrudException, Pagination } from "../../../../core/platform/framework/api/crud-service";
import {
ChannelMember,
ChannelMemberPrimaryKey,
ChannelMemberReadCursors,
ChannelMemberWithUser,
} from "../../entities";
import {
ChannelMemberParameters,
ChannelParameters,
CreateChannelMemberBody,
PaginationQueryParameters,
UpdateChannelMemberBody,
} from "../types";
import { FastifyReply, FastifyRequest } from "fastify";
import { ChannelExecutionContext } from "../../types";
import { plainToClass } from "class-transformer";
import { handleError } from "../../../../utils/handleError";
import { getChannelRooms } from "../../services/member/realtime";
import {
ResourceCreateResponse,
ResourceDeleteResponse,
ResourceGetResponse,
ResourceListResponse,
ResourceUpdateResponse,
User,
} from "../../../../utils/types";
import gr from "../../../global-resolver";
import { formatUser } from "../../../../utils/users";
export class ChannelMemberCrudController
implements
CrudController<
ResourceGetResponse<ChannelMember>,
ResourceCreateResponse<ChannelMember>,
ResourceListResponse<ChannelMember>,
ResourceDeleteResponse
>
{
getPrimaryKey(
request: FastifyRequest<{ Params: ChannelMemberParameters }>,
): ChannelMemberPrimaryKey {
return {
user_id: request.params.member_id,
channel_id: request.params.id,
company_id: request.params.company_id,
workspace_id: request.params.workspace_id,
};
}
async get(
request: FastifyRequest<{ Params: ChannelMemberParameters }>,
_reply: FastifyReply,
): Promise<ResourceGetResponse<ChannelMember>> {
if (!isCurrentUser(request.params.member_id, request.currentUser)) {
throw CrudException.badRequest("User does not have enough rights to get member");
}
const context = getExecutionContext(request);
const resource = await gr.services.channels.members.get(this.getPrimaryKey(request), context);
if (!resource) {
throw CrudException.notFound(`Channel member ${request.params.member_id} not found`);
}
return {
resource,
};
}
async save(
request: FastifyRequest<{ Body: CreateChannelMemberBody; Params: ChannelMemberParameters }>,
reply: FastifyReply,
): Promise<ResourceCreateResponse<ChannelMember>> {
const entity = plainToClass(ChannelMember, {
...request.body.resource,
...{
company_id: request.params.company_id,
workspace_id: request.params.workspace_id,
channel_id: request.params.id,
},
});
try {
const result = await gr.services.channels.members.save(entity, getExecutionContext(request));
if (result.entity) {
reply.code(201);
}
return {
resource: result.entity,
};
} catch (err) {
handleError(reply, err);
}
}
async update(
request: FastifyRequest<{ Body: UpdateChannelMemberBody; Params: ChannelMemberParameters }>,
reply: FastifyReply,
): Promise<ResourceUpdateResponse<ChannelMember>> {
const entity = plainToClass(ChannelMember, {
...request.body.resource,
...this.getPrimaryKey(request),
});
if (!isCurrentUser(entity.user_id, request.currentUser)) {
throw CrudException.badRequest("Current user can not update this member");
}
try {
const result = await gr.services.channels.members.save(entity, getExecutionContext(request));
if (result.entity) {
reply.code(200);
}
return {
resource: result.entity,
};
} catch (err) {
handleError(reply, err);
}
}
async delete(
request: FastifyRequest<{ Params: ChannelMemberParameters }>,
reply: FastifyReply,
): Promise<ResourceDeleteResponse> {
try {
const deleteResult = await gr.services.channels.members.delete(
this.getPrimaryKey(request),
getExecutionContext(request),
);
if (deleteResult.deleted) {
reply.code(204);
return {
status: "success",
};
}
return {
status: "error",
};
} catch (err) {
handleError(reply, err);
}
}
/**
* List channel members
*
* @param request
*/
async list(
request: FastifyRequest<{
Querystring: PaginationQueryParameters & { company_role?: string; search?: string };
Params: ChannelParameters;
}>,
): Promise<ResourceListResponse<ChannelMemberWithUser>> {
let list: ChannelMember[] = [];
let nextPageToken: string = null;
const resources = [];
const context = getExecutionContext(request);
if (request.query.search) {
const users = await gr.services.users.search(
new Pagination(request.query.page_token, request.query.limit),
{
search: request.query.search,
companyId: request.params.company_id,
},
context,
);
nextPageToken = users.nextPage?.page_token;
for (const user of users.getEntities()) {
const channelMember = await gr.services.channels.members.getChannelMember(
user,
{
company_id: request.params.company_id,
workspace_id: request.params.workspace_id,
id: request.params.id,
},
undefined,
context,
);
if (channelMember) {
list.push(channelMember);
}
}
} else {
const channelMembers = await gr.services.channels.members.list(
new Pagination(request.query.page_token, request.query.limit),
{ company_role: request.query.company_role },
context,
);
nextPageToken = channelMembers.nextPage?.page_token;
list = channelMembers.getEntities();
}
for (const member of list) {
if (member) {
const user = await formatUser(await gr.services.users.getCached({ id: member.user_id }), {
includeCompanies: true,
});
resources.push({ ...member, user });
}
}
return {
...{
resources,
},
...(request.query.websockets && {
websockets: gr.platformServices.realtime.sign(
getChannelRooms(request.params, request.currentUser),
request.currentUser.id,
),
}),
next_page_token: nextPageToken,
};
}
/**
*
* @param request Private exists
* @param reply
*/
async exists(
request: FastifyRequest<{ Params: ChannelMemberParameters }>,
reply: FastifyReply,
): Promise<Response> {
const context = getExecutionContext(request);
const resource = await gr.services.channels.members.get(this.getPrimaryKey(request), context);
if (!resource) {
return reply.status(200).send({ has_access: false });
}
return reply.status(200).send({ has_access: true });
}
/**
* Lists the channel read sections for all members.
*
* @param {FastifyRequest<{ Params: ChannelParameters }>} request - the request object
* @param {FastifyReply} reply - the reply object
* @returns {Promise<ResourceListResponse<ChannelMemberReadCursors>>} - the response
*/
async getAllChannelMembersReadSections(
request: FastifyRequest<{ Params: ChannelParameters }>,
reply: FastifyReply,
): Promise<ResourceListResponse<ChannelMemberReadCursors>> {
try {
const context = getExecutionContext(request);
const channelMembersReadSections =
await gr.services.channels.members.getChannelMembersReadSections(context);
return {
resources: channelMembersReadSections.getEntities(),
};
} catch (err) {
handleError(reply, err);
}
}
/**
* Get the channel member read section for a user.
*
* @param {FastifyRequest<{ Params: ChannelMemberParameters }>} request - the request object
* @param {FastifyReply} reply - the reply object
* @returns {Promise<ResourceGetResponse<ChannelMemberReadCursors>>} - the read section for the member
*/
async getChannelMemberReadSections(
request: FastifyRequest<{ Params: ChannelMemberParameters }>,
reply: FastifyReply,
): Promise<ResourceGetResponse<ChannelMemberReadCursors>> {
try {
const { member_id } = request.params;
const context = getExecutionContext(request);
const channelMembersReadSections =
await gr.services.channels.members.getChannelMemberReadSections(member_id, context);
return {
resource: channelMembersReadSections,
};
} catch (err) {
handleError(reply, err);
}
}
}
function getExecutionContext(
request: FastifyRequest<{ Params: ChannelParameters }>,
): ChannelExecutionContext {
return {
user: request.currentUser,
url: request.url,
method: request.routerMethod,
reqId: request.id,
transport: "http",
channel: {
id: request.params.id,
company_id: request.params.company_id,
workspace_id: request.params.workspace_id,
},
};
}
function isCurrentUser(memberId: string, user: User): boolean {
return memberId && memberId === user.id;
}
@@ -1,204 +0,0 @@
import { CrudController } from "../../../../core/platform/services/webserver/types";
import { CrudException, Pagination } from "../../../../core/platform/framework/api/crud-service";
import { ChannelTab, ChannelTabPrimaryKey } from "../../entities";
import {
ChannelParameters,
ChannelTabParameters,
CreateChannelTabBody,
PaginationQueryParameters,
UpdateChannelTabBody,
} from "../types";
import { FastifyReply, FastifyRequest } from "fastify";
import { ChannelExecutionContext } from "../../types";
import { plainToClass } from "class-transformer";
import { handleError } from "../../../../utils/handleError";
import {
ResourceCreateResponse,
ResourceDeleteResponse,
ResourceEventsPayload,
ResourceGetResponse,
ResourceListResponse,
ResourceUpdateResponse,
} from "../../../../utils/types";
import { localEventBus } from "../../../../core/platform/framework/event-bus";
import { getTabsRealtimeRoom } from "../../services/tab";
import gr from "../../../global-resolver";
export class ChannelTabCrudController
implements
CrudController<
ResourceGetResponse<ChannelTab>,
ResourceCreateResponse<ChannelTab>,
ResourceListResponse<ChannelTab>,
ResourceDeleteResponse
>
{
getPrimaryKey(request: FastifyRequest<{ Params: ChannelTabParameters }>): ChannelTabPrimaryKey {
return {
id: request.params.tab_id,
channel_id: request.params.id,
company_id: request.params.company_id,
workspace_id: request.params.workspace_id,
};
}
async get(
request: FastifyRequest<{ Params: ChannelTabParameters }>,
_reply: FastifyReply,
): Promise<ResourceGetResponse<ChannelTab>> {
const resource = await gr.services.tab.get(
this.getPrimaryKey(request),
getExecutionContext(request),
);
if (!resource) {
throw CrudException.notFound(`Channel member ${request.params.tab_id} not found`);
}
return {
resource,
};
}
async save(
request: FastifyRequest<{ Body: CreateChannelTabBody; Params: ChannelTabParameters }>,
reply: FastifyReply,
): Promise<ResourceCreateResponse<ChannelTab>> {
const entity = plainToClass(ChannelTab, {
...request.body.resource,
...{
company_id: request.params.company_id,
workspace_id: request.params.workspace_id,
channel_id: request.params.id,
},
});
try {
const context = getExecutionContext(request);
const result = await gr.services.tab.save(entity, {}, context);
if (result.entity) {
localEventBus.publish<ResourceEventsPayload>("channel:tab:created", {
tab: result.entity,
actor: getExecutionContext(request).user,
resourcesAfter: [result.entity],
channelParameters: request.params,
user: context.user,
});
reply.code(201);
}
return {
resource: result.entity,
};
} catch (err) {
handleError(reply, err);
}
}
async update(
request: FastifyRequest<{ Body: UpdateChannelTabBody; Params: ChannelTabParameters }>,
reply: FastifyReply,
): Promise<ResourceUpdateResponse<ChannelTab>> {
const entity = plainToClass(ChannelTab, {
...request.body.resource,
...this.getPrimaryKey(request),
});
try {
const result = await gr.services.tab.save(entity, {}, getExecutionContext(request));
if (result.entity) {
reply.code(200);
}
return {
resource: result.entity,
};
} catch (err) {
handleError(reply, err);
}
}
async delete(
request: FastifyRequest<{ Params: ChannelTabParameters }>,
reply: FastifyReply,
): Promise<ResourceDeleteResponse> {
try {
const context = getExecutionContext(request);
const deleteResult = await gr.services.tab.delete(this.getPrimaryKey(request));
if (deleteResult.deleted) {
localEventBus.publish<ResourceEventsPayload>("channel:tab:deleted", {
tab: deleteResult.entity,
actor: getExecutionContext(request).user,
resourcesAfter: [deleteResult.entity],
channelParameters: request.params,
user: context.user,
});
reply.code(204);
return {
status: "success",
};
}
return {
status: "error",
};
} catch (err) {
handleError(reply, err);
}
}
/**
* List channel tabs
*
* @param request
*/
async list(
request: FastifyRequest<{
Querystring: PaginationQueryParameters;
Params: ChannelParameters;
}>,
): Promise<ResourceListResponse<ChannelTab>> {
const context = getExecutionContext(request);
const list = await gr.services.tab.list(
new Pagination(request.query.page_token, request.query.limit),
{},
context,
);
return {
...{
resources: list.getEntities(),
},
...(request.query.websockets && {
websockets: gr.platformServices.realtime.sign(
[{ room: getTabsRealtimeRoom(context.channel) }],
context.user.id,
),
}),
...(list.page_token && {
next_page_token: list.page_token,
}),
};
}
}
function getExecutionContext(
request: FastifyRequest<{ Params: ChannelParameters }>,
): ChannelExecutionContext {
return {
user: request.currentUser,
url: request.url,
method: request.routerMethod,
reqId: request.id,
transport: "http",
channel: {
id: request.params.id,
company_id: request.params.company_id,
workspace_id: request.params.workspace_id,
},
};
}
@@ -1,12 +0,0 @@
import { FastifyInstance, FastifyRegisterOptions } from "fastify";
import routes from "./routes";
export default (
fastify: FastifyInstance,
options: FastifyRegisterOptions<{
prefix: string;
}>,
): void => {
fastify.log.debug("Configuring /internal/services/channels/v1 routes");
fastify.register(routes, options);
};
@@ -1,12 +0,0 @@
import { validate as validateUuid } from "uuid";
import { ChannelVisibility } from "../types";
export function checkCompanyAndWorkspaceForUser(
companyId: string,
workspaceId: string,
): Promise<boolean> {
return Promise.resolve(
validateUuid(companyId) &&
(validateUuid(workspaceId) || workspaceId === ChannelVisibility.DIRECT),
);
}
@@ -1,270 +0,0 @@
import { FastifyInstance, FastifyPluginCallback } from "fastify";
import {
BaseChannelsParameters,
ChannelParameters,
PaginationQueryParameters,
RecentChannelsParameters,
} from "./types";
import {
createChannelMemberSchema,
createChannelSchema,
getChannelMemberSchema,
getChannelSchema,
updateChannelMemberSchema,
updateChannelSchema,
} from "./schemas";
import {
ChannelCrudController,
ChannelMemberCrudController,
ChannelTabCrudController,
} from "./controllers";
import { checkCompanyAndWorkspaceForUser } from "./middleware";
import { FastifyRequest } from "fastify/types/request";
import { checkUserBelongsToCompany } from "../../../utils/company";
const channelsUrl = "/companies/:company_id/workspaces/:workspace_id/channels";
const membersUrl = `${channelsUrl}/:id/members`;
const tabsUrl = `${channelsUrl}/:id/tabs`;
const pendingEmailsUrl = `${channelsUrl}/:channel_id/pending_emails`;
const routes: FastifyPluginCallback = (fastify: FastifyInstance, options, next) => {
const channelsController = new ChannelCrudController();
const membersController = new ChannelMemberCrudController();
const tabsController = new ChannelTabCrudController();
const accessControlCompanyOnly = async (
request: FastifyRequest<{ Params: RecentChannelsParameters }>,
) => {
await checkUserBelongsToCompany(request.currentUser.id, request.params.company_id);
};
const accessControl = async (request: FastifyRequest<{ Params: BaseChannelsParameters }>) => {
const authorized = await checkCompanyAndWorkspaceForUser(
request.params.company_id,
request.params.workspace_id,
);
if (!authorized) {
throw fastify.httpErrors.badRequest("Invalid company/workspace");
}
};
const validateQuery = async (
request: FastifyRequest<{
Params: BaseChannelsParameters;
Querystring: PaginationQueryParameters;
}>,
) => {
if (isNaN(+request.query.limit)) {
request.query.limit = "100";
}
};
// channels
fastify.route({
method: "GET",
url: channelsUrl,
preHandler: [accessControl, validateQuery],
preValidation: [fastify.authenticate],
handler: channelsController.list.bind(channelsController),
});
fastify.route({
method: "GET",
url: `${channelsUrl}/:id`,
preHandler: accessControl,
preValidation: [fastify.authenticate],
schema: getChannelSchema,
handler: channelsController.get.bind(channelsController),
});
fastify.route({
method: "GET",
url: `${channelsUrl}/:id/thumbnail`,
handler: channelsController.thumbnail.bind(channelsController),
});
fastify.route({
method: "POST",
url: channelsUrl,
preHandler: accessControl,
preValidation: [fastify.authenticate],
schema: createChannelSchema,
handler: channelsController.save.bind(channelsController),
});
fastify.route({
method: "POST",
url: `${channelsUrl}/:id`,
preHandler: accessControl,
preValidation: [fastify.authenticate],
schema: updateChannelSchema,
handler: channelsController.update.bind(channelsController),
});
fastify.route<{ Params: ChannelParameters }>({
method: "DELETE",
url: `${channelsUrl}/:id`,
preHandler: accessControl,
preValidation: [fastify.authenticate],
handler: channelsController.delete.bind(channelsController),
});
fastify.route<{ Params: ChannelParameters }>({
method: "POST",
url: `${channelsUrl}/:id/read`,
preHandler: accessControl,
preValidation: [fastify.authenticate],
handler: channelsController.updateRead.bind(channelsController),
});
fastify.route({
method: "GET",
url: "/companies/:company_id/search",
preValidation: [fastify.authenticate],
handler: channelsController.search.bind(channelsController),
});
fastify.route({
method: "GET",
url: "/companies/:company_id/channels/recent",
preHandler: accessControlCompanyOnly,
preValidation: [fastify.authenticate],
handler: channelsController.recent.bind(channelsController),
});
// members
fastify.route({
method: "GET",
url: membersUrl,
preHandler: accessControl,
preValidation: [fastify.authenticate],
handler: membersController.list.bind(membersController),
});
fastify.route({
method: "POST",
url: membersUrl,
preHandler: accessControl,
preValidation: [fastify.authenticate],
schema: createChannelMemberSchema,
handler: membersController.save.bind(membersController),
});
fastify.route({
method: "GET",
url: `${membersUrl}/:member_id`,
preHandler: accessControl,
preValidation: [fastify.authenticate],
schema: getChannelMemberSchema,
handler: membersController.get.bind(membersController),
});
fastify.route({
method: "POST",
url: `${membersUrl}/:member_id`,
preHandler: accessControl,
preValidation: [fastify.authenticate],
schema: updateChannelMemberSchema,
handler: membersController.update.bind(membersController),
});
fastify.route({
method: "DELETE",
url: `${membersUrl}/:member_id`,
preHandler: accessControl,
preValidation: [fastify.authenticate],
handler: membersController.delete.bind(membersController),
});
fastify.route({
method: "GET",
url: `${membersUrl}/read_sections`,
preHandler: accessControl,
preValidation: [fastify.authenticate],
handler: membersController.getAllChannelMembersReadSections.bind(membersController),
});
fastify.route({
method: "GET",
url: `${membersUrl}/:member_id/read_sections`,
preHandler: accessControl,
preValidation: [fastify.authenticate],
handler: membersController.getChannelMemberReadSections.bind(membersController),
});
// pending_emails
fastify.route({
method: "GET",
url: pendingEmailsUrl,
preHandler: [accessControl, validateQuery],
preValidation: [fastify.authenticate],
//schema: getChannelPendingEmailsSchema,
handler: channelsController.findPendingEmails.bind(channelsController),
});
fastify.route({
method: "POST",
url: pendingEmailsUrl,
preHandler: accessControl,
preValidation: [fastify.authenticate],
//schema: createChannelPendingEmailsSchema,
handler: channelsController.savePendingEmails.bind(channelsController),
});
fastify.route({
method: "DELETE",
url: `${pendingEmailsUrl}/:email`,
preHandler: accessControl,
preValidation: [fastify.authenticate],
handler: channelsController.deletePendingEmails.bind(channelsController),
});
// tabs
fastify.route({
method: "GET",
url: tabsUrl,
preHandler: accessControl,
preValidation: [fastify.authenticate],
handler: tabsController.list.bind(tabsController),
});
fastify.route({
method: "POST",
url: tabsUrl,
preHandler: accessControl,
preValidation: [fastify.authenticate],
handler: tabsController.save.bind(tabsController),
});
fastify.route({
method: "GET",
url: `${tabsUrl}/:tab_id`,
preHandler: accessControl,
preValidation: [fastify.authenticate],
handler: tabsController.get.bind(tabsController),
});
fastify.route({
method: "POST",
url: `${tabsUrl}/:tab_id`,
preHandler: accessControl,
preValidation: [fastify.authenticate],
handler: tabsController.update.bind(tabsController),
});
fastify.route({
method: "DELETE",
url: `${tabsUrl}/:tab_id`,
preHandler: accessControl,
preValidation: [fastify.authenticate],
handler: tabsController.delete.bind(tabsController),
});
next();
};
export default routes;
@@ -1,301 +0,0 @@
import { webSocketSchema } from "../../../utils/types";
const channelMemberSchema = {
type: "object",
properties: {
id: { type: "string" },
user_id: { type: "string" },
channel_id: { type: "string" },
company_id: { type: "string" },
workspace_id: { type: "string" },
type: { type: "string", enum: ["member", "guest", "bot"] },
favorite: { type: "boolean" },
last_access: { type: "number" },
last_increment: { type: "number" },
notification_level: {
type: "string",
enum: ["all", "none", "group_mentions", "user_mentions"],
},
},
};
const channelSchema = {
type: "object",
properties: {
id: { type: "string" },
company_id: { type: "string" },
workspace_id: { type: "string" },
type: { type: "string", enum: ["workspace", "direct"] },
name: { type: "string" },
description: { type: "string" },
archivation_date: { type: "number" },
archived: { type: "boolean" },
channel_group: { type: "string" },
connectors: { type: ["array", "null"] },
icon: { type: "string" },
default: { type: "boolean" },
members: { type: ["array", "null"] },
users: { type: ["array", "null"] },
owner: { type: "string" },
visibility: { type: "string", enum: ["private", "public", "direct"] },
last_activity: { type: "number" },
user_member: {},
direct_channel_members: { type: "array" },
stats: {},
is_readonly: { type: "boolean" },
},
};
const channelTabSchema = {
type: "object",
properties: {
company_id: { type: "string" },
workspace_id: { type: "string" },
channel_id: { type: "string" },
id: { type: "string" },
owner: { type: "string" },
order: { type: "string" },
configuration: {
type: "object",
properties: {
board_id: { type: "string" },
directory_id: { type: "string" },
},
},
application_id: { type: "string" },
name: { type: "string" },
},
};
export const createChannelSchema = {
body: {
type: "object",
properties: {
options: {
type: "object",
properties: {
members: { type: "array" },
},
},
resource: {
type: "object",
},
},
required: ["resource"],
},
response: {
201: {
type: "object",
properties: {
websocket: webSocketSchema,
resource: channelSchema,
},
required: ["resource"],
},
},
};
export const updateChannelSchema = {
body: {
type: "object",
properties: {
resource: {
type: "object",
},
},
required: ["resource"],
},
response: {
200: {
type: "object",
properties: {
websocket: webSocketSchema,
resource: channelSchema,
},
required: ["resource"],
},
},
};
export const getChannelSchema = {
request: {
properties: {
company_id: { type: "string" },
workspace_id: { type: "string" },
},
required: ["company_id", "workspace_id"],
},
response: {
200: {
type: "object",
properties: {
websocket: webSocketSchema,
resource: channelSchema,
},
},
},
};
export const getChannelMemberSchema = {
response: {
200: {
type: "object",
properties: {
websocket: webSocketSchema,
resource: channelMemberSchema,
},
required: ["resource"],
},
},
};
export const createChannelMemberSchema = {
body: {
type: "object",
properties: {
resource: channelMemberSchema,
},
required: ["resource"],
},
response: {
201: {
type: "object",
properties: {
websocket: webSocketSchema,
resource: channelMemberSchema,
},
required: ["resource"],
},
},
};
export const updateChannelMemberSchema = {
body: {
type: "object",
properties: {
resource: {
type: "object",
properties: {
favorite: { type: "boolean" },
notification_level: { type: "string" },
},
},
},
required: ["resource"],
},
response: {
200: {
type: "object",
properties: {
websocket: webSocketSchema,
resource: channelMemberSchema,
},
required: ["resource"],
},
},
};
export const getChannelTabSchema = {
response: {
200: {
type: "object",
properties: {
websocket: webSocketSchema,
resource: channelTabSchema,
},
required: ["resource"],
},
},
};
export const createChannelTabSchema = {
body: {
type: "object",
properties: {
resource: {
type: "object",
properties: {},
},
},
required: ["resource"],
},
response: {
201: {
type: "object",
properties: {
websocket: webSocketSchema,
resource: channelTabSchema,
},
required: ["resource"],
},
},
};
export const updateChannelTabSchema = {
body: {
type: "object",
properties: {
resource: {
type: "object",
properties: {},
},
},
required: ["resource"],
},
response: {
200: {
type: "object",
properties: {
websocket: webSocketSchema,
resource: channelTabSchema,
},
required: ["resource"],
},
},
};
const channelPendingEmailsSchema = {
type: "object",
properties: {
company_id: { type: "string" },
workspace_id: { type: "string" },
channel_id: { type: "string" },
email: { type: "string" },
},
};
export const getChannelPendingEmailsSchema = {
response: {
200: {
type: "object",
properties: {
websocket: webSocketSchema,
resource: channelPendingEmailsSchema,
},
required: ["resource"],
},
},
};
export const createChannelPendingEmailsSchema = {
body: {
type: "object",
properties: {
resource: {
type: "object",
properties: {},
},
},
required: ["resource"],
},
response: {
201: {
type: "object",
properties: {
websocket: webSocketSchema,
resource: channelPendingEmailsSchema,
},
required: ["resource"],
},
},
};
@@ -1,117 +0,0 @@
import { Channel, ChannelMember, ChannelPendingEmails, ChannelTab } from "../entities";
export declare type DirectChannel = "direct";
export interface RecentChannelsParameters {
company_id: string;
}
export interface BaseChannelsParameters {
company_id: string;
workspace_id: string | DirectChannel;
}
export interface ChannelParameters extends BaseChannelsParameters {
/** the channel id */
id: string;
}
export interface ChannelMemberParameters extends ChannelParameters {
member_id: string;
}
export interface PaginationQueryParameters {
page_token?: string;
limit?: string;
websockets?: boolean;
}
export interface ChannelListQueryParameters extends PaginationQueryParameters {
include_users?: string;
search_query?: string;
mine?: boolean;
}
export interface ChannelSearchQueryParameters extends PaginationQueryParameters {
q: string;
}
export class CreateChannelBody {
options?: ChannelCreateOptions;
resource: ChannelCreateResource;
}
export class ChannelCreateOptions {
/**
* Members to add to channel in case of direct channel
*/
members?: string[];
}
export type ChannelSaveOptions = ChannelCreateOptions & {
/**
* Add the channel creator as member.
*/
addCreatorAsMember?: boolean;
};
export class ChannelListOptions {
channels?: string[];
mine?: boolean;
company_role?: string;
user_id?: string;
}
export class UpdateChannelBody {
resource: ChannelUpdateResource;
}
export class ReadChannelBody {
value: boolean;
}
export type ChannelCreateResource = Pick<Channel, "name">;
export type ChannelUpdateResource = Pick<Channel, "name">;
// channel members
export class CreateChannelMemberBody {
resource: Pick<ChannelMember, "user_id">;
}
export class UpdateChannelMemberBody {
resource: Pick<ChannelMember, "favorite" | "notification_level">;
}
export type ChannelMemberSaveOptions = null;
// channel tabs
export interface ChannelTabParameters extends ChannelParameters {
tab_id: string;
}
export class CreateChannelTabBody {
resource: ChannelTab;
}
export class UpdateChannelTabBody {
resource: ChannelTab;
}
export type ChannelTabSaveOptions = {
resource: ChannelTab;
};
export interface ChannelPendingEmailsParameters extends BaseChannelsParameters {
channel_id: string;
}
export interface ChannelPendingEmailsDeleteQueryParameters extends ChannelPendingEmailsParameters {
email: ChannelPendingEmails["email"];
}
export interface ChannelPendingEmailsListQueryParameters extends BaseChannelsParameters {
channel_id: string;
}
@@ -14,7 +14,6 @@ import { PushServiceAPI } from "../core/platform/services/push/api";
import { CronAPI } from "../core/platform/services/cron/api";
import WebSocketAPI from "../core/platform/services/websocket/provider";
import TrackerAPI from "../core/platform/services/tracker/provider";
import KnowledgeGraphService from "../core/platform/services/knowledge-graph";
import EmailPusherAPI from "../core/platform/services/email-pusher/provider";
import { logger } from "../core/platform/framework";
@@ -22,26 +21,14 @@ import assert from "assert";
import { CompanyServiceImpl } from "./user/services/companies";
import { WorkspaceServiceImpl } from "./workspaces/services/workspace";
import { UserExternalLinksServiceImpl } from "./user/services/external_links";
import { UserNotificationBadgeService } from "./notifications/services/bages";
import { NotificationPreferencesService } from "./notifications/services/preferences";
import { UserServiceImpl } from "./user/services/users/service";
import { CompanyApplicationServiceImpl } from "./applications/services/company-applications";
import { ApplicationServiceImpl } from "./applications/services/applications";
import { FileServiceImpl } from "./files/services";
import { ChannelServiceImpl } from "./channels/services/channel/service";
import { MemberServiceImpl } from "./channels/services/member/service";
import ChannelPendingEmailServiceImpl from "./channels/services/channel/pending-emails/service";
import { TabServiceImpl } from "./channels/services/tab";
import { ConsoleServiceImpl } from "./console/service";
import { StatisticsServiceImpl } from "./statistics/service";
import { NotificationEngine } from "./notifications/services/engine";
import { MobilePushService } from "./notifications/services/mobile-push";
import { ChannelMemberPreferencesServiceImpl } from "./notifications/services/channel-preferences";
import { ChannelThreadUsersServiceImpl } from "./notifications/services/channel-thread-users";
import { ApplicationHooksService } from "./applications/services/hooks";
import OnlineServiceImpl from "./online/service";
import { ChannelsMessageQueueListener } from "./channels/services/pubsub";
import { UserNotificationDigestService } from "./notifications/services/digest";
import { DocumentsService } from "./documents/services";
import { DocumentsEngine } from "./documents/services/engine";
import { TagsService } from "./tags/services/tags";
@@ -58,7 +45,6 @@ type PlatformServices = {
tracker: TrackerAPI;
webserver: WebServerAPI;
websocket: WebSocketAPI;
knowledgeGraph: KnowledgeGraphService;
emailPusher: EmailPusherAPI;
};
@@ -69,28 +55,12 @@ type TdriveServices = {
console: ConsoleServiceImpl;
statistics: StatisticsServiceImpl;
externalUser: UserExternalLinksServiceImpl;
notifications: {
badges: UserNotificationBadgeService;
channelPreferences: ChannelMemberPreferencesServiceImpl;
channelThreads: ChannelThreadUsersServiceImpl;
engine: NotificationEngine;
preferences: NotificationPreferencesService;
mobilePush: MobilePushService;
digest: UserNotificationDigestService;
};
applications: {
marketplaceApps: ApplicationServiceImpl;
companyApps: CompanyApplicationServiceImpl;
hooks: ApplicationHooksService;
};
files: FileServiceImpl;
channels: {
channels: ChannelServiceImpl;
members: MemberServiceImpl;
pubsub: ChannelsMessageQueueListener;
};
channelPendingEmail: ChannelPendingEmailServiceImpl;
tab: TabServiceImpl;
online: OnlineServiceImpl;
documents: {
documents: DocumentsService;
@@ -126,7 +96,6 @@ class GlobalResolver {
tracker: platform.getProvider<TrackerAPI>("tracker"),
webserver: platform.getProvider<WebServerAPI>("webserver"),
websocket: platform.getProvider<WebSocketAPI>("websocket"),
knowledgeGraph: await new KnowledgeGraphService().init(),
emailPusher: platform.getProvider<EmailPusherAPI>("email-pusher"),
};
@@ -144,28 +113,12 @@ class GlobalResolver {
console: await new ConsoleServiceImpl().init(),
statistics: await new StatisticsServiceImpl().init(),
externalUser: await new UserExternalLinksServiceImpl().init(),
notifications: {
badges: await new UserNotificationBadgeService().init(platform),
channelPreferences: await new ChannelMemberPreferencesServiceImpl().init(),
channelThreads: await new ChannelThreadUsersServiceImpl().init(),
engine: await new NotificationEngine().init(),
preferences: await new NotificationPreferencesService().init(),
mobilePush: await new MobilePushService().init(),
digest: await new UserNotificationDigestService().init(),
},
applications: {
marketplaceApps: await new ApplicationServiceImpl().init(),
companyApps: await new CompanyApplicationServiceImpl().init(),
hooks: await new ApplicationHooksService().init(),
},
files: await new FileServiceImpl().init(),
channels: {
channels: await new ChannelServiceImpl().init(),
members: await new MemberServiceImpl().init(),
pubsub: await new ChannelsMessageQueueListener().init(),
},
channelPendingEmail: await new ChannelPendingEmailServiceImpl().init(),
tab: await new TabServiceImpl().init(),
online: await new OnlineServiceImpl().init(),
documents: {
documents: await new DocumentsService().init(),
@@ -1,25 +0,0 @@
import { Consumes, Prefix, TdriveService } from "../../core/platform/framework";
import WebServerAPI from "../../core/platform/services/webserver/provider";
import web from "./web/index";
@Prefix("/internal/services/knowledge-graph/v1")
@Consumes(["webserver"])
export default class MessageService extends TdriveService<undefined> {
version = "1";
name = "knowledge-graph";
api(): undefined {
return undefined;
}
public async doInit(): Promise<this> {
const fastify = this.context.getProvider<WebServerAPI>("webserver").getServer();
fastify.register((instance, _opts, next) => {
web(instance, { prefix: this.prefix });
next();
});
return this;
}
}
@@ -1,12 +0,0 @@
import { FastifyInstance, FastifyRegisterOptions } from "fastify";
import routes from "./routes";
export default (
fastify: FastifyInstance,
options: FastifyRegisterOptions<{
prefix: string;
}>,
): void => {
fastify.log.debug("Configuring /internal/services/knowledge-graph/v1 routes");
fastify.register(routes, options);
};
@@ -1,41 +0,0 @@
import { FastifyInstance, FastifyPluginCallback, FastifyReply, FastifyRequest } from "fastify";
import { KnowledgeGraphCallbackEvent } from "../../../core/platform/services/knowledge-graph/types";
import gr from "../../global-resolver";
const routes: FastifyPluginCallback = (fastify: FastifyInstance, _, next) => {
fastify.route({
method: "POST",
url: "/push",
handler: async (
request: FastifyRequest<{
Body: {
events: KnowledgeGraphCallbackEvent[];
};
}>,
reply: FastifyReply,
): Promise<any> => {
try {
for (let i = 0; i < request.body.events.length; i++) {
await gr.platformServices.knowledgeGraph.onCallbackEvent(
request.headers.authorization.split(" ")[1],
request.body.events[i],
);
}
} catch (e) {
reply.status(500).send({
status: "error",
error: "Internal server error",
message: e.message || "Unknown error",
});
}
reply.status(200).send({
status: "success",
});
},
});
next();
};
export default routes;
@@ -1,50 +0,0 @@
import { Type } from "class-transformer";
import { merge } from "lodash";
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
import { ChannelMemberNotificationLevel } from "../../../services/channels/types";
export const TYPE = "channel_members_notification_preferences";
@Entity(TYPE, {
primaryKey: [["company_id", "channel_id"], "user_id"],
type: TYPE,
})
export class ChannelMemberNotificationPreference {
/**
* Primary key / Partition key
*/
@Type(() => String)
@Column("user_id", "uuid")
user_id: string;
/**
* Primary key
*/
@Type(() => String)
@Column("company_id", "uuid")
company_id: string;
/**
* Primary key
*/
@Type(() => String)
@Column("channel_id", "uuid")
channel_id: string;
@Type(() => String)
@Column("preferences", "string")
preferences: ChannelMemberNotificationLevel = ChannelMemberNotificationLevel.ALL;
@Column("last_read", "number")
last_read = 0;
}
export type ChannelMemberNotificationPreferencePrimaryKey = Pick<
ChannelMemberNotificationPreference,
"company_id" | "channel_id" | "user_id"
>;
export function getInstance(
preference: ChannelMemberNotificationPreference,
): ChannelMemberNotificationPreference {
return merge(new ChannelMemberNotificationPreference(), preference);
}
@@ -1,50 +0,0 @@
import { Type } from "class-transformer";
import { merge } from "lodash";
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
export const TYPE = "channel_thread_users";
@Entity(TYPE, {
primaryKey: [["company_id", "channel_id", "thread_id"], "user_id"],
type: TYPE,
})
export class ChannelThreadUsers {
/**
* UUIDv4
* Primary key / partition key
*/
@Type(() => String)
@Column("company_id", "uuid")
company_id: string;
/**
* UUIDv4
* Primary key / partition key
*/
@Type(() => String)
@Column("channel_id", "uuid")
channel_id: string;
/**
* UUIDv4
* Primary key / partition key
*/
@Type(() => String)
@Column("thread_id", "uuid")
thread_id: string;
/**
* UUIDv4
*/
@Type(() => String)
@Column("user_id", "uuid")
user_id: string;
}
export type ChannelThreadUsersPrimaryKey = Pick<
ChannelThreadUsers,
"company_id" | "channel_id" | "thread_id"
>;
export function getInstance(element: ChannelThreadUsers): ChannelThreadUsers {
return merge(new ChannelThreadUsers(), element);
}
@@ -1,20 +0,0 @@
export {
ChannelMemberNotificationPreference,
ChannelMemberNotificationPreferencePrimaryKey,
TYPE as ChannelMemberNotificationPreferenceType,
getInstance as getNotificationPreferenceInstance,
} from "./channel-member-notification-preferences";
export {
ChannelThreadUsers,
ChannelThreadUsersPrimaryKey,
TYPE as ChannelThreadUsersType,
getInstance as getChannelThreadUsersInstance,
} from "./channel-thread-users";
export {
UserNotificationBadge,
UserNotificationBadgePrimaryKey,
TYPE as UserNotificationBadgeType,
getInstance as getUserNotificationBadgeInstance,
} from "./user-notification-badges";
@@ -1,78 +0,0 @@
import { Type } from "class-transformer";
import { ChannelType } from "../../../utils/types";
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
import { v4 as uuidv4 } from "uuid";
import { merge } from "lodash";
export const TYPE = "user_notification_badges";
/**
* Table user-notification-badges
*/
@Entity(TYPE, {
primaryKey: [["company_id"], "user_id", "workspace_id", "channel_id", "thread_id"],
type: TYPE,
})
export class UserNotificationBadge {
/**
* UUIDv4
* Primary key / partition key
*/
@Type(() => String)
@Column("company_id", "uuid")
company_id: string;
/**
* UUIDv4
*/
@Type(() => String)
@Column("user_id", "uuid")
user_id: string;
/**
* Text
* Primary key
*/
@Type(() => String)
@Column("workspace_id", "string")
workspace_id: string | ChannelType.DIRECT;
/**
* UUIDv4
* Primary key
*/
@Type(() => String)
@Column("channel_id", "uuid")
channel_id: string;
/**
* UUIDv4
* Primary key
*/
@Type(() => String)
@Column("thread_id", "string") //It can be null
thread_id: string;
@Type(() => String)
@Column("message_id", "string") //It can be null
message_id: string;
/**
* UUIDv4
* Only used because crud entities must always have an id
*/
@Type(() => String)
@Column("id", "uuid")
id: string = uuidv4();
@Column("mention_type", "string")
mention_type: "reply" | "global" | "me" | "unread" | null = "me";
}
export type UserNotificationBadgePrimaryKey = Pick<
UserNotificationBadge,
"user_id" | "company_id" | "workspace_id" | "channel_id" | "thread_id"
>;
export function getInstance(badge: UserNotificationBadge): UserNotificationBadge {
return merge(new UserNotificationBadge(), badge);
}
@@ -1,43 +0,0 @@
import { Type } from "class-transformer";
import { merge } from "lodash";
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
export const TYPE = "user_notification_digest";
/**
* Table user-notification-digest
*/
@Entity(TYPE, {
primaryKey: [["company_id"], "user_id"],
type: TYPE,
})
export class UserNotificationDigest {
/**
* UUIDv4
* Primary key / partition key
*/
@Type(() => String)
@Column("company_id", "uuid")
company_id: string;
/**
* UUIDv4
*/
@Type(() => String)
@Column("user_id", "uuid")
user_id: string;
@Column("created_at", "number")
created_at: number;
@Column("deliver_at", "number")
deliver_at: number;
}
export type UserNotificationDigestPrimaryKey = Pick<
UserNotificationDigest,
"user_id" | "company_id"
>;
export function getInstance(digest: UserNotificationDigest): UserNotificationDigest {
return merge(new UserNotificationDigest(), digest);
}
@@ -1,22 +0,0 @@
import { Prefix, TdriveService } from "../../core/platform/framework";
import WebServerAPI from "../../core/platform/services/webserver/provider";
import web from "./web";
@Prefix("/internal/services/notifications/v1")
export default class NotificationService extends TdriveService<undefined> {
version = "1";
name = "notifications";
public async doInit(): Promise<this> {
const fastify = this.context.getProvider<WebServerAPI>("webserver").getServer();
fastify.register((instance, _opts, next) => {
web(instance, { prefix: this.prefix });
next();
});
return this;
}
// TODO: remove
api(): undefined {
return undefined;
}
}
@@ -1,5 +0,0 @@
export * from "./mobile-push";
export interface Notifier {
notify<Message>(user: string, message: Message): Promise<void>;
}
@@ -1,32 +0,0 @@
import { logger } from "../../../core/platform/framework";
import { MessageQueueServiceAPI } from "../../../core/platform/services/message-queue/api";
import { Notifier } from ".";
const TOPIC = "notification:push:mobile";
export class MobilePushNotifier implements Notifier {
private static instance: MobilePushNotifier;
static get(messageQueue: MessageQueueServiceAPI): Notifier {
if (!MobilePushNotifier.instance) {
MobilePushNotifier.instance = new MobilePushNotifier(messageQueue);
}
return MobilePushNotifier.instance;
}
private constructor(private messageQueue: MessageQueueServiceAPI) {}
async notify<Message>(user: string, message: Message): Promise<void> {
logger.info(`MobilePushNotifier - Push to mobile ${user}`);
logger.debug(`MobilePushNotifier - Push to mobile ${user}, ${JSON.stringify(message)}`);
try {
await this.messageQueue.publish<Message>(TOPIC, {
data: message,
});
} catch (err) {
logger.warn({ err }, `MobilePushNotifier - Error while sending notification to user ${user}`);
}
}
}
@@ -1,261 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import {
Initializable,
RealtimeDeleted,
RealtimeSaved,
TdriveContext,
TdriveServiceProvider,
} from "../../../core/platform/framework";
import { ResourcePath } from "../../../core/platform/services/realtime/types";
import {
CrudException,
DeleteResult,
ExecutionContext,
ListResult,
OperationType,
Pagination,
SaveResult,
} from "../../../core/platform/framework/api/crud-service";
import {
getUserNotificationBadgeInstance,
UserNotificationBadge,
UserNotificationBadgePrimaryKey,
UserNotificationBadgeType,
} from "../entities";
import { NotificationExecutionContext } from "../types";
import { getNotificationRoomName } from "./realtime";
import Repository from "../../../core/platform/services/database/services/orm/repository/repository";
import gr from "../../global-resolver";
import _, { pick, uniq } from "lodash";
export class UserNotificationBadgeService implements TdriveServiceProvider, Initializable {
version: "1";
repository: Repository<UserNotificationBadge>;
async init(context: TdriveContext): Promise<this> {
this.repository = await gr.database.getRepository<UserNotificationBadge>(
UserNotificationBadgeType,
UserNotificationBadge,
);
return this;
}
async get(
pk: UserNotificationBadgePrimaryKey,
context: ExecutionContext,
): Promise<UserNotificationBadge> {
return await this.repository.findOne(pk, {}, context);
}
@RealtimeSaved<UserNotificationBadge>((badge, context) => {
return [
{
room: ResourcePath.get(getNotificationRoomName(badge.user_id)),
},
];
})
async save<SaveOptions>(
badge: UserNotificationBadge,
context: ExecutionContext,
): Promise<SaveResult<UserNotificationBadge>> {
//Initiate the digest
await gr.services.notifications.digest.putBadge(badge);
await this.repository.save(getUserNotificationBadgeInstance(badge), context);
return new SaveResult(UserNotificationBadgeType, badge, OperationType.CREATE);
}
@RealtimeDeleted<UserNotificationBadge>((badge, context) => {
return [
{
room: ResourcePath.get(getNotificationRoomName(badge.user_id)),
},
];
})
async delete(
pk: UserNotificationBadgePrimaryKey,
context?: NotificationExecutionContext,
): Promise<DeleteResult<UserNotificationBadge>> {
//Cancel the current digest as we just read the badges
await gr.services.notifications.digest.cancelDigest(pk.company_id, pk.user_id);
await this.repository.remove(pk as UserNotificationBadge, context);
return new DeleteResult(UserNotificationBadgeType, pk as UserNotificationBadge, true);
}
list(): Promise<ListResult<UserNotificationBadge>> {
throw new Error("Not implemented");
}
async listForUserPerCompanies(
user_id: string,
context: ExecutionContext,
): Promise<ListResult<UserNotificationBadge>> {
//We remove all badge from current company as next block will create dupicates
const companies_ids = (await gr.services.companies.getAllForUser(user_id)).map(
gu => gu.group_id,
);
let result: UserNotificationBadge[] = [];
let type = "";
for (const company_id of companies_ids) {
const find = await this.repository.find(
{
company_id,
user_id,
},
{
pagination: new Pagination("", "1"),
},
context,
);
type = find.type;
result = result.concat(find.getEntities());
}
const badges = new ListResult(type, result);
await this.ensureBadgesAreReachable(badges, context);
return badges;
}
async listForUser(
company_id: string,
user_id: string,
filter: Pick<UserNotificationBadgePrimaryKey, "workspace_id" | "channel_id" | "thread_id">,
context?: ExecutionContext,
): Promise<ListResult<UserNotificationBadge>> {
if (!company_id || !user_id) {
throw CrudException.badRequest("company_id and user_id are required");
}
//Cancel the current digest as we just read the badges
await gr.services.notifications.digest.cancelDigest(company_id, user_id);
const badges = await this.repository.find(
{
...{
company_id,
user_id,
},
...pick(filter, ["workspace_id", "channel_id", "thread_id"]),
},
{},
context,
);
await this.ensureBadgesAreReachable(badges, context);
return badges;
}
// This will ensure we are still in the channels and if not, we'll remove the badge
// We need to also ensure more than that
// - Are we in the workspace?
// - Are we in the company?
async ensureBadgesAreReachable(
badges: ListResult<UserNotificationBadge>,
context?: ExecutionContext,
): Promise<ListResult<UserNotificationBadge>> {
if (badges.getEntities().length === 0) {
return badges;
}
const userId = badges.getEntities()[0].user_id;
const channels = uniq(badges.getEntities().map(r => r.channel_id));
for (const channelId of channels) {
const someBadge = badges.getEntities().find(b => b.channel_id === channelId);
const channelMemberPk = {
company_id: someBadge.company_id,
workspace_id: someBadge.workspace_id,
channel_id: channelId,
user_id: userId,
};
const context = {
user: { id: channelMemberPk.user_id, server_request: true },
channel: { id: channelId, ...channelMemberPk },
};
const exists =
(await gr.services.channels.channels.get(
{
id: channelId,
..._.pick(channelMemberPk, "company_id", "workspace_id"),
},
context,
)) && (await gr.services.channels.members.get(channelMemberPk, context));
if (!exists) {
for (const badge of badges.getEntities()) {
if (badge.channel_id === channelId) this.removeUserChannelBadges(badge, context);
}
badges.filterEntities(b => b.channel_id !== channelId);
}
}
const badgePerWorkspace = _.uniqBy(badges.getEntities(), r => r.workspace_id);
for (const badge of badgePerWorkspace) {
const workspaceId = badge.workspace_id;
const companyId = badge.company_id;
if (!workspaceId || workspaceId === "direct") {
continue;
}
try {
const exists =
(await gr.services.workspaces.get({
id: workspaceId,
company_id: companyId,
})) &&
(await gr.services.workspaces.getUser({
workspaceId,
userId,
}));
if (!exists) {
await gr.services.channels.members.ensureUserNotInWorkspaceIsNotInChannel(
{ id: userId },
{ id: workspaceId, company_id: companyId },
context,
);
for (const badge of badges.getEntities()) {
if (badge.workspace_id === workspaceId) this.removeUserChannelBadges(badge, context);
}
badges.filterEntities(b => b.workspace_id !== workspaceId);
}
} catch (e) {}
}
return badges;
}
/**
* FIXME: This is a temporary implementation which is sending as many websocket notifications as there are badges to remove
* A better implementation will be to do a bulk delete and have a single websocket notification event
* @param filter
* @param context
*/
async removeUserChannelBadges(
filter: Pick<
UserNotificationBadgePrimaryKey,
"workspace_id" | "company_id" | "channel_id" | "user_id"
>,
context?: ExecutionContext,
): Promise<number> {
const badges = (
await this.repository.find(
_.pick(filter, ["workspace_id", "company_id", "channel_id", "user_id"]),
{},
context,
)
).getEntities();
return (
await Promise.all(
badges.map(async badge => {
try {
return (await this.delete(badge)).deleted;
} catch (err) {}
}),
)
).filter(Boolean).length;
}
}
@@ -1,157 +0,0 @@
import _ from "lodash";
import {
DeleteResult,
ExecutionContext,
ListResult,
OperationType,
SaveResult,
} from "../../../core/platform/framework/api/crud-service";
import {
ChannelMemberNotificationPreference,
ChannelMemberNotificationPreferencePrimaryKey,
} from "../entities";
import Repository from "../../../core/platform/services/database/services/orm/repository/repository";
import { Initializable, logger, TdriveServiceProvider } from "../../../core/platform/framework";
import gr from "../../global-resolver";
const TYPE = "channel_members_notification_preferences";
export class ChannelMemberPreferencesServiceImpl implements TdriveServiceProvider, Initializable {
version: "1";
repository: Repository<ChannelMemberNotificationPreference>;
async init(): Promise<this> {
this.repository = await gr.database.getRepository<ChannelMemberNotificationPreference>(
TYPE,
ChannelMemberNotificationPreference,
);
return this;
}
async save(
entity: ChannelMemberNotificationPreference,
context: ExecutionContext,
): Promise<SaveResult<ChannelMemberNotificationPreference>> {
const pk: ChannelMemberNotificationPreferencePrimaryKey = {
user_id: entity.user_id,
company_id: entity.company_id,
channel_id: entity.channel_id,
};
let preference = await this.repository.findOne(pk, {}, context);
if (!preference) {
preference = new ChannelMemberNotificationPreference();
preference = _.merge(entity, pk);
}
preference = _.merge(preference, entity);
await this.repository.save(preference, context);
return new SaveResult(TYPE, preference, OperationType.CREATE);
}
async get(
pk: ChannelMemberNotificationPreferencePrimaryKey,
context: ExecutionContext,
): Promise<ChannelMemberNotificationPreference> {
return await this.repository.findOne(pk, {}, context);
}
async delete(
pk: ChannelMemberNotificationPreferencePrimaryKey,
context?: ExecutionContext,
): Promise<DeleteResult<ChannelMemberNotificationPreference>> {
await this.repository.remove(pk as ChannelMemberNotificationPreference, context);
return new DeleteResult(TYPE, pk as ChannelMemberNotificationPreference, true);
}
list(): Promise<ListResult<ChannelMemberNotificationPreference>> {
throw new Error("Not implemented");
}
async getChannelPreferencesForUsers(
channelAndCompany: Pick<
ChannelMemberNotificationPreferencePrimaryKey,
"channel_id" | "company_id"
>,
users?: string[] | null,
lastRead?: {
lessThan: number;
},
context?: ExecutionContext,
): Promise<ListResult<ChannelMemberNotificationPreference>> {
logger.debug(
`ChannelMemberPreferenceService - Get Channel preferences for users ${JSON.stringify(
users,
)} with lastRead < ${lastRead?.lessThan}`,
);
const result = await this.repository.find(
users ? { ...channelAndCompany, ...{ user_id: users } } : channelAndCompany,
{},
context,
);
if (result.getEntities().length > 0 && lastRead && lastRead.lessThan) {
result.filterEntities(entity => entity.last_read < lastRead.lessThan);
}
logger.debug(
`ChannelMemberPreferenceService - Result ${JSON.stringify(
result.getEntities().map(preference => preference.user_id),
)}`,
);
return result;
}
async getChannelPreferencesForUsersFilteredByReadTime(
channelAndCompany: Pick<
ChannelMemberNotificationPreferencePrimaryKey,
"channel_id" | "company_id"
>,
users: string[] = [],
lastRead: number,
context?: ExecutionContext,
): Promise<ListResult<ChannelMemberNotificationPreference>> {
const result = await this.repository.find(
{ ...channelAndCompany, ...{ user_id: users } },
{},
context,
);
if (result.getEntities().length > 0 && lastRead) {
result.filterEntities(entity => entity.last_read < lastRead);
}
return result;
}
async updateLastRead(
channelAndCompany: Pick<ChannelMemberNotificationPreference, "channel_id" | "company_id">,
user_id: string,
lastRead: number,
context?: ExecutionContext,
): Promise<ChannelMemberNotificationPreference> {
const pk: ChannelMemberNotificationPreferencePrimaryKey = {
user_id,
company_id: channelAndCompany.company_id,
channel_id: channelAndCompany.channel_id,
};
const preference = await this.repository.findOne(pk, {}, context);
if (!preference) {
return;
}
preference.last_read = lastRead;
await this.repository.save(preference, context);
return preference;
}
}
@@ -1,68 +0,0 @@
import {
DeleteResult,
ExecutionContext,
ListResult,
OperationType,
SaveResult,
} from "../../../core/platform/framework/api/crud-service";
import {
ChannelThreadUsers,
ChannelThreadUsersPrimaryKey,
ChannelThreadUsersType,
} from "../entities";
import Repository from "../../../core/platform/services/database/services/orm/repository/repository";
import gr from "../../global-resolver";
import { Initializable, TdriveServiceProvider } from "../../../core/platform/framework";
export class ChannelThreadUsersServiceImpl implements TdriveServiceProvider, Initializable {
version: "1";
repository: Repository<ChannelThreadUsers>;
async init(): Promise<this> {
this.repository = await gr.database.getRepository<ChannelThreadUsers>(
ChannelThreadUsersType,
ChannelThreadUsers,
);
return this;
}
async bulkSave(
entities: ChannelThreadUsers[],
context: ExecutionContext,
): Promise<SaveResult<ChannelThreadUsers[]>> {
await this.repository.saveAll(entities, context);
return new SaveResult(ChannelThreadUsersType, entities, OperationType.CREATE);
}
async get(
pk: ChannelThreadUsersPrimaryKey,
context: ExecutionContext,
): Promise<ChannelThreadUsers> {
return await this.repository.findOne(pk, {}, context);
}
async delete(
pk: ChannelThreadUsersPrimaryKey,
context: ExecutionContext,
): Promise<DeleteResult<ChannelThreadUsers>> {
await this.repository.remove(pk as ChannelThreadUsers, context);
return new DeleteResult(ChannelThreadUsersType, pk as ChannelThreadUsers, true);
}
list(): Promise<ListResult<ChannelThreadUsers>> {
throw new Error("Not implemented");
}
async getUsersInThread(
pk: ChannelThreadUsersPrimaryKey,
context: ExecutionContext,
): Promise<ListResult<ChannelThreadUsers>> {
if (!pk.company_id || !pk.channel_id || !pk.thread_id) {
throw new Error("Missing required fields");
}
return this.repository.find(pk, {}, context);
}
}
@@ -1,154 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Initializable, TdriveServiceProvider } from "../../../core/platform/framework";
import { Paginable, Pagination } from "../../../core/platform/framework/api/crud-service";
import Repository from "../../../core/platform/services/database/services/orm/repository/repository";
import { Channel } from "../../../services/channels/entities";
import Workspace from "../../../services/workspaces/entities/workspace";
import gr from "../../global-resolver";
import { UserNotificationBadge } from "../entities";
import { getInstance, TYPE, UserNotificationDigest } from "../entities/user-notification-digest";
export class UserNotificationDigestService implements TdriveServiceProvider, Initializable {
version: "1";
repository: Repository<UserNotificationDigest>;
async init(): Promise<this> {
this.repository = await gr.database.getRepository<UserNotificationDigest>(
TYPE,
UserNotificationDigest,
);
gr.platformServices.cron.schedule(
"*/15 * * * *",
async () => {
//This being multi-node we will try to avoid running them at the exact same time
//Fixme: this is until we find a better solution of course
await new Promise(r => setTimeout(r, 1000 * 60 * Math.random()));
await this.processDigests();
},
"Find and process digests",
);
return this;
}
async getDigest(companyId: string, userId: string): Promise<UserNotificationDigest> {
return await this.repository.findOne({ company_id: companyId, user_id: userId });
}
async cancelDigest(companyId: string, userId: string) {
const digest = await this.getDigest(companyId, userId);
if (digest) await this.repository.remove(digest);
}
async putBadge(badge: UserNotificationBadge): Promise<void> {
const digest = await this.getDigest(badge.company_id, badge.user_id);
let deliver_at = digest?.deliver_at;
if (!deliver_at) {
//Get user preferences to set the deliver at
const preferences = await gr.services.notifications.preferences.getMerged({
company_id: badge.company_id,
user_id: badge.user_id,
workspace_id: "all",
});
let emailNotificationsDelay = preferences?.preferences?.email_notifications_delay;
if (emailNotificationsDelay === undefined) emailNotificationsDelay = 15;
if (!emailNotificationsDelay) {
return;
} else {
deliver_at = Date.now() + emailNotificationsDelay * 1000 * 60;
}
}
await this.repository.save(
getInstance({
company_id: badge.company_id,
user_id: badge.user_id,
created_at: digest?.created_at || Date.now(),
deliver_at,
}),
);
}
async processDigests(): Promise<void> {
let digestsPagination: Paginable = new Pagination(null, "100");
do {
const digests = await this.repository.find({});
const digestsEntities = digests.getEntities().filter(d => {
return d.deliver_at < Date.now();
});
for (const digest of digestsEntities) {
await this.repository.remove(digest);
}
for (const digest of digestsEntities) {
await this.processDigest(digest);
}
digestsPagination = new Pagination(digests.nextPage.page_token, "100");
} while (digestsPagination.page_token);
}
async processDigest(digest: UserNotificationDigest): Promise<void> {
const badges = await gr.services.notifications.badges.listForUser(
digest.company_id,
digest.user_id,
{} as any,
);
const user = await gr.services.users.get({ id: digest.user_id });
const company = await gr.services.companies.getCompany({ id: digest.company_id });
const notifications: {
channel: Channel;
workspace: Workspace;
}[] = [];
const workspaces: { [key: string]: Workspace } = {};
const channels: { [key: string]: Channel } = {};
for (const badge of badges.getEntities()) {
if (!badge.thread_id) continue;
try {
channels[badge.channel_id] =
channels[badge.channel_id] ||
(await gr.services.channels.channels.get({
company_id: badge.company_id,
workspace_id: badge.workspace_id,
id: badge.channel_id,
}));
workspaces[badge.workspace_id] =
badge.workspace_id && badge.workspace_id !== "direct"
? workspaces[badge.workspace_id] ||
(await gr.services.workspaces.get({
company_id: badge.company_id,
id: badge.workspace_id,
}))
: null;
notifications.push({
channel: channels[badge.channel_id],
workspace: workspaces[badge.workspace_id],
});
} catch (e) {}
}
const etaEntry = {
user,
company,
notifications,
};
if (notifications.length > 0) {
const { html, text, subject } = await gr.platformServices.emailPusher.build(
"notification-digest",
user.language,
etaEntry,
);
await gr.platformServices.emailPusher.send(user.email_canonical, {
subject,
html,
text,
});
}
}
}
@@ -1,34 +0,0 @@
import { Initializable } from "../../../../core/platform/framework";
import { MarkChannelAsReadMessageProcessor } from "./processors/mark-channel-as-read";
import { MarkChannelAsUnreadMessageProcessor } from "./processors/mark-channel-as-unread";
import { PushNotificationMessageProcessor } from "./processors/mobile-push-notifications";
import { PushNotificationToUsersMessageProcessor } from "./processors/push-to-users";
import { LeaveChannelMessageProcessor } from "./processors/channel-member-deleted";
import { JoinChannelMessageProcessor } from "./processors/channel-member-created";
import { UpdateChannelMemberMessageProcessor } from "./processors/channel-member-updated";
import { PushReactionNotification } from "./processors/reaction-notification";
import gr from "../../../global-resolver";
/**
* The notification engine is in charge of processing data and delivering user notifications on the right place
*/
export class NotificationEngine implements Initializable {
async init(): Promise<this> {
gr.platformServices.messageQueue.processor.addHandler(
new UpdateChannelMemberMessageProcessor(),
);
gr.platformServices.messageQueue.processor.addHandler(new JoinChannelMessageProcessor());
gr.platformServices.messageQueue.processor.addHandler(new LeaveChannelMessageProcessor());
gr.platformServices.messageQueue.processor.addHandler(new MarkChannelAsReadMessageProcessor());
gr.platformServices.messageQueue.processor.addHandler(
new MarkChannelAsUnreadMessageProcessor(),
);
gr.platformServices.messageQueue.processor.addHandler(new PushNotificationMessageProcessor());
gr.platformServices.messageQueue.processor.addHandler(
new PushNotificationToUsersMessageProcessor(),
);
gr.platformServices.messageQueue.processor.addHandler(new PushReactionNotification());
return this;
}
}
@@ -1,79 +0,0 @@
import {
ChannelMemberNotificationPreference,
getNotificationPreferenceInstance,
} from "../../../entities";
import { logger } from "../../../../../core/platform/framework";
import { Channel, ChannelMember } from "../../../../channels/entities";
import gr from "../../../../global-resolver";
import { NotificationMessageQueueHandler } from "../../../types";
import { ExecutionContext } from "../../../../../core/platform/framework/api/crud-service";
type JoinChannelMessage = { channel: Channel; member: ChannelMember };
export class JoinChannelMessageProcessor
implements NotificationMessageQueueHandler<JoinChannelMessage, void>
{
readonly topics = {
in: "channel:member:created",
};
readonly options = {
unique: true,
ack: true,
};
readonly name = "JoinChannelMessageProcessor";
validate(message: JoinChannelMessage): boolean {
return !!(message && message.channel && message.member);
}
async process(message: JoinChannelMessage, context?: ExecutionContext): Promise<void> {
logger.info(
`${this.name} - Processing join channel message for user ${message.member.user_id} in channel ${message.channel.id}`,
);
try {
let preference: ChannelMemberNotificationPreference;
if (Channel.isDirectChannel(message.channel)) {
// Check if the user already have preference for this channel in case he already joined it before
preference = await gr.services.notifications.channelPreferences.get(
{
company_id: message.member.company_id,
channel_id: message.member.channel_id,
user_id: message.member.user_id,
},
context,
);
}
if (preference) {
logger.info(
`${this.name} - Notification preference already exists for user ${message.member.user_id} in direct channel ${message.channel.id}`,
);
return;
}
preference = getNotificationPreferenceInstance({
channel_id: message.member.channel_id,
company_id: message.member.company_id,
user_id: message.member.user_id,
last_read: message.member.last_access || 0,
preferences: message.member.notification_level,
});
await gr.services.notifications.channelPreferences.save(preference, context);
logger.info(
`${this.name} - Notification preference has been created for user ${message.member.user_id} in channel ${message.channel.id}`,
);
} catch (err) {
logger.warn(
{ err },
`${this.name} - Error while creating notification preference from pubsub`,
);
}
}
}
@@ -1,101 +0,0 @@
import { assign, merge } from "lodash";
import { ChannelMemberNotificationPreference, UserNotificationBadge } from "../../../entities";
import { logger } from "../../../../../core/platform/framework";
import { Channel, ChannelMember } from "../../../../channels/entities";
import { isDirectChannel } from "../../../../channels/utils";
import gr from "../../../../global-resolver";
import { NotificationMessageQueueHandler } from "../../../types";
import { ExecutionContext } from "../../../../../core/platform/framework/api/crud-service";
type LeaveChannelMessage = { channel: Channel; member: ChannelMember };
export class LeaveChannelMessageProcessor
implements NotificationMessageQueueHandler<LeaveChannelMessage, void>
{
readonly topics = {
in: "channel:member:deleted",
};
readonly options = {
unique: true,
ack: true,
};
readonly name = "LeaveChannelMessageProcessor";
validate(message: LeaveChannelMessage): boolean {
return !!(message && message.channel && message.member);
}
async process(message: LeaveChannelMessage, context?: ExecutionContext): Promise<void> {
logger.info(
`${this.name} - Processing leave channel message for user ${message.member.user_id} in channel ${message.channel.id}`,
);
if (Channel.isDirectChannel(message.channel)) {
logger.info(
`${this.name} - Channel is direct, do not clean resources for user ${message.member.user_id} in channel ${message.channel.id}`,
);
return;
}
await Promise.all([this.removeBadges(message, context), this.removePreferences(message)]);
}
async removeBadges(message: LeaveChannelMessage, context: ExecutionContext): Promise<void> {
logger.info(
`${this.name} - Removing badges for user ${message.member.user_id} in channel ${message.channel.id}`,
);
try {
const badgeEntity = new UserNotificationBadge();
assign(badgeEntity, {
workspace_id: message.channel.workspace_id,
company_id: message.channel.company_id,
channel_id: message.channel.id,
user_id: message.member.user_id,
});
const removedBadges = await gr.services.notifications.badges.removeUserChannelBadges(
badgeEntity,
context,
);
logger.info(
`${this.name} - Removed ${removedBadges} badges for user ${message.member.user_id} in channel ${message.channel.id}`,
);
} catch (err) {
logger.warn(
{ err },
`${this.name} - Error while removing badges for user ${message.member.user_id} in channel ${message.channel.id}`,
);
}
}
async removePreferences(message: LeaveChannelMessage): Promise<void> {
if (isDirectChannel(message.channel)) {
logger.info(
`${this.name} - Notification preference was kept for user ${message.member.user_id} in direct channel ${message.channel.id}`,
);
return;
}
try {
const preference = merge(new ChannelMemberNotificationPreference(), {
channel_id: message.member.channel_id,
company_id: message.member.company_id,
user_id: message.member.user_id,
});
await gr.services.notifications.channelPreferences.delete(preference);
logger.info(
`${this.name} - Notification preference has been deleted for user ${message.member.user_id} in channel ${message.channel.id}`,
);
} catch (err) {
logger.warn(
{ err },
`${this.name} - Error while removing preferences for user ${message.member.user_id} in channel ${message.channel.id}`,
);
}
}
}
@@ -1,55 +0,0 @@
import { merge } from "lodash";
import { ChannelMemberNotificationPreference } from "../../../entities";
import { logger } from "../../../../../core/platform/framework";
import { Channel, ChannelMember } from "../../../../channels/entities";
import gr from "../../../../global-resolver";
import { NotificationMessageQueueHandler } from "../../../types";
import { ExecutionContext } from "../../../../../core/platform/framework/api/crud-service";
type UpdateChannelMessage = { channel: Channel; member: ChannelMember };
export class UpdateChannelMemberMessageProcessor
implements NotificationMessageQueueHandler<UpdateChannelMessage, void>
{
readonly topics = {
in: "channel:member:updated",
};
readonly options = {
unique: true,
ack: true,
};
readonly name = "UpdateChannelMemberMessageProcessor";
validate(message: UpdateChannelMessage): boolean {
return !!(message && message.channel && message.member);
}
async process(message: UpdateChannelMessage, context?: ExecutionContext): Promise<void> {
logger.info(
`${this.name} - Processing update channel member message for user ${message.member.user_id} in channel ${message.channel.id}`,
);
const preference = merge(new ChannelMemberNotificationPreference(), {
channel_id: message.member.channel_id,
company_id: message.member.company_id,
user_id: message.member.user_id,
last_read: message.member.last_access || 0,
preferences: message.member.notification_level,
});
try {
await gr.services.notifications.channelPreferences.save(preference, context);
logger.info(
`${this.name} - Channel member notification preference has been updated for user ${message.member.user_id} in channel ${message.channel.id}`,
);
} catch (err) {
logger.error(
{ err },
`${this.name} - Error while updating channel member notification preference for user ${message.member.user_id} in channel ${message.channel.id}`,
);
}
}
}
@@ -1,70 +0,0 @@
import _ from "lodash";
import { UserNotificationBadge } from "../../../entities";
import { logger } from "../../../../../core/platform/framework";
import { ChannelReadMessage, NotificationMessageQueueHandler } from "../../../types";
import gr from "../../../../global-resolver";
import { ExecutionContext } from "../../../../../core/platform/framework/api/crud-service";
export class MarkChannelAsReadMessageProcessor
implements NotificationMessageQueueHandler<ChannelReadMessage, void>
{
readonly topics = {
in: "channel:read",
};
readonly options = {
unique: true,
ack: true,
};
readonly name = "MarkChannelAsReadMessageProcessor";
validate(message: ChannelReadMessage): boolean {
return !!(
message &&
message.channel &&
message.channel.workspace_id &&
message.channel.company_id &&
message.channel.id &&
message.member &&
message.member.user_id
);
}
async process(message: ChannelReadMessage, context?: ExecutionContext): Promise<void> {
logger.info(
`${this.name} - Processing message for user ${message.member.user_id} in channel ${message.channel.id}`,
);
await this.removeBadges(message, context);
}
async removeBadges(message: ChannelReadMessage, context: ExecutionContext): Promise<void> {
logger.info(
`${this.name} - Removing badges for user ${message.member.user_id} in channel ${message.channel.id}`,
);
try {
const badgeEntity = new UserNotificationBadge();
_.assign(badgeEntity, {
workspace_id: message.channel.workspace_id,
company_id: message.channel.company_id,
channel_id: message.channel.id,
user_id: message.member.user_id,
});
const removedBadges = await gr.services.notifications.badges.removeUserChannelBadges(
badgeEntity,
context,
);
logger.info(
`${this.name} - Removed ${removedBadges} badges for user ${message.member.user_id} in channel ${message.channel.id}`,
);
} catch (err) {
logger.warn(
{ err },
`${this.name} - Error while removing badges for user ${message.member.user_id} in channel ${message.channel.id}`,
);
}
}
}
@@ -1,68 +0,0 @@
import _ from "lodash";
import { UserNotificationBadge } from "../../../entities";
import { logger } from "../../../../../core/platform/framework";
import { ChannelUnreadMessage, NotificationMessageQueueHandler } from "../../../types";
import gr from "../../../../global-resolver";
import { ExecutionContext } from "../../../../../core/platform/framework/api/crud-service";
export class MarkChannelAsUnreadMessageProcessor
implements NotificationMessageQueueHandler<ChannelUnreadMessage, void>
{
readonly topics = {
in: "channel:unread",
};
readonly options = {
unique: true,
ack: true,
};
readonly name = "MarkChannelAsUnreadMessageProcessor";
validate(message: ChannelUnreadMessage): boolean {
return !!(
message &&
message.channel &&
message.channel.workspace_id &&
message.channel.company_id &&
message.channel.id &&
message.member &&
message.member.user_id
);
}
async process(message: ChannelUnreadMessage, context?: ExecutionContext): Promise<void> {
logger.info(
`${this.name} - Processing message for user ${message.member.user_id} in channel ${message.channel.id}`,
);
await this.addBadge(message, context);
}
async addBadge(message: ChannelUnreadMessage, context: ExecutionContext): Promise<void> {
logger.info(
`${this.name} - Creating a badge for user ${message.member.user_id} in channel ${message.channel.id}`,
);
try {
const badgeEntity = new UserNotificationBadge();
_.assign(badgeEntity, {
workspace_id: message.channel.workspace_id,
company_id: message.channel.company_id,
channel_id: message.channel.id,
user_id: message.member.user_id,
mention_type: "unread",
});
gr.services.notifications.badges.save(badgeEntity, context);
logger.info(
`${this.name} - Created new badge for user ${message.member.user_id} in channel ${message.channel.id}`,
);
} catch (err) {
logger.warn(
{ err },
`${this.name} - Error while creating new badge for user ${message.member.user_id} in channel ${message.channel.id}`,
);
}
}
}
@@ -1,48 +0,0 @@
import { logger } from "../../../../../core/platform/framework";
import {
NotificationMessageQueueHandler,
PushNotificationMessage,
PushNotificationMessageResult,
} from "../../../types";
import gr from "../../../../global-resolver";
/**
* Push new message notification to a set of users
*/
export class PushNotificationMessageProcessor
implements
NotificationMessageQueueHandler<PushNotificationMessage, PushNotificationMessageResult>
{
readonly topics = {
in: "notification:push:mobile",
};
readonly options = {
unique: true,
ack: true,
};
name = "PushMobileNotificationProcessor";
validate(message: PushNotificationMessage): boolean {
return !!(
message &&
message.channel_id &&
message.company_id &&
message.workspace_id &&
message.message_id &&
message.badge_value &&
message.user
);
}
async process(message: PushNotificationMessage): Promise<PushNotificationMessageResult> {
logger.info(
`${this.name} - Processing mobile push notification for channel ${message.channel_id}`,
);
await gr.services.notifications.mobilePush.push(message);
return message;
}
}
@@ -1,213 +0,0 @@
import { logger } from "../../../../../core/platform/framework";
import { MobilePushNotifier } from "../../../notifiers";
import {
MentionNotification,
MentionNotificationResult,
NotificationMessageQueueHandler,
PushNotificationMessage,
} from "../../../types";
import { ChannelMemberNotificationPreference } from "../../../entities";
import { UserNotificationBadge } from "../../../entities";
import _ from "lodash";
import { websocketEventBus } from "../../../../../core/platform/services/realtime/bus";
import {
RealtimeEntityActionType,
ResourcePath,
} from "../../../../../core/platform/services/realtime/types";
import { getNotificationRoomName } from "../../realtime";
import gr from "../../../../global-resolver";
import { ExecutionContext } from "../../../../../core/platform/framework/api/crud-service";
/**
* Push new message notification to a set of users
*/
export class PushNotificationToUsersMessageProcessor
implements NotificationMessageQueueHandler<MentionNotification, MentionNotificationResult>
{
readonly topics = {
in: "notification:mentions",
};
readonly options = {
unique: true,
ack: true,
};
name = "PushNotificationToUsersMessageProcessor";
validate(message: MentionNotification): boolean {
return !!(
message &&
message.channel_id &&
message.company_id &&
message.workspace_id &&
message.creation_date
);
}
async process(
message: MentionNotification,
context?: ExecutionContext,
): Promise<MentionNotificationResult> {
logger.info(`${this.name} - Processing mention notification for channel ${message.channel_id}`);
if (
!message.company_id ||
!message.workspace_id ||
!message.channel_id ||
!message.creation_date
) {
throw new Error("Missing required fields");
}
if (!message.mentions || !message.mentions.users || !message.mentions.users.length) {
logger.info(`${this.name} - Message does not have any user to mention`);
return;
}
const usersToUpdate = await this.filterUsersOnLastReadChannelTime(
{ channel_id: message.channel_id, company_id: message.company_id },
message.mentions.users,
message.creation_date,
context,
);
if (!usersToUpdate.length) {
logger.info(`${this.name} - There are no users to notify from the last read channel date`);
return;
}
const badges = await this.addNewMessageBadgesForUsers(
{
channel_id: message.channel_id,
company_id: message.company_id,
workspace_id: message.workspace_id,
thread_id: message.thread_id || message.message_id,
message_id: message.message_id,
},
usersToUpdate,
message.mentions,
context,
);
badges.forEach(badge => {
const badgeLocation = {
company_id: message.company_id,
workspace_id: message.workspace_id,
channel_id: message.channel_id,
user: badge.user_id.toString(),
thread_id: message.thread_id || message.message_id,
message_id: message.message_id,
};
websocketEventBus.publish(RealtimeEntityActionType.Event, {
type: "notification:desktop",
room: ResourcePath.get(getNotificationRoomName(badge.user_id)),
entity: {
...badgeLocation,
title: message.title,
text: formatNotificationMessage(message),
},
resourcePath: null,
result: null,
});
this.sendPushNotification(badge.user_id, {
...badgeLocation,
badge_value: 1,
title: message.title,
text: formatNotificationMessage(message),
});
});
}
async filterUsersOnLastReadChannelTime(
channel: Pick<ChannelMemberNotificationPreference, "channel_id" | "company_id">,
users: string[] = [],
timestamp: number,
context?: ExecutionContext,
): Promise<string[]> {
if (!users.length) {
return [];
}
return (
await gr.services.notifications.channelPreferences.getChannelPreferencesForUsers(
channel,
users,
{
lessThan: timestamp,
},
context,
)
)
.getEntities()
.map((preference: ChannelMemberNotificationPreference) => preference.user_id);
}
async addNewMessageBadgesForUsers(
badge: Pick<
UserNotificationBadge,
"channel_id" | "company_id" | "thread_id" | "workspace_id" | "message_id"
>,
users: string[] = [],
mentions: MentionNotification["mentions"],
context: ExecutionContext,
): Promise<Array<UserNotificationBadge>> {
logger.info(`${this.name} - Update badge for users ${users.join("/")}`);
return (
await Promise.all(
users.map(user => {
const badgeEntity = new UserNotificationBadge();
_.assign(badgeEntity, {
channel_id: badge.channel_id,
company_id: badge.company_id,
workspace_id: badge.workspace_id,
thread_id: badge.thread_id,
message_id: badge.message_id,
user_id: user,
mention_type: mentions.users.includes(user),
});
return this.saveBadge(badgeEntity, context);
}),
)
).filter(Boolean);
}
private saveBadge(
badge: UserNotificationBadge,
context: ExecutionContext,
): Promise<UserNotificationBadge> {
return gr.services.notifications.badges
.save(badge, context)
.then(result => result.entity)
.catch(err => {
logger.warn({ err }, `${this.name} - A badge has not been saved for user ${badge.user_id}`);
return null;
});
}
sendPushNotification(user: string, pushNotification: PushNotificationMessage): void {
MobilePushNotifier.get(gr.platformServices.messageQueue).notify(user, pushNotification);
}
}
function formatNotificationMessage(message: MentionNotification): string {
let text = message.text;
// Clean the message text to remove @userName:id-id-id
((text || "").match(/@[^: ]+:([0-f-]{36})/gm) || []).forEach(match => {
const string = (match || "").trim();
const id = string.split(":").pop();
const fallback = string.split(":").shift();
text = text.replace(string, message.object_names?.users[id] || fallback);
});
// Clean the message text to remove #channelName:id-id-id
((text || "").match(/#[^: ]+:([0-f-]{36})/gm) || []).forEach(match => {
const string = (match || "").trim();
const id = string.split(":").pop();
const fallback = string.split(":").shift();
text = text.replace(string, message.object_names?.channels[id] || fallback);
});
return text;
}
@@ -1,127 +0,0 @@
import {
NotificationMessageQueueHandler,
PushNotificationMessage,
ReactionNotification,
} from "../../../types";
import { logger } from "../../../../../core/platform/framework";
import { websocketEventBus } from "../../../../../core/platform/services/realtime/bus";
import {
RealtimeEntityActionType,
ResourcePath,
} from "../../../../../core/platform/services/realtime/types";
import { MobilePushNotifier } from "../../../notifiers";
import gr from "../../../../global-resolver";
import { getNotificationRoomName } from "../../realtime";
import { Channel } from "../../../../channels/entities/channel";
import User from "../../../../user/entities/user";
export class PushReactionNotification
implements NotificationMessageQueueHandler<ReactionNotification, void>
{
readonly topics = {
in: "notification:reaction",
};
readonly options = {
unique: true,
ack: true,
};
name = "PushReactionToUsersMessageProcessor";
validate(message: ReactionNotification): boolean {
return !!(
message &&
message.message_id &&
message.channel_id &&
message.company_id &&
message.workspace_id &&
message.thread_id &&
message.user_id &&
message.reaction &&
message.creation_date &&
message.reaction_user_id
);
}
async process(task: ReactionNotification): Promise<void> {
logger.info(`${this.name} - Processing reaction notification for message ${task.message_id}`);
const location = {
company_id: task.company_id,
workspace_id: task.workspace_id,
channel_id: task.channel_id,
user: task.user_id.toString(),
thread_id: task.thread_id || task.message_id,
message_id: task.message_id,
};
const { title, text } = await this.buildNotificationMessageContent(task);
websocketEventBus.publish(RealtimeEntityActionType.Event, {
type: "notification:desktop",
room: ResourcePath.get(getNotificationRoomName(task.user_id)),
entity: {
...location,
title,
text,
},
resourcePath: null,
result: null,
});
this.sendPushNotification(task.user_id, {
...location,
title,
text,
});
}
sendPushNotification(user: string, reaction: PushNotificationMessage): void {
MobilePushNotifier.get(gr.platformServices.messageQueue).notify(user, reaction);
}
async buildNotificationMessageContent(
message: ReactionNotification,
): Promise<{ title: string; text: string }> {
const { company_id, workspace_id, reaction_user_id, reaction } = message;
let title = "";
const channel: Channel = await gr.services.channels.channels.get({
id: message.channel_id,
company_id: company_id,
workspace_id: workspace_id,
});
const [company, workspace, user] = await Promise.all([
gr.services.companies.getCompany({ id: company_id }),
workspace_id === "direct"
? null
: gr.services.workspaces.get({
company_id: company_id,
id: workspace_id,
}),
gr.services.users.get({ id: reaction_user_id }),
]);
const companyName = company?.name || "";
const workspaceName = workspace_id === "direct" ? "Direct" : workspace?.name || "";
const userName = this.getUserName(user) || "Tdrive";
if (Channel.isDirectChannel(channel)) {
title = `${userName} in ${companyName}`;
} else {
title = `${channel.name} in ${companyName}${workspaceName}`;
}
return {
title,
text: `${userName}: ${reaction}`,
};
}
getUserName(user: User): string {
return (
`${user?.first_name || ""} ${user?.last_name || ""}`.trim() || `@${user?.username_canonical}`
);
}
}
@@ -1,59 +0,0 @@
import Repository from "../../../core/platform/services/database/services/orm/repository/repository";
import { PushNotificationMessage } from "../types";
import User, { TYPE as UserType } from "../../user/entities/user";
import { CrudException, ExecutionContext } from "../../../core/platform/framework/api/crud-service";
import gr from "../../global-resolver";
export class MobilePushService {
name: "MobilePushService";
version: "1";
userRepository: Repository<User>;
async init(): Promise<this> {
this.userRepository = await gr.database.getRepository<User>(UserType, User);
return this;
}
//Fixme: add a bulk system to group requests to fcm
async push(message: PushNotificationMessage, context?: ExecutionContext): Promise<void> {
// Get devices and loop over devices
const userId = message.user;
const user = await this.userRepository.findOne({ id: userId }, {}, context);
if (!user) {
throw CrudException.notFound(`User ${userId} not found`);
}
const notification = {
title: message.title,
body: message.text,
sound: "default",
badge: message.badge_value,
};
const options = {
notification_data: {
company_id: message.company_id,
workspace_id: message.workspace_id,
channel_id: message.channel_id,
message_id: message.message_id,
thread_id: message.thread_id,
},
collapse_key: message.channel_id,
};
const { preferences } = await gr.services.notifications.preferences.getMerged(
{ user_id: user.id, company_id: message.company_id, workspace_id: message.workspace_id },
user,
);
if (preferences.mobile_notifications === "never") {
return;
}
if (preferences.private_message_content) {
notification.body = "[Private]";
}
await gr.platformServices.push.push(user.devices, notification, options);
}
}
@@ -1,147 +0,0 @@
import _ from "lodash";
import { Initializable, TdriveServiceProvider } from "../../../core/platform/framework";
import {
CrudException,
DeleteResult,
ExecutionContext,
ListResult,
OperationType,
SaveResult,
} from "../../../core/platform/framework/api/crud-service";
import Repository from "../../../core/platform/services/database/services/orm/repository/repository";
import User, { UserNotificationPreferences } from "../../../services/user/entities/user";
import gr from "../../global-resolver";
type UserNotificationPreferencesPrimaryKey = {
company_id: string;
workspace_id: string;
user_id: string;
};
export class NotificationPreferencesService implements TdriveServiceProvider, Initializable {
version: "1";
repository: Repository<UserNotificationPreferences>;
async init(): Promise<this> {
return this;
}
async list(): Promise<ListResult<UserNotificationPreferences>> {
throw new Error("Not implemented");
}
async get(
pk: UserNotificationPreferencesPrimaryKey,
user?: User,
): Promise<UserNotificationPreferences> {
user = user || (await gr.services.users.get({ id: pk.user_id }));
const notificationPreferences = (user?.preferences?.notifications || []).find(n => {
return n.company_id === pk.company_id && n.workspace_id === pk.workspace_id;
});
return (
notificationPreferences || {
company_id: pk.company_id,
workspace_id: pk.workspace_id,
preferences: {
highlight_words: [],
night_break: {
enable: false,
from: 0,
to: 0,
},
private_message_content: false,
mobile_notifications: "always",
email_notifications_delay: 15,
deactivate_notifications_until: 0,
notification_sound: "default",
},
}
);
}
/** We can define preferences for specifically a workspace or for all a company or all Tdrive
* This function will ensure we get all with inherit and all
*/
async getMerged(
pk: UserNotificationPreferencesPrimaryKey,
user?: User,
): Promise<UserNotificationPreferences> {
let preferences = await this.get(pk, user);
if (pk.workspace_id !== "all")
preferences = _.merge(await this.get({ ...pk, workspace_id: "all" }), preferences);
if (pk.company_id !== "all")
preferences = _.merge(
await this.get({ ...pk, workspace_id: "all", company_id: "all" }),
preferences,
);
return preferences;
}
async delete(
pk: UserNotificationPreferencesPrimaryKey,
context?: ExecutionContext,
): Promise<DeleteResult<UserNotificationPreferences>> {
const user = await gr.services.users.get({ id: pk.user_id });
const notificationPreferences = (user?.preferences?.notifications || []).filter(n => {
return n.company_id !== pk.company_id || n.workspace_id !== pk.workspace_id;
});
await gr.services.users.setPreferences(
{ id: pk.user_id },
{
notifications: notificationPreferences,
},
context,
);
return new DeleteResult("user_notifications_preference", pk as any, true);
}
async listPreferences(
workspace_id: string,
company_id: string,
user_id: string,
): Promise<ListResult<UserNotificationPreferences>> {
if (!workspace_id || !company_id || !user_id) {
throw CrudException.badRequest("workspace_id, company_id and user_id are required");
}
const user = await gr.services.users.get({ id: user_id });
const notificationPreferences = (user?.preferences?.notifications || []).filter(n => {
return n.company_id === company_id && n.workspace_id === workspace_id;
});
return new ListResult("user_notifications_preference", notificationPreferences);
}
async savePreferences(
singleNotificationPreferences: UserNotificationPreferences,
userId: string,
context: ExecutionContext,
): Promise<SaveResult<UserNotificationPreferences>> {
const user = await gr.services.users.get({ id: userId });
const notificationPreferences = (user?.preferences?.notifications || []).filter(n => {
return (
n.company_id !== singleNotificationPreferences.company_id ||
n.workspace_id !== singleNotificationPreferences.workspace_id
);
});
notificationPreferences.push(singleNotificationPreferences);
await gr.services.users.setPreferences(
{ id: userId },
{
notifications: notificationPreferences,
},
context,
);
return new SaveResult(
"user_notifications_preference",
singleNotificationPreferences,
OperationType.CREATE,
);
}
}
@@ -1,12 +0,0 @@
import { User, WebsocketMetadata } from "../../../utils/types";
export function getWebsocketInformation(user: User): WebsocketMetadata {
return {
room: getNotificationRoomName(user.id),
token: "",
};
}
export function getNotificationRoomName(userId: string): string {
return `/notifications?type=private&user=${userId}`;
}
@@ -1,97 +0,0 @@
import { ExecutionContext } from "../../core/platform/framework/api/crud-service";
import { Channel, ChannelMember } from "../channels/entities";
import { PaginationQueryParameters } from "../channels/web/types";
import { uuid } from "../../utils/types";
import { MessageQueueHandler } from "../../core/platform/services/message-queue/api";
export type NotificationConfiguration = {
push: {
type: "fcm";
fcm: {
endpoint: string;
key: string;
};
};
};
export type NotificationExecutionContext = ExecutionContext;
export type MentionNotification = {
company_id: uuid;
workspace_id: uuid | "direct";
channel_id: uuid;
thread_id: uuid;
message_id: uuid;
creation_date: number;
mentions?: {
users: uuid[];
};
object_names?: {
users: { [id: string]: string };
channels: { [id: string]: string };
};
title: string;
text: string;
};
export type MentionNotificationResult = MentionNotification;
export type ChannelReadMessage = { member: ChannelMember; channel: Channel };
export type ChannelUnreadMessage = ChannelReadMessage;
export type PushNotificationMessage = {
company_id: uuid;
workspace_id: uuid | "direct";
channel_id: uuid;
message_id: uuid;
thread_id: uuid;
badge_value?: number;
user: string;
title: string;
text: string;
};
export type PushNotificationMessageResult = PushNotificationMessage;
export interface NotificationListQueryParameters extends PaginationQueryParameters {
company_id: uuid;
workspace_id: uuid | "direct";
channel_id: uuid;
thread_id: uuid;
all_companies: boolean;
}
export interface NotificationListUrlParameters {
company_id: uuid;
}
export interface NotificationPreferenceListQueryParameters extends PaginationQueryParameters {
workspace_id: uuid | "all";
company_id: uuid | "all";
user_id: uuid;
}
export type NotificationMessageQueueHandler<InputMessage, OutputMessage> = MessageQueueHandler<
InputMessage,
OutputMessage
>;
export type ReactionNotification = {
company_id: uuid;
workspace_id: uuid | "direct";
channel_id: uuid;
thread_id: uuid;
message_id: uuid;
creation_date: number;
user_id: string;
reaction: string;
reaction_user_id: string;
};
export type ReactionNotificationResult = ReactionNotification;
export type NotificationAcknowledgeBody = {
thread_id: uuid;
workspace_id: uuid | "direct";
channel_id: uuid;
message_id: uuid;
};
@@ -1,2 +0,0 @@
export * from "./notification";
export * from "./preferences";
@@ -1,83 +0,0 @@
import { FastifyRequest } from "fastify";
import { CrudController } from "../../../../core/platform/services/webserver/types";
import { NotificationListQueryParameters } from "../../types";
import {
ResourceCreateResponse,
ResourceDeleteResponse,
ResourceGetResponse,
ResourceListResponse,
} from "../../../../utils/types";
import { UserNotificationBadge } from "../../entities";
import { getWebsocketInformation } from "../../services/realtime";
import gr from "../../../global-resolver";
import { WorkspaceExecutionContext } from "../../../channels/types";
export class NotificationController
implements
CrudController<
ResourceGetResponse<UserNotificationBadge>,
ResourceCreateResponse<UserNotificationBadge>,
ResourceListResponse<UserNotificationBadge>,
ResourceDeleteResponse
>
{
async list(
request: FastifyRequest<{
Querystring: NotificationListQueryParameters;
}>,
): Promise<ResourceListResponse<UserNotificationBadge>> {
const context = getExecutionContext(request);
let resources: UserNotificationBadge[] = [];
let page_token = "";
//Get one badge per company if requested
if (request.query.all_companies) {
const list = await gr.services.notifications.badges.listForUserPerCompanies(
request.currentUser.id,
context,
);
resources = resources.concat(list.getEntities());
}
if (request.query.company_id) {
const list = await gr.services.notifications.badges.listForUser(
request.query.company_id,
request.currentUser.id,
{ ...request.query },
context,
);
resources = resources.concat(list.getEntities());
page_token = list.page_token;
}
return {
...{
resources,
},
...(request.query.websockets && {
websockets: gr.platformServices.realtime.sign(
[getWebsocketInformation(request.currentUser)],
request.currentUser.id,
),
}),
...(page_token && {
next_page_token: page_token,
}),
};
}
}
function getExecutionContext(request: FastifyRequest): WorkspaceExecutionContext {
return {
user: request.currentUser,
url: request.url,
method: request.routerMethod,
reqId: request.id,
transport: "http",
workspace: {
company_id: undefined,
workspace_id: undefined,
},
};
}
@@ -1,78 +0,0 @@
import { FastifyReply, FastifyRequest } from "fastify";
import { ExecutionContext } from "../../../../core/platform/framework/api/crud-service";
import { UserNotificationPreferences } from "../../../../services/user/entities/user";
import { handleError } from "../../../../utils/handleError";
import { ResourceCreateResponse, ResourceListResponse } from "../../../../utils/types";
import gr from "../../../global-resolver";
import { NotificationPreferenceListQueryParameters } from "../../types";
const ALL = "all";
export class NotificationPreferencesController {
async list(
request: FastifyRequest<{
Querystring: NotificationPreferenceListQueryParameters;
}>,
): Promise<ResourceListResponse<UserNotificationPreferences>> {
const list = await gr.services.notifications.preferences.listPreferences(
ALL,
ALL,
request.currentUser.id,
);
let resources = list.getEntities();
if (!resources.length) {
resources = [
{
company_id: ALL,
workspace_id: ALL,
preferences: {
highlight_words: [],
night_break: { enable: false, from: 0, to: 0 },
private_message_content: false,
mobile_notifications: "always",
email_notifications_delay: 15,
deactivate_notifications_until: 0,
notification_sound: "default",
},
},
];
}
return { resources };
}
async save(
request: FastifyRequest<{ Body: { resource: UserNotificationPreferences } }>,
reply: FastifyReply,
context?: ExecutionContext,
): Promise<ResourceCreateResponse<UserNotificationPreferences>> {
const entity = {
workspace_id: ALL,
company_id: ALL,
user_id: request.currentUser.id,
preferences: {
...request.body.resource,
},
};
try {
const result = await gr.services.notifications.preferences.savePreferences(
entity as UserNotificationPreferences,
request.currentUser.id,
context,
);
if (result) {
reply.code(201);
}
return {
resource: result.entity,
};
} catch (err) {
handleError(reply, err);
}
}
}
@@ -1,12 +0,0 @@
import { FastifyInstance, FastifyRegisterOptions } from "fastify";
import routes from "./routes";
export default (
fastify: FastifyInstance,
options: FastifyRegisterOptions<{
prefix: string;
}>,
): void => {
fastify.log.debug("Configuring /internal/services/notifications/v1 routes");
fastify.register(routes, options);
};
@@ -1,37 +0,0 @@
import { FastifyInstance, FastifyPluginCallback } from "fastify";
import { NotificationController, NotificationPreferencesController } from "./controllers";
import { createNotificationPreferencesSchema } from "./schemas";
const badgesUrl = "/badges";
const notificationPreferencesUrl = "/preferences";
const routes: FastifyPluginCallback = (fastify: FastifyInstance, options, next) => {
const notificationController = new NotificationController();
const notificationPreferencesController = new NotificationPreferencesController();
fastify.route({
method: "GET",
url: badgesUrl,
preValidation: [fastify.authenticate],
handler: notificationController.list.bind(notificationController),
});
fastify.route({
method: "GET",
url: notificationPreferencesUrl,
preValidation: [fastify.authenticate],
handler: notificationPreferencesController.list.bind(notificationPreferencesController),
});
fastify.route({
method: "POST",
url: notificationPreferencesUrl,
preValidation: [fastify.authenticate],
schema: createNotificationPreferencesSchema,
handler: notificationPreferencesController.save.bind(notificationPreferencesController),
});
next();
};
export default routes;
@@ -1,43 +0,0 @@
const notificationPreferencesSchema = {
type: "object",
properties: {
preferences: {
type: "object",
properties: {
highlight_words: { type: "array" },
night_break: {
type: "object",
properties: {
enable: { type: "boolean" },
from: { type: "number" },
to: { type: "number" },
},
},
private_message_content: { type: "boolean" },
mobile_notifications: { type: "string" },
email_notifications_delay: { type: "number" },
deactivate_notifications_until: { type: "number" },
notification_sound: { type: "string" },
},
},
},
};
export const createNotificationPreferencesSchema = {
body: {
type: "object",
properties: {
resource: notificationPreferencesSchema,
},
required: ["resource"],
},
response: {
201: {
type: "object",
properties: {
resource: notificationPreferencesSchema,
},
required: ["resource"],
},
},
};
@@ -34,10 +34,6 @@ import { logger, RealtimeSaved } from "../../../core/platform/framework";
import { getCompanyRoom, getUserRoom } from "../realtime";
import gr from "../../global-resolver";
import { localEventBus } from "../../../core/platform/framework/event-bus";
import {
KnowledgeGraphEvents,
KnowledgeGraphGenericEventPayload,
} from "../../../core/platform/services/knowledge-graph/types";
export class CompanyServiceImpl {
version: "1";
@@ -100,15 +96,6 @@ export class CompanyServiceImpl {
await this.externalCompanyRepository.save(extCompany, context);
}
localEventBus.publish<KnowledgeGraphGenericEventPayload<Company>>(
KnowledgeGraphEvents.COMPANY_UPSERT,
{
id: company.id,
resource: company,
links: [],
},
);
return new SaveResult<Company>("company", company, OperationType.UPDATE);
}
@@ -32,10 +32,6 @@ import { RealtimeSaved } from "../../../../core/platform/framework";
import { getPublicUserRoom, getUserRoom } from "../../realtime";
import NodeCache from "node-cache";
import gr from "../../../global-resolver";
import {
KnowledgeGraphEvents,
KnowledgeGraphGenericEventPayload,
} from "../../../../core/platform/services/knowledge-graph/types";
import { formatUser } from "../../../../utils/users";
export class UserServiceImpl {
@@ -121,27 +117,11 @@ export class UserServiceImpl {
},
];
})
async save<SaveOptions>(user: User, context?: ExecutionContext): Promise<SaveResult<User>> {
async save(user: User, context?: ExecutionContext): Promise<SaveResult<User>> {
this.assignDefaults(user);
await this.repository.save(user, context);
await this.updateExtRepository(user);
localEventBus.publish<KnowledgeGraphGenericEventPayload<User>>(
KnowledgeGraphEvents.USER_UPSERT,
{
id: user.id,
resource: user,
links: [
{
// FIXME: We should provide the company id here
id: "",
relation: "parent",
type: "company",
},
],
},
);
await this.publishPublicUserRealtime(user.id);
return new SaveResult("user", user, OperationType.UPDATE);
@@ -35,7 +35,6 @@ import { getCompanyRooms, getUserRooms } from "../realtime";
import { formatCompany, getCompanyStats } from "../utils";
import { formatUser } from "../../../utils/users";
import gr from "../../global-resolver";
import { UserChannel, UsersIncludedChannel } from "../../channels/entities";
export class UsersCrudController
implements
@@ -282,39 +281,11 @@ export class UsersCrudController
}
async recent(
request: FastifyRequest<{ Params: CompanyParameters; Querystring: { limit: 100 } }>,
_request: FastifyRequest<{ Params: CompanyParameters; Querystring: { limit: 100 } }>,
_reply: FastifyReply,
): Promise<ResourceListResponse<UserObject>> {
const context = getExecutionContext(request);
const userId = context.user.id;
const companyId = request.params.id;
let channels: UserChannel[] = await gr.services.channels.channels
.getChannelsForUsersInWorkspace(companyId, "direct", userId, undefined, context)
.then(list => list.getEntities());
channels = channels.sort((a, b) => b.last_activity - a.last_activity);
channels = channels.slice(0, 100);
const userIncludedChannels: UsersIncludedChannel[] = await Promise.all(
channels.map(
channel =>
gr.services.channels.channels.includeUsersInDirectChannel(
channel,
userId,
) as Promise<UsersIncludedChannel>,
),
);
const users: UserObject[] = [];
for (const channel of userIncludedChannels) {
for (const user of channel.users) {
if (user.id != request.currentUser.id) users.push(user);
}
}
return {
resources: [...uniq(users).slice(0, request.query.limit)],
resources: [],
};
}
}
@@ -60,10 +60,6 @@ import { expandUUID4, reduceUUID4 } from "../../../utils/uuid-reducer";
import gr from "../../global-resolver";
import { logger } from "@sentry/utils";
import { localEventBus } from "../../../core/platform/framework/event-bus";
import {
KnowledgeGraphEvents,
KnowledgeGraphGenericEventPayload,
} from "../../../core/platform/services/knowledge-graph/types";
import WorkspaceInviteDomain, {
TYPE as WorkspaceInviteDomainType,
getInstance as getWorkspaceInviteDomainInstance,
@@ -239,15 +235,6 @@ export class WorkspaceServiceImpl implements TdriveServiceProvider, Initializabl
});
}
localEventBus.publish<KnowledgeGraphGenericEventPayload<Workspace>>(
KnowledgeGraphEvents.WORKSPACE_UPSERT,
{
id: workspace.id,
resource: workspace,
links: [{ relation: "parent", type: "company", id: workspace.company_id }],
},
);
return new SaveResult<Workspace>(
TYPE,
workspace,
@@ -227,23 +227,6 @@ export class WorkspaceInviteTokensCrudController
);
}
await gr.services.channelPendingEmail.proccessPendingEmails(
{
company_id,
user_id: userId,
workspace_id,
},
{
company_id,
workspace_id,
},
{
user: request.currentUser,
url: request.url,
method: request.routerMethod,
transport: "http",
},
);
resource.company.id = company.id;
resource.workspace.id = workspace.id;
}
@@ -35,7 +35,6 @@ import WorkspacePendingUser from "../../entities/workspace_pending_users";
import { ConsoleCompany, CreateConsoleUser } from "../../../console/types";
import { hasCompanyAdminLevel } from "../../../../utils/company";
import gr from "../../../global-resolver";
import { getChannelPendingEmailsInstance } from "../../../channels/entities";
export class WorkspaceUsersCrudController
implements
@@ -413,16 +412,6 @@ export class WorkspaceUsersCrudController
invitation.role,
invitation.company_role,
);
(request.body.channels || []).forEach(async channelId => {
const channelPendingEmail = getChannelPendingEmailsInstance({
channel_id: channelId,
company_id: request.params.company_id,
email: invitation.email,
workspace_id: context.workspace_id,
});
await gr.services.channelPendingEmail.create(channelPendingEmail, context);
});
responses.push({ email: invitation.email, status: "ok" });
};
@@ -516,18 +505,6 @@ export class WorkspaceUsersCrudController
await Promise.all(
usersToProcessImmediately.map(user => {
gr.services.console.processPendingUser(user);
gr.services.channelPendingEmail.proccessPendingEmails(
{
company_id: context.company_id,
user_id: user.id,
workspace_id: context.workspace_id,
},
{
workspace_id: context.workspace_id,
company_id: context.company_id,
},
context,
);
}),
);
+2 -14
View File
@@ -3,13 +3,6 @@
*/
import { WorkspacePrimaryKey } from "../services/workspaces/entities/workspace";
import {
Channel as ChannelEntity,
ChannelMember,
ChannelPendingEmails,
ChannelTab,
} from "../services/channels/entities";
import { ChannelParameters } from "../services/channels/web/types";
export type uuid = string;
@@ -102,14 +95,9 @@ export interface ResourceWebsocket {
export interface ResourceEventsPayload {
user: User;
channel?: ChannelEntity;
channelParameters?: ChannelParameters;
guest?: ChannelPendingEmails;
member?: ChannelMember;
actor?: User;
resourcesBefore?: (User | ChannelEntity | ChannelTab | ChannelMember)[];
resourcesAfter?: (User | ChannelEntity | ChannelTab | ChannelMember)[];
tab?: ChannelTab;
resourcesBefore?: User[];
resourcesAfter?: User[];
company?: { id: string };
workspace?: WorkspacePrimaryKey;
}
@@ -0,0 +1,306 @@
import "reflect-metadata";
import { afterAll, beforeAll, describe, expect, it } from "@jest/globals";
import { init, TestPlatform } from "../setup";
import { TestDbService } from "../utils.prepare.db";
import { Tag } from "../../../src/services/tags/entities/tags";
import { deserialize } from "class-transformer";
import {
ResourceCreateResponse,
ResourceGetResponse,
ResourceListResponse,
} from "../../../src/utils/types";
describe("The Tag feature", () => {
const url = "/internal/services/tags/v1";
let platform: TestPlatform;
let testDbService: TestDbService;
const tagIds: string[] = [];
beforeAll(async ends => {
platform = await init({
services: ["webserver", "database", "storage", "message-queue", "tags"],
});
testDbService = await TestDbService.getInstance(platform, true);
ends();
});
afterAll(async done => {
for (let i = 0; i < tagIds.length; i++) {
for (let j = 0; j < tagIds.length; j++) {
if (tagIds[j] === tagIds[i] && j !== i) {
throw new Error("Tag are not unique");
}
}
}
await platform?.tearDown();
platform = null;
done();
});
describe("Create tag", () => {
it("should 201 if creator is a company admin", async done => {
const user = await testDbService.createUser([testDbService.defaultWorkspace()], {
companyRole: "admin",
});
const jwtToken = await platform.auth.getJWTToken({ sub: user.id });
for (let i = 0; i < 3; i++) {
const createTag = await platform.app.inject({
method: "POST",
url: `${url}/companies/${platform.workspace.company_id}/tags`,
headers: {
authorization: `Bearer ${jwtToken}`,
},
payload: {
name: `test${i}`,
colour: `#00000${i}`,
},
});
const tagResult: ResourceCreateResponse<Tag> = deserialize(
ResourceCreateResponse,
createTag.body,
);
expect(createTag.statusCode).toBe(201);
expect(tagResult.resource).toBeDefined();
expect(tagResult.resource.name).toBe(`test${i}`);
expect(tagResult.resource.colour).toBe(`#00000${i}`);
expect(tagResult.resource.company_id).toBe(platform.workspace.company_id);
expect(tagResult.resource.tag_id).toBe(`test${i}`);
const getTag = await platform.app.inject({
method: "GET",
url: `${url}/companies/${platform.workspace.company_id}/tags/${tagResult.resource.tag_id}`,
headers: {
authorization: `Bearer ${jwtToken}`,
},
});
expect(getTag.statusCode).toBe(200);
tagIds.push(tagResult.resource.tag_id);
}
done();
});
it("should 401 if creator is not a company admin", async done => {
const user = await testDbService.createUser([testDbService.defaultWorkspace()], {
companyRole: "member",
});
const jwtToken = await platform.auth.getJWTToken({ sub: user.id });
const createTag = await platform.app.inject({
method: "POST",
url: `${url}/companies/${platform.workspace.company_id}/tags`,
headers: {
authorization: `Bearer ${jwtToken}`,
},
payload: {
name: "testNotAdmin",
colour: "#000000",
},
});
expect(createTag.statusCode).toBe(401);
const tagResult: ResourceCreateResponse<Tag> = deserialize(
ResourceCreateResponse,
createTag.body,
);
expect(tagResult.resource).toBe(undefined);
done();
});
});
describe("Get tag", () => {
it("should 200 get a tag", async done => {
const user = await testDbService.createUser([testDbService.defaultWorkspace()], {
companyRole: "member",
});
const jwtToken = await platform.auth.getJWTToken({ sub: user.id });
const getTag = await platform.app.inject({
method: "GET",
url: `${url}/companies/${platform.workspace.company_id}/tags/${tagIds[0]}`,
headers: {
authorization: `Bearer ${jwtToken}`,
},
});
expect(getTag.statusCode).toBe(200);
const getResult: ResourceGetResponse<Tag> = deserialize(ResourceGetResponse, getTag.body);
expect(getResult.resource).toBeDefined();
expect(getResult.resource.name).toBe("test0");
expect(getResult.resource.colour).toBe("#000000");
expect(getResult.resource.company_id).toBe(platform.workspace.company_id);
done();
});
it("should 200 tag does not exist", async done => {
const user = await testDbService.createUser([testDbService.defaultWorkspace()], {
companyRole: "member",
});
const jwtToken = await platform.auth.getJWTToken({ sub: user.id });
const getTag = await platform.app.inject({
method: "GET",
url: `${url}/companies/${platform.workspace.company_id}/tags/NonExistingTag`,
headers: {
authorization: `Bearer ${jwtToken}`,
},
});
expect(getTag.statusCode).toBe(200);
const getResult: ResourceGetResponse<Tag> = deserialize(ResourceGetResponse, getTag.body);
expect(getResult.resource).toBe(null);
done();
});
});
describe("Update tag", () => {
it("Should 204 if user is admin", async done => {
const user = await testDbService.createUser([testDbService.defaultWorkspace()], {
companyRole: "admin",
});
const jwtToken = await platform.auth.getJWTToken({ sub: user.id });
const updateTag = await platform.app.inject({
method: "POST",
url: `${url}/companies/${platform.workspace.company_id}/tags`,
headers: {
authorization: `Bearer ${jwtToken}`,
},
payload: {
name: "test1",
colour: "#000003",
},
});
expect(updateTag.statusCode).toBe(201);
const tagUpdatedResult: ResourceCreateResponse<Tag> = deserialize(
ResourceCreateResponse,
updateTag.body,
);
expect(tagUpdatedResult.resource).toBeDefined();
expect(tagUpdatedResult.resource.name).toBe("test1");
expect(tagUpdatedResult.resource.colour).toBe("#000003");
expect(tagUpdatedResult.resource.company_id).toBe(platform.workspace.company_id);
expect(tagUpdatedResult.resource.tag_id).toBe("test1");
const getUpdatedTag = await platform.app.inject({
method: "GET",
url: `${url}/companies/${platform.workspace.company_id}/tags/${tagUpdatedResult.resource.tag_id}`,
headers: {
authorization: `Bearer ${jwtToken}`,
},
});
expect(getUpdatedTag.statusCode).toBe(200);
done();
});
it("should 401 if creator is not a company admin", async done => {
const user = await testDbService.createUser([testDbService.defaultWorkspace()], {
companyRole: "member",
});
const jwtToken = await platform.auth.getJWTToken({ sub: user.id });
const createTag = await platform.app.inject({
method: "POST",
url: `${url}/companies/${platform.workspace.company_id}/tags/testNotAdmin`,
headers: {
authorization: `Bearer ${jwtToken}`,
},
payload: {
name: "testNotAdmin",
colour: "#000000",
},
});
expect(createTag.statusCode).toBe(401);
const tagResult: ResourceCreateResponse<Tag> = deserialize(
ResourceCreateResponse,
createTag.body,
);
console.log("tagResult2", tagResult, jwtToken);
expect(tagResult.resource).toBe(undefined);
done();
});
});
describe("List tags", () => {
it("should 200 list a tag", async done => {
const user = await testDbService.createUser([testDbService.defaultWorkspace()], {
companyRole: "member",
});
const jwtToken = await platform.auth.getJWTToken({ sub: user.id });
const listTag = await platform.app.inject({
method: "GET",
url: `${url}/companies/${platform.workspace.company_id}/tags`,
headers: {
authorization: `Bearer ${jwtToken}`,
},
});
expect(listTag.statusCode).toBe(200);
const tagResult: ResourceListResponse<Tag> = deserialize(ResourceListResponse, listTag.body);
expect(tagResult.resources).toBeDefined();
for (const tag of tagResult.resources) {
expect(tag.name).toBeDefined();
expect(tag.colour).toBeDefined();
expect(tag.company_id).toBe(platform.workspace.company_id);
expect(tag.tag_id).toBeDefined();
}
done();
});
});
describe("Delete tag", () => {
it("should 200 if admin delete a tag", async done => {
const user = await testDbService.createUser([testDbService.defaultWorkspace()], {
companyRole: "admin",
});
const jwtToken = await platform.auth.getJWTToken({ sub: user.id });
const deleteTag = await platform.app.inject({
method: "DELETE",
url: `${url}/companies/${platform.workspace.company_id}/tags/${tagIds[0]}`,
headers: {
authorization: `Bearer ${jwtToken}`,
},
});
expect(deleteTag.statusCode).toBe(200);
done();
});
it("should 200 if tag does not exist", async done => {
const user = await testDbService.createUser([testDbService.defaultWorkspace()], {
companyRole: "admin",
});
const jwtToken = await platform.auth.getJWTToken({ sub: user.id });
const deleteTag = await platform.app.inject({
method: "DELETE",
url: `${url}/companies/${platform.workspace.company_id}/tags/NonExistingTag`,
headers: {
authorization: `Bearer ${jwtToken}`,
},
});
expect(deleteTag.statusCode).toBe(200);
done();
});
it("should 401 if not admin", async done => {
const user = await testDbService.createUser([testDbService.defaultWorkspace()], {
companyRole: "member",
});
const jwtToken = await platform.auth.getJWTToken({ sub: user.id });
const deleteTag = await platform.app.inject({
method: "DELETE",
url: `${url}/companies/${platform.workspace.company_id}/tags/${tagIds[0]}`,
headers: {
authorization: `Bearer ${jwtToken}`,
},
});
expect(deleteTag.statusCode).toBe(401);
done();
});
});
});
@@ -5,79 +5,64 @@ import {
buildComparison,
buildIn,
} from "../../../../../../../../../src/core/platform/services/database/services/orm/connectors/cassandra/query-builder";
import { ChannelMemberNotificationPreference } from "../../../../../../../../../src/services/notifications/entities/channel-member-notification-preferences";
import { DriveFile } from "../../../../../../../../../src/services/documents/entities/drive-file";
describe("The QueryBuilder module", () => {
describe("The buildSelectQuery function", () => {
it("should build a valid query from primary key parameters", () => {
const filters = {
company_id: "comp1",
channel_id: "chan1",
parent_id: "parent1",
};
const result = buildSelectQuery<ChannelMemberNotificationPreference>(
ChannelMemberNotificationPreference,
filters,
{},
{ keyspace: "tdrive" },
);
const result = buildSelectQuery<DriveFile>(DriveFile, filters, {}, { keyspace: "tdrive" });
expect(result).toEqual(
"SELECT * FROM tdrive.channel_members_notification_preferences WHERE company_id = comp1 AND channel_id = chan1;",
"SELECT * FROM tdrive.drive_files WHERE company_id = comp1 AND parent_id = 'parent1';",
);
});
it("should build a valid query from primary key parameters and comparison", () => {
const filters = {
company_id: "comp1",
channel_id: "chan1",
parent_id: "parent1",
};
const result = buildSelectQuery<ChannelMemberNotificationPreference>(
ChannelMemberNotificationPreference,
const result = buildSelectQuery<DriveFile>(
DriveFile,
filters,
{
$lt: [["last_read", 1000]],
$lt: [["size", 1000]],
},
{ keyspace: "tdrive" },
);
expect(result).toEqual(
"SELECT * FROM tdrive.channel_members_notification_preferences WHERE company_id = comp1 AND channel_id = chan1 AND last_read < 1000;",
"SELECT * FROM tdrive.drive_files WHERE company_id = comp1 AND parent_id = 'parent1' AND size < 1000;",
);
});
it("should build IN query from array parameters", () => {
const filters = {
company_id: "comp1",
channel_id: "chan1",
user_id: ["u1", "u2", "u3"],
parent_id: "parent1",
creator: ["u1", "u2", "u3"],
};
const result = buildSelectQuery<ChannelMemberNotificationPreference>(
ChannelMemberNotificationPreference,
filters,
{},
{ keyspace: "tdrive" },
);
const result = buildSelectQuery<DriveFile>(DriveFile, filters, {}, { keyspace: "tdrive" });
expect(result).toEqual(
"SELECT * FROM tdrive.channel_members_notification_preferences WHERE company_id = comp1 AND channel_id = chan1 AND user_id IN (u1,u2,u3);",
"SELECT * FROM tdrive.drive_files WHERE company_id = comp1 AND parent_id = 'parent1' AND creator IN (u1,u2,u3);",
);
});
it("should not build IN query from array parameters when array is empty", () => {
const filters = {
company_id: "comp1",
channel_id: "chan1",
parent_id: "parent1",
user_id: [],
};
const result = buildSelectQuery<ChannelMemberNotificationPreference>(
ChannelMemberNotificationPreference,
filters,
{},
{ keyspace: "tdrive" },
);
const result = buildSelectQuery<DriveFile>(DriveFile, filters, {}, { keyspace: "tdrive" });
expect(result).toEqual(
"SELECT * FROM tdrive.channel_members_notification_preferences WHERE company_id = comp1 AND channel_id = chan1;",
"SELECT * FROM tdrive.drive_files WHERE company_id = comp1 AND parent_id = 'parent1';",
);
});
});

Some files were not shown because too many files have changed in this diff Show More