@@ -0,0 +1,33 @@
|
||||
import Application, { TYPE } from "./application";
|
||||
|
||||
export default {
|
||||
index: TYPE,
|
||||
source: (entity: Application) => {
|
||||
return {
|
||||
company_id: entity.company_id,
|
||||
name: entity.identity.name,
|
||||
description: entity.identity.description,
|
||||
categories: entity.identity.categories,
|
||||
compatibility: entity.identity.compatibility,
|
||||
published: entity.publication.published,
|
||||
created_at: entity.stats.created_at,
|
||||
};
|
||||
},
|
||||
mongoMapping: {
|
||||
text: {
|
||||
name: "text",
|
||||
description: "text",
|
||||
},
|
||||
},
|
||||
esMapping: {
|
||||
properties: {
|
||||
company_id: { type: "keyword" },
|
||||
name: { type: "text", index_prefixes: { min_chars: 1 } },
|
||||
description: { type: "text" },
|
||||
categories: { type: "keyword" },
|
||||
compatibility: { type: "keyword" },
|
||||
published: { type: "boolean" },
|
||||
created_at: { type: "number" },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,212 @@
|
||||
import { Type } from "class-transformer";
|
||||
import _, { merge } from "lodash";
|
||||
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
|
||||
import search from "./application.search";
|
||||
|
||||
export const TYPE = "applications";
|
||||
|
||||
@Entity(TYPE, {
|
||||
primaryKey: ["id"],
|
||||
type: TYPE,
|
||||
search,
|
||||
})
|
||||
export default class Application {
|
||||
@Type(() => String)
|
||||
@Column("id", "timeuuid", { generator: "timeuuid" })
|
||||
id: string;
|
||||
|
||||
@Type(() => String)
|
||||
@Column("group_id", "timeuuid")
|
||||
company_id: string;
|
||||
|
||||
@Column("is_default", "boolean")
|
||||
is_default: boolean;
|
||||
|
||||
@Column("identity", "json")
|
||||
identity: ApplicationIdentity;
|
||||
|
||||
//This information is private to the application, make sure not to disclose it
|
||||
@Column("api", "encoded_json")
|
||||
api: ApplicationApi;
|
||||
|
||||
@Column("access", "json")
|
||||
access: ApplicationAccess;
|
||||
|
||||
@Column("display", "json")
|
||||
display: ApplicationDisplay;
|
||||
|
||||
@Column("publication", "json")
|
||||
publication: ApplicationPublication;
|
||||
|
||||
@Column("stats", "json")
|
||||
stats: ApplicationStatistics;
|
||||
|
||||
getPublicObject(): PublicApplicationObject {
|
||||
const i = _.pick(
|
||||
this,
|
||||
"id",
|
||||
"company_id",
|
||||
"is_default",
|
||||
"identity",
|
||||
"access",
|
||||
"display",
|
||||
"publication",
|
||||
"stats",
|
||||
);
|
||||
|
||||
i.is_default = !!i.is_default;
|
||||
return i;
|
||||
}
|
||||
|
||||
getApplicationObject(): ApplicationObject {
|
||||
const i = _.pick(
|
||||
this,
|
||||
"id",
|
||||
"company_id",
|
||||
"is_default",
|
||||
"identity",
|
||||
"access",
|
||||
"display",
|
||||
"publication",
|
||||
"stats",
|
||||
"api",
|
||||
);
|
||||
|
||||
i.is_default = !!i.is_default;
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
export type PublicApplicationObject = Pick<
|
||||
Application,
|
||||
"id" | "company_id" | "is_default" | "identity" | "access" | "display" | "publication" | "stats"
|
||||
>;
|
||||
|
||||
export type ApplicationObject = Pick<
|
||||
Application,
|
||||
| "id"
|
||||
| "company_id"
|
||||
| "is_default"
|
||||
| "identity"
|
||||
| "access"
|
||||
| "display"
|
||||
| "publication"
|
||||
| "stats"
|
||||
| "api"
|
||||
>;
|
||||
|
||||
export type ApplicationPrimaryKey = { id: string };
|
||||
|
||||
export function getInstance(message: Application): Application {
|
||||
return merge(new Application(), message);
|
||||
}
|
||||
|
||||
export type ApplicationIdentity = {
|
||||
code: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
website: string;
|
||||
categories: string[];
|
||||
compatibility: "tdrive"[];
|
||||
repository?: string;
|
||||
};
|
||||
|
||||
export type ApplicationPublication = {
|
||||
published: boolean; //Publication accepted // RO
|
||||
requested: boolean; //Publication requested
|
||||
};
|
||||
|
||||
export type ApplicationStatistics = {
|
||||
created_at: number; // RO
|
||||
updated_at: number; // RO
|
||||
version: number; // RO
|
||||
};
|
||||
|
||||
export type ApplicationApi = {
|
||||
hooks_url: string;
|
||||
allowed_ips: string;
|
||||
private_key: string; // RO
|
||||
};
|
||||
|
||||
type ApplicationScopes =
|
||||
| "files"
|
||||
| "applications"
|
||||
| "workspaces"
|
||||
| "users"
|
||||
| "messages"
|
||||
| "channels";
|
||||
|
||||
export type ApplicationAccess = {
|
||||
read: ApplicationScopes[];
|
||||
write: ApplicationScopes[];
|
||||
delete: ApplicationScopes[];
|
||||
hooks: ApplicationScopes[];
|
||||
};
|
||||
|
||||
export type ApplicationDisplay = {
|
||||
tdrive: {
|
||||
files?: {
|
||||
editor?: {
|
||||
preview_url: string; //Open a preview inline (iframe)
|
||||
edition_url: string; //Url to edit the file (full screen)
|
||||
extensions?: string[]; //Main extensions app can read
|
||||
// if file was created by the app, then the app is able to edit with or without extension
|
||||
empty_files?: {
|
||||
url: string; // "https://[...]/empty.docx";
|
||||
filename: string; // "Untitled.docx";
|
||||
name: string; // "Word Document";
|
||||
}[];
|
||||
};
|
||||
actions?: //List of action that can apply on a file
|
||||
{
|
||||
name: string;
|
||||
id: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
//Chat plugin
|
||||
chat?: {
|
||||
input?:
|
||||
| true
|
||||
| {
|
||||
icon?: string; //If defined replace original icon url of your app
|
||||
type?: "file" | "call"; //To add in existing apps folder / default icon
|
||||
};
|
||||
commands?: {
|
||||
command: string; // my_app mycommand
|
||||
description: string;
|
||||
}[];
|
||||
actions?: //List of action that can apply on a message
|
||||
{
|
||||
name: string;
|
||||
id: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
//Allow app to appear as a bot user in direct chat
|
||||
direct?:
|
||||
| true
|
||||
| {
|
||||
name?: string;
|
||||
icon?: string; //If defined replace original icon url of your app
|
||||
};
|
||||
|
||||
//Display app as a standalone application in a tab
|
||||
tab?:
|
||||
| {
|
||||
url: string;
|
||||
}
|
||||
| true;
|
||||
|
||||
//Display app as a standalone application on the left bar
|
||||
standalone?:
|
||||
| {
|
||||
url: string;
|
||||
}
|
||||
| true;
|
||||
|
||||
//Define where the app can be configured from
|
||||
configuration?: ("global" | "channel")[];
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Type } from "class-transformer";
|
||||
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
|
||||
import { PublicApplicationObject } from "./application";
|
||||
|
||||
export const TYPE = "group_app";
|
||||
|
||||
@Entity(TYPE, {
|
||||
primaryKey: [["group_id"], "app_id", "id"],
|
||||
type: TYPE,
|
||||
})
|
||||
export default class CompanyApplication {
|
||||
@Type(() => String)
|
||||
@Column("group_id", "timeuuid")
|
||||
company_id: string;
|
||||
|
||||
@Type(() => String)
|
||||
@Column("app_id", "timeuuid")
|
||||
application_id: string;
|
||||
|
||||
@Type(() => String)
|
||||
@Column("id", "timeuuid", { generator: "timeuuid" })
|
||||
id: string;
|
||||
|
||||
@Column("created_at", "number")
|
||||
created_at: number;
|
||||
|
||||
@Type(() => String)
|
||||
@Column("created_by", "string")
|
||||
created_by: string; //Will be the default delegated user when doing actions on Tdrive
|
||||
}
|
||||
|
||||
export type CompanyApplicationPrimaryKey = Pick<
|
||||
CompanyApplication,
|
||||
"company_id" | "application_id" | "id"
|
||||
>;
|
||||
|
||||
export class CompanyApplicationWithApplication extends CompanyApplication {
|
||||
//Not in database but attached to this object
|
||||
application?: PublicApplicationObject;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Prefix, TdriveService } from "../../core/platform/framework";
|
||||
import WebServerAPI from "../../core/platform/services/webserver/provider";
|
||||
import web from "./web";
|
||||
|
||||
@Prefix("/internal/services/applications/v1")
|
||||
export default class ApplicationsService extends TdriveService<undefined> {
|
||||
version = "1";
|
||||
name = "applications";
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { WebsocketMetadata } from "../../utils/types";
|
||||
|
||||
export function getCompanyApplicationRooms(companyId: string): WebsocketMetadata[] {
|
||||
return [
|
||||
{
|
||||
room: getCompanyApplicationRoom(companyId),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function getCompanyApplicationRoom(companyApplicationId: string): string {
|
||||
return `/company-application/${companyApplicationId}`;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import Application, {
|
||||
ApplicationPrimaryKey,
|
||||
getInstance as getApplicationInstance,
|
||||
PublicApplicationObject,
|
||||
TYPE,
|
||||
} from "../entities/application";
|
||||
import Repository from "../../../core/platform/services/database/services/orm/repository/repository";
|
||||
import { Initializable, logger, TdriveServiceProvider } from "../../../core/platform/framework";
|
||||
import {
|
||||
DeleteResult,
|
||||
ExecutionContext,
|
||||
ListResult,
|
||||
OperationType,
|
||||
Pagination,
|
||||
SaveResult,
|
||||
} from "../../../core/platform/framework/api/crud-service";
|
||||
import SearchRepository from "../../../core/platform/services/search/repository";
|
||||
import assert from "assert";
|
||||
|
||||
import gr from "../../global-resolver";
|
||||
|
||||
export class ApplicationServiceImpl implements TdriveServiceProvider, Initializable {
|
||||
version: "1";
|
||||
repository: Repository<Application>;
|
||||
searchRepository: SearchRepository<Application>;
|
||||
|
||||
async init(): Promise<this> {
|
||||
try {
|
||||
this.searchRepository = gr.platformServices.search.getRepository<Application>(
|
||||
TYPE,
|
||||
Application,
|
||||
);
|
||||
this.repository = await gr.database.getRepository<Application>(TYPE, Application);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
logger.error("Error while initializing applications service");
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
async get(pk: ApplicationPrimaryKey, context: ExecutionContext): Promise<Application> {
|
||||
return await this.repository.findOne(pk, {}, context);
|
||||
}
|
||||
|
||||
async list(
|
||||
pagination: Pagination,
|
||||
options?: { search?: string },
|
||||
context?: ExecutionContext,
|
||||
): Promise<ListResult<PublicApplicationObject>> {
|
||||
let entities: ListResult<Application>;
|
||||
if (options.search) {
|
||||
entities = await this.searchRepository.search(
|
||||
{},
|
||||
{
|
||||
pagination,
|
||||
$text: {
|
||||
$search: options.search,
|
||||
},
|
||||
},
|
||||
context,
|
||||
);
|
||||
} else {
|
||||
entities = await this.repository.find({}, { pagination }, context);
|
||||
}
|
||||
entities.filterEntities(app => app.publication.published);
|
||||
|
||||
const applications = entities
|
||||
.getEntities()
|
||||
.filter(app => app)
|
||||
.map(app => app.getPublicObject());
|
||||
return new ListResult(entities.type, applications, entities.nextPage);
|
||||
}
|
||||
|
||||
async listUnpublished(context: ExecutionContext): Promise<Application[]> {
|
||||
const entities = await this.repository.find({}, {}, context);
|
||||
entities.filterEntities(app => !app.publication.published);
|
||||
return entities.getEntities();
|
||||
}
|
||||
|
||||
async listDefaults(context: ExecutionContext): Promise<ListResult<PublicApplicationObject>> {
|
||||
const entities = [];
|
||||
|
||||
let page: Pagination = { limitStr: "100" };
|
||||
do {
|
||||
const applicationListResult = await this.repository.find({}, { pagination: page }, context);
|
||||
page = applicationListResult.nextPage as Pagination;
|
||||
applicationListResult.filterEntities(app => app.publication.published && app.is_default);
|
||||
|
||||
for (const application of applicationListResult.getEntities()) {
|
||||
if (application) entities.push(application.getPublicObject());
|
||||
}
|
||||
} while (page.page_token);
|
||||
|
||||
return new ListResult(TYPE, entities);
|
||||
}
|
||||
|
||||
async save<SaveOptions>(
|
||||
item: Application,
|
||||
options?: SaveOptions,
|
||||
context?: ExecutionContext,
|
||||
): Promise<SaveResult<Application>> {
|
||||
assert(item.company_id, "company_id is not defined");
|
||||
|
||||
try {
|
||||
const entity = getApplicationInstance(item);
|
||||
await this.repository.save(entity, context);
|
||||
return new SaveResult<Application>("application", entity, OperationType.UPDATE);
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async delete(
|
||||
pk: ApplicationPrimaryKey,
|
||||
context?: ExecutionContext,
|
||||
): Promise<DeleteResult<Application>> {
|
||||
const entity = await this.get(pk, context);
|
||||
await this.repository.remove(entity, context);
|
||||
return new DeleteResult<Application>("application", entity, true);
|
||||
}
|
||||
|
||||
async publish(pk: ApplicationPrimaryKey, context: ExecutionContext): Promise<void> {
|
||||
const entity = await this.get(pk, context);
|
||||
if (!entity) {
|
||||
throw new Error("Entity not found");
|
||||
}
|
||||
entity.publication.published = true;
|
||||
await this.repository.save(entity, context);
|
||||
}
|
||||
|
||||
async unpublish(pk: ApplicationPrimaryKey, context: ExecutionContext): Promise<void> {
|
||||
const entity = await this.get(pk, context);
|
||||
if (!entity) {
|
||||
throw new Error("Entity not found");
|
||||
}
|
||||
entity.publication.published = false;
|
||||
await this.repository.save(entity, context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import CompanyApplication, {
|
||||
CompanyApplicationPrimaryKey,
|
||||
CompanyApplicationWithApplication,
|
||||
TYPE,
|
||||
} from "../entities/company-application";
|
||||
import Repository from "../../../core/platform/services/database/services/orm/repository/repository";
|
||||
import {
|
||||
Initializable,
|
||||
logger,
|
||||
RealtimeDeleted,
|
||||
RealtimeSaved,
|
||||
TdriveServiceProvider,
|
||||
} from "../../../core/platform/framework";
|
||||
import {
|
||||
DeleteResult,
|
||||
ListResult,
|
||||
OperationType,
|
||||
Paginable,
|
||||
Pagination,
|
||||
SaveResult,
|
||||
} from "../../../core/platform/framework/api/crud-service";
|
||||
import { CompanyExecutionContext } from "../web/types";
|
||||
import { getCompanyApplicationRoom } from "../realtime";
|
||||
import gr from "../../global-resolver";
|
||||
|
||||
export class CompanyApplicationServiceImpl implements TdriveServiceProvider, Initializable {
|
||||
version: "1";
|
||||
repository: Repository<CompanyApplication>;
|
||||
|
||||
async init(): Promise<this> {
|
||||
try {
|
||||
this.repository = await gr.database.getRepository<CompanyApplication>(
|
||||
TYPE,
|
||||
CompanyApplication,
|
||||
);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
logger.error("Error while initializing applications service");
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
// TODO: remove logic from context
|
||||
async get(
|
||||
pk: Pick<CompanyApplicationPrimaryKey, "company_id" | "application_id"> & { id?: string },
|
||||
context?: CompanyExecutionContext,
|
||||
): Promise<CompanyApplicationWithApplication> {
|
||||
const companyApplication = await this.repository.findOne(
|
||||
{
|
||||
group_id: context ? context.company.id : pk.company_id,
|
||||
app_id: pk.application_id,
|
||||
},
|
||||
{},
|
||||
context,
|
||||
);
|
||||
|
||||
const application = await gr.services.applications.marketplaceApps.get(
|
||||
{
|
||||
id: pk.application_id,
|
||||
},
|
||||
context,
|
||||
);
|
||||
|
||||
return {
|
||||
...companyApplication,
|
||||
application: application,
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
@RealtimeSaved<CompanyApplication>((companyApplication, _context) => {
|
||||
return [
|
||||
{
|
||||
room: getCompanyApplicationRoom(companyApplication.id),
|
||||
resource: companyApplication,
|
||||
},
|
||||
];
|
||||
})
|
||||
async save<SaveOptions>(
|
||||
item: Pick<CompanyApplicationPrimaryKey, "company_id" | "application_id">,
|
||||
_?: SaveOptions,
|
||||
context?: CompanyExecutionContext,
|
||||
): Promise<SaveResult<CompanyApplication>> {
|
||||
if (!context?.user?.id && !context?.user?.server_request) {
|
||||
throw new Error("Only an user of a company can add an application to a company.");
|
||||
}
|
||||
|
||||
let operation = OperationType.UPDATE;
|
||||
let companyApplication = await this.repository.findOne(
|
||||
{
|
||||
group_id: context?.company.id,
|
||||
app_id: item.application_id,
|
||||
},
|
||||
{},
|
||||
context,
|
||||
);
|
||||
if (!companyApplication) {
|
||||
operation = OperationType.CREATE;
|
||||
|
||||
companyApplication = new CompanyApplication();
|
||||
companyApplication.company_id = context.company.id;
|
||||
companyApplication.application_id = item.application_id;
|
||||
companyApplication.created_at = new Date().getTime();
|
||||
companyApplication.created_by = context?.user?.id || "";
|
||||
|
||||
await this.repository.save(companyApplication, context);
|
||||
}
|
||||
|
||||
return new SaveResult(TYPE, companyApplication, operation);
|
||||
}
|
||||
|
||||
async initWithDefaultApplications(
|
||||
companyId: string,
|
||||
context: CompanyExecutionContext,
|
||||
): Promise<void> {
|
||||
const defaultApps = await gr.services.applications.marketplaceApps.listDefaults(context);
|
||||
for (const defaultApp of defaultApps.getEntities()) {
|
||||
await this.save({ company_id: companyId, application_id: defaultApp.id }, {}, context);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
@RealtimeDeleted<CompanyApplication>((companyApplication, _context) => {
|
||||
return [
|
||||
{
|
||||
room: getCompanyApplicationRoom(companyApplication.id),
|
||||
resource: companyApplication,
|
||||
},
|
||||
];
|
||||
})
|
||||
async delete(
|
||||
pk: CompanyApplicationPrimaryKey,
|
||||
context?: CompanyExecutionContext,
|
||||
): Promise<DeleteResult<CompanyApplication>> {
|
||||
const companyApplication = await this.repository.findOne(
|
||||
{
|
||||
group_id: context.company.id,
|
||||
app_id: pk.application_id,
|
||||
},
|
||||
{},
|
||||
context,
|
||||
);
|
||||
|
||||
let deleted = false;
|
||||
if (companyApplication) {
|
||||
this.repository.remove(companyApplication, context);
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
return new DeleteResult(TYPE, companyApplication, deleted);
|
||||
}
|
||||
|
||||
async list<ListOptions>(
|
||||
pagination: Paginable,
|
||||
options?: ListOptions,
|
||||
context?: CompanyExecutionContext,
|
||||
): Promise<ListResult<CompanyApplicationWithApplication>> {
|
||||
const companyApplications = await this.repository.find(
|
||||
{
|
||||
group_id: context.company.id,
|
||||
},
|
||||
{ pagination: Pagination.fromPaginable(pagination) },
|
||||
context,
|
||||
);
|
||||
|
||||
const applications = [];
|
||||
|
||||
for (const companyApplication of companyApplications.getEntities()) {
|
||||
const application = await gr.services.applications.marketplaceApps.get(
|
||||
{
|
||||
id: companyApplication.application_id,
|
||||
},
|
||||
context,
|
||||
);
|
||||
if (application)
|
||||
applications.push({
|
||||
...companyApplication,
|
||||
application: application,
|
||||
});
|
||||
}
|
||||
|
||||
return new ListResult<CompanyApplicationWithApplication>(
|
||||
TYPE,
|
||||
applications,
|
||||
companyApplications.nextPage,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import Application from "../entities/application";
|
||||
import Repository from "../../../core/platform/services/database/services/orm/repository/repository";
|
||||
import {
|
||||
Initializable,
|
||||
logger as log,
|
||||
TdriveServiceProvider,
|
||||
} from "../../../core/platform/framework";
|
||||
import { CrudException, ExecutionContext } from "../../../core/platform/framework/api/crud-service";
|
||||
import SearchRepository from "../../../core/platform/services/search/repository";
|
||||
import axios from "axios";
|
||||
import * as crypto from "crypto";
|
||||
import { isObject } from "lodash";
|
||||
import gr from "../../global-resolver";
|
||||
|
||||
export class ApplicationHooksService implements TdriveServiceProvider, Initializable {
|
||||
version: "1";
|
||||
repository: Repository<Application>;
|
||||
searchRepository: SearchRepository<Application>;
|
||||
|
||||
async init() {
|
||||
return this;
|
||||
}
|
||||
|
||||
async notifyApp(
|
||||
application_id: string,
|
||||
connection_id: string,
|
||||
user_id: string,
|
||||
type: string,
|
||||
name: string,
|
||||
content: any,
|
||||
company_id: string,
|
||||
workspace_id: string,
|
||||
context: ExecutionContext,
|
||||
): Promise<void> {
|
||||
const app = await gr.services.applications.marketplaceApps.get({ id: application_id }, context);
|
||||
if (!app) {
|
||||
throw CrudException.notFound("Application not found");
|
||||
}
|
||||
|
||||
if (!app.api.hooks_url) {
|
||||
throw CrudException.badRequest("Application hooks_url is not defined");
|
||||
}
|
||||
|
||||
const payload = {
|
||||
type,
|
||||
name,
|
||||
content,
|
||||
connection_id: connection_id,
|
||||
user_id: user_id,
|
||||
company_id,
|
||||
workspace_id,
|
||||
};
|
||||
|
||||
const signature = crypto
|
||||
.createHmac("sha256", app.api.private_key)
|
||||
.update(JSON.stringify(payload))
|
||||
.digest("hex");
|
||||
|
||||
return await axios
|
||||
.post(app.api.hooks_url, payload, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Tdrive-Signature": signature,
|
||||
},
|
||||
})
|
||||
|
||||
.then(({ data }) => data)
|
||||
.catch(e => {
|
||||
log.error(e.message);
|
||||
const r = e.response;
|
||||
|
||||
if (!r) {
|
||||
throw CrudException.badGateway("Can't connect remote application");
|
||||
}
|
||||
|
||||
let msg = r.data;
|
||||
|
||||
if (isObject(msg)) {
|
||||
// parse typical responses
|
||||
if (r.data.message) {
|
||||
msg = r.data.message;
|
||||
} else if (r.data.error) {
|
||||
msg = r.data.error;
|
||||
} else {
|
||||
msg = JSON.stringify(r.data);
|
||||
}
|
||||
}
|
||||
|
||||
if (r.status == 403) {
|
||||
throw CrudException.forbidden(msg);
|
||||
} else {
|
||||
throw CrudException.badRequest(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { CrudController } from "../../../../core/platform/services/webserver/types";
|
||||
import {
|
||||
PaginationQueryParameters,
|
||||
ResourceCreateResponse,
|
||||
ResourceDeleteResponse,
|
||||
ResourceGetResponse,
|
||||
ResourceListResponse,
|
||||
ResourceUpdateResponse,
|
||||
} from "../../../../utils/types";
|
||||
import Application, {
|
||||
ApplicationObject,
|
||||
PublicApplicationObject,
|
||||
} from "../../entities/application";
|
||||
import {
|
||||
CrudException,
|
||||
ExecutionContext,
|
||||
Pagination,
|
||||
} from "../../../../core/platform/framework/api/crud-service";
|
||||
import _ from "lodash";
|
||||
import { randomBytes } from "crypto";
|
||||
import { ApplicationEventRequestBody } from "../types";
|
||||
import { logger as log } from "../../../../core/platform/framework";
|
||||
import { hasCompanyAdminLevel } from "../../../../utils/company";
|
||||
import gr from "../../../global-resolver";
|
||||
import config from "../../../../core/config";
|
||||
import axios from "axios";
|
||||
|
||||
export class ApplicationController
|
||||
implements
|
||||
CrudController<
|
||||
ResourceGetResponse<PublicApplicationObject>,
|
||||
ResourceUpdateResponse<PublicApplicationObject>,
|
||||
ResourceListResponse<PublicApplicationObject>,
|
||||
ResourceDeleteResponse
|
||||
>
|
||||
{
|
||||
async get(
|
||||
request: FastifyRequest<{ Params: { application_id: string } }>,
|
||||
): Promise<ResourceGetResponse<ApplicationObject | PublicApplicationObject>> {
|
||||
const context = getExecutionContext(request);
|
||||
|
||||
const entity = await gr.services.applications.marketplaceApps.get(
|
||||
{
|
||||
id: request.params.application_id,
|
||||
},
|
||||
context,
|
||||
);
|
||||
|
||||
const companyUser = await gr.services.companies.getCompanyUser(
|
||||
{ id: entity.company_id },
|
||||
{ id: context.user.id },
|
||||
);
|
||||
|
||||
const isAdmin = companyUser && companyUser.role == "admin";
|
||||
|
||||
return {
|
||||
resource: isAdmin ? entity.getApplicationObject() : entity.getPublicObject(),
|
||||
};
|
||||
}
|
||||
|
||||
async list(
|
||||
request: FastifyRequest<{
|
||||
Querystring: PaginationQueryParameters & { search: string };
|
||||
}>,
|
||||
): Promise<ResourceListResponse<PublicApplicationObject>> {
|
||||
const entities = await gr.services.applications.marketplaceApps.list(new Pagination(), {
|
||||
search: request.query.search,
|
||||
});
|
||||
return {
|
||||
resources: entities.getEntities(),
|
||||
next_page_token: entities.nextPage.page_token,
|
||||
};
|
||||
}
|
||||
|
||||
async save(
|
||||
request: FastifyRequest<{
|
||||
Params: { application_id: string };
|
||||
Body: { resource: Application };
|
||||
}>,
|
||||
_reply: FastifyReply,
|
||||
): Promise<ResourceGetResponse<ApplicationObject | PublicApplicationObject>> {
|
||||
const context = getExecutionContext(request);
|
||||
|
||||
try {
|
||||
const app = request.body.resource;
|
||||
const now = new Date().getTime();
|
||||
const pluginsEndpoint = config.get("plugins.api");
|
||||
|
||||
let entity: Application;
|
||||
|
||||
if (request.params.application_id) {
|
||||
entity = await gr.services.applications.marketplaceApps.get(
|
||||
{
|
||||
id: request.params.application_id,
|
||||
},
|
||||
context,
|
||||
);
|
||||
|
||||
if (!entity) {
|
||||
throw CrudException.notFound("Application not found");
|
||||
}
|
||||
|
||||
entity.publication.requested = app.publication.requested;
|
||||
if (app.publication.requested === false) {
|
||||
entity.publication.published = false;
|
||||
}
|
||||
|
||||
if (entity.publication.published) {
|
||||
if (
|
||||
!_.isEqual(
|
||||
_.pick(entity, "identity", "api", "access", "display"),
|
||||
_.pick(app, "identity", "api", "access", "display"),
|
||||
)
|
||||
) {
|
||||
throw CrudException.badRequest(
|
||||
"You can't update applications details while it published",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
entity.identity = app.identity;
|
||||
entity.api.hooks_url = app.api.hooks_url;
|
||||
entity.api.allowed_ips = app.api.allowed_ips;
|
||||
entity.access = app.access;
|
||||
entity.display = app.display;
|
||||
|
||||
entity.stats.updated_at = now;
|
||||
entity.stats.version++;
|
||||
|
||||
const res = await gr.services.applications.marketplaceApps.save(entity);
|
||||
entity = res.entity;
|
||||
} else {
|
||||
// INSERT
|
||||
|
||||
app.is_default = false;
|
||||
app.publication.published = false;
|
||||
app.api.private_key = randomBytes(32).toString("base64");
|
||||
|
||||
app.stats = {
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
version: 0,
|
||||
};
|
||||
|
||||
const res = await gr.services.applications.marketplaceApps.save(app);
|
||||
entity = res.entity;
|
||||
}
|
||||
|
||||
// SYNC PLUGINS
|
||||
if (app.identity.repository) {
|
||||
try {
|
||||
axios
|
||||
.post(
|
||||
`${pluginsEndpoint}/add`,
|
||||
{
|
||||
gitRepo: app.identity.repository,
|
||||
pluginId: entity.getApplicationObject().id,
|
||||
pluginSecret: entity.getApplicationObject().api.private_key,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
)
|
||||
.then(response => {
|
||||
log.info(response.data);
|
||||
})
|
||||
.catch(error => {
|
||||
log.error(error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
resource: entity.getApplicationObject(),
|
||||
};
|
||||
} catch (e) {
|
||||
log.error(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async delete(
|
||||
request: FastifyRequest<{ Params: { application_id: string } }>,
|
||||
reply: FastifyReply,
|
||||
): Promise<ResourceDeleteResponse> {
|
||||
const context = getExecutionContext(request);
|
||||
|
||||
const application = await gr.services.applications.marketplaceApps.get(
|
||||
{
|
||||
id: request.params.application_id,
|
||||
},
|
||||
context,
|
||||
);
|
||||
|
||||
const compUser = await gr.services.companies.getCompanyUser(
|
||||
{ id: application.company_id },
|
||||
{ id: context.user.id },
|
||||
);
|
||||
if (!compUser || !hasCompanyAdminLevel(compUser.role)) {
|
||||
throw CrudException.forbidden("You don't have the rights to delete this application");
|
||||
}
|
||||
|
||||
const deleteResult = await gr.services.applications.marketplaceApps.delete(
|
||||
{
|
||||
id: request.params.application_id,
|
||||
},
|
||||
context,
|
||||
);
|
||||
|
||||
if (deleteResult.deleted) {
|
||||
reply.code(204);
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
async event(
|
||||
request: FastifyRequest<{
|
||||
Body: ApplicationEventRequestBody;
|
||||
Params: { application_id: string };
|
||||
}>,
|
||||
_reply: FastifyReply,
|
||||
): Promise<ResourceCreateResponse<any>> {
|
||||
const context = getExecutionContext(request);
|
||||
|
||||
const content = request.body.data;
|
||||
|
||||
const applicationEntity = await gr.services.applications.marketplaceApps.get(
|
||||
{
|
||||
id: request.params.application_id,
|
||||
},
|
||||
context,
|
||||
);
|
||||
|
||||
if (!applicationEntity) {
|
||||
throw CrudException.notFound("Application not found");
|
||||
}
|
||||
|
||||
const companyUser = gr.services.companies.getCompanyUser(
|
||||
{ id: request.body.company_id },
|
||||
{ id: context.user.id },
|
||||
);
|
||||
|
||||
if (!companyUser) {
|
||||
throw CrudException.badRequest(
|
||||
"You cannot send event to an application from another company",
|
||||
);
|
||||
}
|
||||
|
||||
const applicationInCompany = await gr.services.applications.companyApps.get({
|
||||
company_id: request.body.company_id,
|
||||
application_id: request.params.application_id,
|
||||
id: undefined,
|
||||
});
|
||||
|
||||
if (!applicationInCompany) {
|
||||
throw CrudException.badRequest("Application isn't installed in this company");
|
||||
}
|
||||
|
||||
const hookResponse = await gr.services.applications.hooks.notifyApp(
|
||||
request.params.application_id,
|
||||
request.body.connection_id,
|
||||
context.user.id,
|
||||
request.body.type,
|
||||
request.body.name,
|
||||
content,
|
||||
request.body.company_id,
|
||||
request.body.workspace_id,
|
||||
context,
|
||||
);
|
||||
|
||||
return {
|
||||
resource: hookResponse,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getExecutionContext(request: FastifyRequest): ExecutionContext {
|
||||
return {
|
||||
user: request.currentUser,
|
||||
url: request.url,
|
||||
method: request.routerMethod,
|
||||
transport: "http",
|
||||
};
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
|
||||
import {
|
||||
PaginationQueryParameters,
|
||||
ResourceDeleteResponse,
|
||||
ResourceGetResponse,
|
||||
ResourceListResponse,
|
||||
ResourceUpdateResponse,
|
||||
} from "../../../../utils/types";
|
||||
import { PublicApplicationObject } from "../../entities/application";
|
||||
import { CompanyExecutionContext } from "../types";
|
||||
import { CrudController } from "../../../../core/platform/services/webserver/types";
|
||||
import { getCompanyApplicationRooms } from "../../realtime";
|
||||
import gr from "../../../global-resolver";
|
||||
|
||||
export class CompanyApplicationController
|
||||
implements
|
||||
CrudController<
|
||||
ResourceGetResponse<PublicApplicationObject>,
|
||||
ResourceUpdateResponse<PublicApplicationObject>,
|
||||
ResourceListResponse<PublicApplicationObject>,
|
||||
ResourceDeleteResponse
|
||||
>
|
||||
{
|
||||
async get(
|
||||
request: FastifyRequest<{ Params: { company_id: string; application_id: string } }>,
|
||||
): Promise<ResourceGetResponse<PublicApplicationObject>> {
|
||||
const context = getCompanyExecutionContext(request);
|
||||
const resource = await gr.services.applications.companyApps.get(
|
||||
{
|
||||
application_id: request.params.application_id,
|
||||
company_id: context.company.id,
|
||||
id: undefined,
|
||||
},
|
||||
context,
|
||||
);
|
||||
return {
|
||||
resource: resource?.application,
|
||||
};
|
||||
}
|
||||
|
||||
async list(
|
||||
request: FastifyRequest<{
|
||||
Params: { company_id: string };
|
||||
Querystring: PaginationQueryParameters & { search: string };
|
||||
}>,
|
||||
): Promise<ResourceListResponse<PublicApplicationObject>> {
|
||||
const context = getCompanyExecutionContext(request);
|
||||
const resources = await gr.services.applications.companyApps.list(
|
||||
request.query,
|
||||
{ search: request.query.search },
|
||||
context,
|
||||
);
|
||||
|
||||
return {
|
||||
resources: resources.getEntities().map(ca => ca.application),
|
||||
next_page_token: resources.nextPage.page_token,
|
||||
websockets:
|
||||
gr.platformServices.realtime.sign(
|
||||
getCompanyApplicationRooms(request.params.company_id),
|
||||
context.user.id,
|
||||
) || [],
|
||||
};
|
||||
}
|
||||
|
||||
async save(
|
||||
request: FastifyRequest<{
|
||||
Params: { company_id: string; application_id: string };
|
||||
Body: PublicApplicationObject;
|
||||
}>,
|
||||
): Promise<ResourceGetResponse<PublicApplicationObject>> {
|
||||
const context = getCompanyExecutionContext(request);
|
||||
|
||||
const resource = await gr.services.applications.companyApps.save(
|
||||
{ application_id: request.params.application_id, company_id: context.company.id },
|
||||
{},
|
||||
context,
|
||||
);
|
||||
|
||||
const app = await gr.services.applications.companyApps.get(resource.entity);
|
||||
|
||||
return {
|
||||
resource: app.application,
|
||||
};
|
||||
}
|
||||
|
||||
async delete(
|
||||
request: FastifyRequest<{ Params: { company_id: string; application_id: string } }>,
|
||||
_reply: FastifyReply,
|
||||
): Promise<ResourceDeleteResponse> {
|
||||
const context = getCompanyExecutionContext(request);
|
||||
const resource = await gr.services.applications.companyApps.delete(
|
||||
{
|
||||
application_id: request.params.application_id,
|
||||
company_id: context.company.id,
|
||||
id: undefined,
|
||||
},
|
||||
context,
|
||||
);
|
||||
return {
|
||||
status: resource.deleted ? "success" : "error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getCompanyExecutionContext(
|
||||
request: FastifyRequest<{
|
||||
Params: { company_id: string };
|
||||
}>,
|
||||
): CompanyExecutionContext {
|
||||
return {
|
||||
user: request.currentUser,
|
||||
company: { id: request.params.company_id },
|
||||
url: request.url,
|
||||
method: request.routerMethod,
|
||||
reqId: request.id,
|
||||
transport: "http",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./applications";
|
||||
@@ -0,0 +1,12 @@
|
||||
import { FastifyInstance, FastifyRegisterOptions } from "fastify";
|
||||
import routes from "./routes";
|
||||
|
||||
export default (
|
||||
fastify: FastifyInstance,
|
||||
options: FastifyRegisterOptions<{
|
||||
prefix: string;
|
||||
}>,
|
||||
): void => {
|
||||
fastify.log.debug("Configuring /internal/services/applications/v1 routes");
|
||||
fastify.register(routes, options);
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
import { FastifyInstance, FastifyPluginCallback, FastifyRequest } from "fastify";
|
||||
import { ApplicationController } from "./controllers/applications";
|
||||
import { CompanyApplicationController } from "./controllers/company-applications";
|
||||
|
||||
import Application from "../entities/application";
|
||||
import { applicationEventHookSchema, applicationPostSchema } from "./schemas";
|
||||
import { logger as log } from "../../../core/platform/framework";
|
||||
import { checkUserBelongsToCompany, hasCompanyAdminLevel } from "../../../utils/company";
|
||||
import gr from "../../global-resolver";
|
||||
|
||||
const applicationsUrl = "/applications";
|
||||
const companyApplicationsUrl = "/companies/:company_id/applications";
|
||||
|
||||
const routes: FastifyPluginCallback = (fastify: FastifyInstance, options, next) => {
|
||||
const applicationController = new ApplicationController();
|
||||
const companyApplicationController = new CompanyApplicationController();
|
||||
|
||||
const adminCheck = async (
|
||||
request: FastifyRequest<{
|
||||
Body: { resource: Application };
|
||||
Params: { application_id: string };
|
||||
}>,
|
||||
) => {
|
||||
try {
|
||||
let companyId: string = request.body?.resource?.company_id;
|
||||
|
||||
if (request.params.application_id) {
|
||||
const application = await gr.services.applications.marketplaceApps.get(
|
||||
{
|
||||
id: request.params.application_id,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
if (!application) {
|
||||
throw fastify.httpErrors.notFound("Application is not defined");
|
||||
}
|
||||
|
||||
companyId = application.company_id;
|
||||
}
|
||||
|
||||
const userId = request.currentUser.id;
|
||||
|
||||
if (!companyId) {
|
||||
throw fastify.httpErrors.forbidden(`Company ${companyId} not found`);
|
||||
}
|
||||
|
||||
const companyUser = await checkUserBelongsToCompany(userId, companyId);
|
||||
|
||||
if (!hasCompanyAdminLevel(companyUser.role)) {
|
||||
throw fastify.httpErrors.forbidden("You must be an admin of this company");
|
||||
}
|
||||
} catch (e) {
|
||||
log.error(e);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Applications collection
|
||||
* Marketplace of applications
|
||||
*/
|
||||
|
||||
//Get and search list of applications in the marketplace
|
||||
fastify.route({
|
||||
method: "GET",
|
||||
url: `${applicationsUrl}`,
|
||||
preValidation: [fastify.authenticate],
|
||||
// schema: applicationGetSchema,
|
||||
handler: applicationController.list.bind(applicationController),
|
||||
});
|
||||
|
||||
//Get a single application in the marketplace
|
||||
fastify.route({
|
||||
method: "GET",
|
||||
url: `${applicationsUrl}/:application_id`,
|
||||
preValidation: [fastify.authenticate],
|
||||
// schema: applicationGetSchema,
|
||||
handler: applicationController.get.bind(applicationController),
|
||||
});
|
||||
|
||||
//Create application (must be my company application and I must be company admin)
|
||||
fastify.route({
|
||||
method: "POST",
|
||||
url: `${applicationsUrl}`,
|
||||
preHandler: [adminCheck],
|
||||
preValidation: [fastify.authenticate],
|
||||
schema: applicationPostSchema,
|
||||
handler: applicationController.save.bind(applicationController),
|
||||
});
|
||||
|
||||
//Edit application (must be my company application and I must be company admin)
|
||||
fastify.route({
|
||||
method: "POST",
|
||||
url: `${applicationsUrl}/:application_id`,
|
||||
preHandler: [adminCheck],
|
||||
preValidation: [fastify.authenticate],
|
||||
schema: applicationPostSchema,
|
||||
handler: applicationController.save.bind(applicationController),
|
||||
});
|
||||
|
||||
// Delete application (must be my company application and I must be company admin)
|
||||
fastify.route({
|
||||
method: "DELETE",
|
||||
url: `${applicationsUrl}/:application_id`,
|
||||
preHandler: [adminCheck],
|
||||
preValidation: [fastify.authenticate],
|
||||
handler: applicationController.delete.bind(applicationController),
|
||||
});
|
||||
|
||||
/**
|
||||
* Company applications collection
|
||||
* Company-wide available applications
|
||||
* (must be my company application and I must be company admin)
|
||||
*/
|
||||
|
||||
//Get list of applications for a company
|
||||
fastify.route({
|
||||
method: "GET",
|
||||
url: `${companyApplicationsUrl}`,
|
||||
preValidation: [fastify.authenticate],
|
||||
handler: companyApplicationController.list.bind(companyApplicationController),
|
||||
});
|
||||
|
||||
//Get one application of a company
|
||||
fastify.route({
|
||||
method: "GET",
|
||||
url: `${companyApplicationsUrl}/:application_id`,
|
||||
preValidation: [fastify.authenticate],
|
||||
handler: companyApplicationController.get.bind(companyApplicationController),
|
||||
});
|
||||
|
||||
//Remove an application from a company
|
||||
fastify.route({
|
||||
method: "DELETE",
|
||||
url: `${companyApplicationsUrl}/:application_id`,
|
||||
preValidation: [fastify.authenticate],
|
||||
handler: companyApplicationController.delete.bind(companyApplicationController),
|
||||
});
|
||||
|
||||
//Add an application to the company
|
||||
fastify.route({
|
||||
method: "POST",
|
||||
url: `${companyApplicationsUrl}/:application_id`,
|
||||
preValidation: [fastify.authenticate],
|
||||
handler: companyApplicationController.save.bind(companyApplicationController),
|
||||
});
|
||||
|
||||
//Application event triggered by a user
|
||||
fastify.route({
|
||||
method: "POST",
|
||||
url: `${applicationsUrl}/:application_id/event`,
|
||||
preValidation: [fastify.authenticate],
|
||||
schema: applicationEventHookSchema,
|
||||
handler: applicationController.event.bind(applicationController),
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
export default routes;
|
||||
@@ -0,0 +1,129 @@
|
||||
export const applicationsSchema = {
|
||||
type: "object",
|
||||
properties: {},
|
||||
};
|
||||
|
||||
const applicationIdentity = {
|
||||
type: "object",
|
||||
properties: {
|
||||
code: { type: "string" },
|
||||
name: { type: "string" },
|
||||
icon: { type: "string" },
|
||||
description: { type: "string" },
|
||||
website: { type: "string" },
|
||||
categories: { type: "array", items: { type: "string" } },
|
||||
compatibility: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
required: ["code", "name", "icon", "description", "website", "categories", "compatibility"],
|
||||
};
|
||||
|
||||
const applicationAccess = {
|
||||
type: "object",
|
||||
properties: {
|
||||
read: { type: "array", items: { type: "string" } },
|
||||
write: { type: "array", items: { type: "string" } },
|
||||
delete: { type: "array", items: { type: "string" } },
|
||||
hooks: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
required: ["read", "write", "delete", "hooks"],
|
||||
};
|
||||
|
||||
const requestApplicationPublication = {
|
||||
type: "object",
|
||||
properties: {
|
||||
requested: { type: "boolean" },
|
||||
},
|
||||
required: ["requested"],
|
||||
};
|
||||
|
||||
const responseApplicationPublication = {
|
||||
type: "object",
|
||||
properties: {
|
||||
published: { type: "boolean" },
|
||||
requested: { type: "boolean" },
|
||||
},
|
||||
required: ["requested", "published"],
|
||||
};
|
||||
|
||||
const applicationStats = {
|
||||
type: "object",
|
||||
properties: {
|
||||
created_at: { type: "number" },
|
||||
updated_at: { type: "number" },
|
||||
version: { type: "number" },
|
||||
},
|
||||
required: ["created_at", "updated_at", "version"],
|
||||
};
|
||||
|
||||
const apiObject = {
|
||||
type: "object",
|
||||
properties: {
|
||||
hooks_url: { type: "string" },
|
||||
allowed_ips: { type: "string" },
|
||||
private_key: { type: "string" },
|
||||
},
|
||||
required: ["hooks_url", "allowed_ips"],
|
||||
};
|
||||
|
||||
const requestApplicationObject = {
|
||||
type: "object",
|
||||
properties: {
|
||||
company_id: { type: "string" },
|
||||
identity: applicationIdentity,
|
||||
access: applicationAccess,
|
||||
display: {},
|
||||
api: apiObject,
|
||||
publication: requestApplicationPublication,
|
||||
},
|
||||
required: ["company_id", "identity", "access", "display", "api", "publication"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const responseApplicationObject = {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
is_default: { type: "boolean" },
|
||||
company_id: { type: "string" },
|
||||
identity: applicationIdentity,
|
||||
access: applicationAccess,
|
||||
display: {},
|
||||
publication: responseApplicationPublication,
|
||||
api: apiObject,
|
||||
stats: applicationStats,
|
||||
},
|
||||
required: [
|
||||
"id",
|
||||
"is_default",
|
||||
"company_id",
|
||||
"identity",
|
||||
"access",
|
||||
"display",
|
||||
"publication",
|
||||
"stats",
|
||||
],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const applicationPostSchema = {
|
||||
body: { type: "object", properties: { resource: requestApplicationObject } },
|
||||
response: {
|
||||
"2xx": {
|
||||
resource: responseApplicationObject,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const applicationEventHookSchema = {
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
company_id: { type: "string" },
|
||||
workspace_id: { type: "string" },
|
||||
type: { type: "string" },
|
||||
name: { type: "string" },
|
||||
content: {},
|
||||
},
|
||||
required: ["company_id", "workspace_id", "type", "content"],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ExecutionContext } from "../../../core/platform/framework/api/crud-service";
|
||||
|
||||
export interface CompanyExecutionContext extends ExecutionContext {
|
||||
company: { id: string };
|
||||
}
|
||||
|
||||
export interface ApplicationEventRequestBody {
|
||||
company_id: string;
|
||||
workspace_id: string;
|
||||
connection_id: string;
|
||||
type: string;
|
||||
name?: string;
|
||||
content: any;
|
||||
data: any;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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",
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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];
|
||||
@@ -0,0 +1,138 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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" },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
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">;
|
||||
@@ -0,0 +1,34 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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";
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Type } from "class-transformer";
|
||||
|
||||
export class Member {
|
||||
@Type(() => String)
|
||||
id: string;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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"
|
||||
>;
|
||||
@@ -0,0 +1,23 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
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$);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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";
|
||||
}
|
||||
@@ -0,0 +1,987 @@
|
||||
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 };
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
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) }];
|
||||
}
|
||||
@@ -0,0 +1,911 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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}`;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ChannelType } from "./types";
|
||||
|
||||
export function isDirectChannel(channel: { workspace_id: string }): boolean {
|
||||
return channel?.workspace_id === ChannelType.DIRECT;
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
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",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./channel";
|
||||
export * from "./member";
|
||||
export * from "./tab";
|
||||
@@ -0,0 +1,319 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
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),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
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;
|
||||
@@ -0,0 +1,301 @@
|
||||
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"],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ConsoleRemoteClient } from "./clients/remote";
|
||||
import { ConsoleServiceClient } from "./client-interface";
|
||||
import { ConsoleType } from "./types";
|
||||
import { ConsoleInternalClient } from "./clients/internal";
|
||||
import { ConsoleServiceImpl } from "./service";
|
||||
|
||||
class StaticConsoleClientFactory {
|
||||
create(consoleInstance: ConsoleServiceImpl): ConsoleServiceClient {
|
||||
const type: ConsoleType = consoleInstance.consoleType;
|
||||
switch (type) {
|
||||
case "remote":
|
||||
return new ConsoleRemoteClient(consoleInstance);
|
||||
case "internal":
|
||||
return new ConsoleInternalClient(consoleInstance);
|
||||
default:
|
||||
throw new Error(`${type} is not supported`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ConsoleClientFactory = new StaticConsoleClientFactory();
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
ConsoleCompany,
|
||||
ConsoleHookCompany,
|
||||
ConsoleHookUser,
|
||||
CreateConsoleCompany,
|
||||
CreateConsoleUser,
|
||||
CreatedConsoleCompany,
|
||||
CreatedConsoleUser,
|
||||
CreateInternalUser,
|
||||
UpdateConsoleUserRole,
|
||||
UpdatedConsoleUserRole,
|
||||
} from "./types";
|
||||
import User from "../user/entities/user";
|
||||
import Company, { CompanySearchKey } from "../user/entities/company";
|
||||
|
||||
export interface ConsoleServiceClient {
|
||||
/**
|
||||
* Create a company
|
||||
*
|
||||
* @param company
|
||||
*/
|
||||
createCompany(company: CreateConsoleCompany): Promise<CreatedConsoleCompany>;
|
||||
|
||||
/**
|
||||
* Add user to company
|
||||
*
|
||||
* @param company
|
||||
* @param user
|
||||
* @param inviter
|
||||
*/
|
||||
addUserToCompany(company: ConsoleCompany, user: CreateConsoleUser): Promise<CreatedConsoleUser>;
|
||||
|
||||
/**
|
||||
* Add user to tdrive in general (for non-console version)
|
||||
*
|
||||
* @param user
|
||||
*/
|
||||
addUserToTdrive(user: CreateInternalUser): Promise<User>;
|
||||
|
||||
/**
|
||||
* Update user role
|
||||
*
|
||||
* @param company
|
||||
* @param user
|
||||
*/
|
||||
updateUserRole(
|
||||
company: ConsoleCompany,
|
||||
user: UpdateConsoleUserRole,
|
||||
): Promise<UpdatedConsoleUserRole>;
|
||||
|
||||
updateLocalCompanyFromConsole(companyDTO: ConsoleHookCompany): Promise<Company>;
|
||||
|
||||
updateLocalUserFromConsole(user: ConsoleHookUser): Promise<User>;
|
||||
|
||||
removeCompanyUser(consoleUserId: string, company: Company): Promise<void>;
|
||||
|
||||
removeUser(consoleUserId: string): Promise<void>;
|
||||
|
||||
removeCompany(companySearchKey: CompanySearchKey): Promise<void>;
|
||||
|
||||
fetchCompanyInfo(consoleCompanyCode: string): Promise<ConsoleHookCompany>;
|
||||
|
||||
getUserByAccessToken(idToken: string): Promise<ConsoleHookUser>;
|
||||
|
||||
resendVerificationEmail(email: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { AxiosInstance } from "axios";
|
||||
import { merge } from "lodash";
|
||||
|
||||
import { ConsoleServiceClient } from "../client-interface";
|
||||
import {
|
||||
ConsoleCompany,
|
||||
ConsoleHookCompany,
|
||||
ConsoleHookUser,
|
||||
CreateConsoleCompany,
|
||||
CreateConsoleUser,
|
||||
CreatedConsoleCompany,
|
||||
CreatedConsoleUser,
|
||||
UpdateConsoleUserRole,
|
||||
UpdatedConsoleUserRole,
|
||||
} from "../types";
|
||||
|
||||
import { v1 as uuidv1 } from "uuid";
|
||||
import User, { getInstance as getUserInstance } from "../../user/entities/user";
|
||||
import Company, { CompanySearchKey } from "../../user/entities/company";
|
||||
import { logger } from "../../../core/platform/framework/logger";
|
||||
import gr from "../../global-resolver";
|
||||
import { ConsoleServiceImpl } from "../service";
|
||||
|
||||
export class ConsoleInternalClient implements ConsoleServiceClient {
|
||||
version: "1";
|
||||
client: AxiosInstance;
|
||||
|
||||
constructor(private consoleInstance: ConsoleServiceImpl) {}
|
||||
|
||||
async addUserToCompany(
|
||||
company: ConsoleCompany,
|
||||
user: CreateConsoleUser,
|
||||
): Promise<CreatedConsoleUser> {
|
||||
logger.info("Internal: addUserToCompany");
|
||||
await gr.services.companies.setUserRole(company.id, user.id, user.role);
|
||||
return merge(user, { _id: user.id });
|
||||
}
|
||||
|
||||
async updateUserRole(
|
||||
_company: ConsoleCompany,
|
||||
_user: UpdateConsoleUserRole,
|
||||
): Promise<UpdatedConsoleUserRole> {
|
||||
logger.info("Internal: updateUserRole");
|
||||
throw Error("ConsoleInternalClient.updateUserRole is not implemented");
|
||||
}
|
||||
|
||||
async createCompany(_company: CreateConsoleCompany): Promise<CreatedConsoleCompany> {
|
||||
logger.info("Internal: ");
|
||||
throw Error("ConsoleInternalClient.createCompany is not implemented");
|
||||
}
|
||||
|
||||
async addUserToTdrive(user: CreateConsoleUser): Promise<User> {
|
||||
logger.info("Internal: addUserToTdrive");
|
||||
const userToCreate = getUserInstance({
|
||||
id: uuidv1(),
|
||||
email_canonical: user.email,
|
||||
first_name: user.firstName,
|
||||
last_name: user.lastName,
|
||||
});
|
||||
|
||||
const createdUser = await gr.services.users.save(userToCreate).then(result => result.entity);
|
||||
if (user.password) {
|
||||
await gr.services.users.setPassword({ id: createdUser.id }, user.password);
|
||||
}
|
||||
return createdUser;
|
||||
}
|
||||
|
||||
updateLocalCompanyFromConsole(_companyDTO: ConsoleHookCompany): Promise<Company> {
|
||||
logger.info("Internal: updateLocalCompanyFromConsole");
|
||||
throw new Error("Method should not be implemented.");
|
||||
}
|
||||
|
||||
updateLocalUserFromConsole(_user: ConsoleHookUser): Promise<User> {
|
||||
logger.info("Internal: updateLocalUserFromConsole");
|
||||
throw new Error("Method should not be implemented.");
|
||||
}
|
||||
|
||||
removeCompany(_companySearchKey: CompanySearchKey): Promise<void> {
|
||||
logger.info("Internal: removeCompany");
|
||||
throw new Error("Method should not be implemented.");
|
||||
}
|
||||
|
||||
removeCompanyUser(_consoleUserId: string, _company: Company): Promise<void> {
|
||||
logger.info("Internal: removeCompanyUser");
|
||||
throw new Error("Method should not be implemented.");
|
||||
}
|
||||
|
||||
removeUser(_consoleUserId: string): Promise<void> {
|
||||
logger.info("Internal: removeUser");
|
||||
throw new Error("Method should not be implemented.");
|
||||
}
|
||||
|
||||
fetchCompanyInfo(_consoleCompanyCode: string): Promise<ConsoleHookCompany> {
|
||||
logger.info("Internal: fetchCompanyInfo");
|
||||
throw new Error("Method should not be implemented.");
|
||||
}
|
||||
|
||||
getUserByAccessToken(_idToken: string): Promise<ConsoleHookUser> {
|
||||
logger.info("Internal: getUserByAccessToken");
|
||||
throw new Error("Method should not be implemented.");
|
||||
}
|
||||
|
||||
resendVerificationEmail(_accessToken: string): Promise<void> {
|
||||
logger.info("Internal: resendVerificationEmail");
|
||||
throw new Error("Method should not be implemented.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import JwksClient from "jwks-rsa";
|
||||
import nJwt from "njwt";
|
||||
|
||||
function verifyAudience(expected: string, aud: string | Array<string>) {
|
||||
if (!expected) {
|
||||
throw new Error("expected audience is required");
|
||||
}
|
||||
|
||||
if (Array.isArray(aud) && !aud.includes(expected)) {
|
||||
throw new Error(
|
||||
`audience claim ${expected} does not match one of the expected audiences: ${aud.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!Array.isArray(aud) && aud !== expected) {
|
||||
throw new Error(`audience claim ${aud} does not match expected audience: ${expected}`);
|
||||
}
|
||||
}
|
||||
|
||||
function verifyIssuer(expected: string, issuer: string) {
|
||||
if (issuer !== expected) {
|
||||
throw new Error(`issuer ${issuer} does not match expected issuer: ${expected}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class OidcJwtVerifier {
|
||||
public claimsToAssert: any;
|
||||
private issuer: string;
|
||||
private jwksUri: string;
|
||||
private jwksClient: any;
|
||||
private verifier: any;
|
||||
|
||||
constructor(options: { [key: string]: any } = {}) {
|
||||
// https://github.com/auth0/node-jwks-rsa/blob/master/CHANGELOG.md#request-agent-options
|
||||
if (options.requestAgentOptions) {
|
||||
// jwks-rsa no longer accepts 'requestAgentOptions' and instead requires a http(s).Agent be passed directly
|
||||
const msg = `\`requestAgentOptions\` has been deprecated, use \`requestAgent\` instead.
|
||||
For more info see https://github.com/auth0/node-jwks-rsa/blob/master/CHANGELOG.md#request-agent-options`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
this.claimsToAssert = options.assertClaims || {};
|
||||
this.issuer = options.issuer;
|
||||
this.jwksUri = options.jwksUri;
|
||||
this.jwksClient = JwksClient({
|
||||
jwksUri: this.jwksUri,
|
||||
cache: true,
|
||||
cacheMaxAge: options.cacheMaxAge || 60 * 60 * 1000,
|
||||
cacheMaxEntries: 3,
|
||||
jwksRequestsPerMinute: options.jwksRequestsPerMinute || 10,
|
||||
rateLimit: true,
|
||||
requestAgent: options.requestAgent,
|
||||
});
|
||||
this.verifier = nJwt
|
||||
.createVerifier()
|
||||
.setSigningAlgorithm("RS256")
|
||||
.withKeyResolver((kid: string, cb: any) => {
|
||||
if (kid) {
|
||||
this.jwksClient.getSigningKey(kid, (err: any, key: any) => {
|
||||
cb(err, key && (key.publicKey || key.rsaPublicKey));
|
||||
});
|
||||
} else {
|
||||
cb("No KID specified", null);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
async verifyAsPromise(tokenString: string): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Convert to a promise
|
||||
this.verifier.verify(tokenString, (err: any, jwt: any) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
const jwtBodyProxy = new Proxy(jwt.body, {});
|
||||
Object.defineProperty(jwt, "claims", {
|
||||
enumerable: true,
|
||||
writable: false,
|
||||
value: jwtBodyProxy,
|
||||
});
|
||||
|
||||
delete jwt.body;
|
||||
resolve(jwt);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async verifyIdToken(idTokenString: string, expectedClientId: string) {
|
||||
const jwt = await this.verifyAsPromise(idTokenString);
|
||||
verifyAudience(expectedClientId, jwt.claims.aud);
|
||||
verifyIssuer(this.issuer, jwt.claims.iss);
|
||||
|
||||
return jwt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { AxiosInstance } from "axios";
|
||||
import { ConsoleServiceClient } from "../client-interface";
|
||||
import {
|
||||
ConsoleCompany,
|
||||
ConsoleHookCompany,
|
||||
ConsoleHookUser,
|
||||
ConsoleOptions,
|
||||
CreateConsoleCompany,
|
||||
CreateConsoleUser,
|
||||
CreatedConsoleCompany,
|
||||
CreatedConsoleUser,
|
||||
UpdateConsoleUserRole,
|
||||
UpdatedConsoleUserRole,
|
||||
} from "../types";
|
||||
|
||||
import { OidcJwtVerifier } from "./remote-jwks-verifier";
|
||||
import { CrudException } from "../../../core/platform/framework/api/crud-service";
|
||||
import { logger } from "../../../core/platform/framework/logger";
|
||||
import gr from "../../global-resolver";
|
||||
import Company, { CompanySearchKey } from "../../user/entities/company";
|
||||
import User, { getInstance } from "../../user/entities/user";
|
||||
import { getInstance as getCompanyInstance } from "../../user/entities/company";
|
||||
import { ConsoleServiceImpl } from "../service";
|
||||
import coalesce from "../../../utils/coalesce";
|
||||
|
||||
export class ConsoleRemoteClient implements ConsoleServiceClient {
|
||||
version: "1";
|
||||
client: AxiosInstance;
|
||||
|
||||
private infos: ConsoleOptions;
|
||||
private verifier: OidcJwtVerifier;
|
||||
|
||||
constructor(consoleInstance: ConsoleServiceImpl) {
|
||||
this.infos = consoleInstance.consoleOptions;
|
||||
this.verifier = new OidcJwtVerifier({
|
||||
clientId: this.infos.client_id,
|
||||
issuer: this.infos.issuer?.replace(/\/+$/, ""),
|
||||
jwksUri: this.infos.jwks_uri,
|
||||
// For local deployment create a https agent that ignore self signed certificate
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
requestAgent: new (require("https").Agent)({
|
||||
rejectUnauthorized: this.infos.issuer.includes("example.com") ? false : true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
fetchCompanyInfo(consoleCompanyCode: string): Promise<ConsoleHookCompany> {
|
||||
throw new Error(`Method not implemented, ${consoleCompanyCode}.`);
|
||||
}
|
||||
resendVerificationEmail(email: string): Promise<void> {
|
||||
throw new Error(`Method not implemented, ${email}.`);
|
||||
}
|
||||
|
||||
private auth() {
|
||||
return {};
|
||||
}
|
||||
|
||||
async addUserToCompany(
|
||||
company: ConsoleCompany,
|
||||
user: CreateConsoleUser,
|
||||
): Promise<CreatedConsoleUser> {
|
||||
logger.info(`Method not implemented, ${company.id}, ${user.id}.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
async updateUserRole(
|
||||
company: ConsoleCompany,
|
||||
user: UpdateConsoleUserRole,
|
||||
): Promise<UpdatedConsoleUserRole> {
|
||||
logger.info("Remote: updateUserRole");
|
||||
logger.info(`Method not implemented, ${company.id}, ${user.id}.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
async createCompany(company: CreateConsoleCompany): Promise<CreatedConsoleCompany> {
|
||||
logger.info("Remote: createCompany");
|
||||
logger.info(`Method not implemented, ${company}.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
addUserToTdrive(user: CreateConsoleUser): Promise<User> {
|
||||
logger.info("Remote: addUserToTdrive");
|
||||
//should do noting for real console
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
async updateLocalCompanyFromConsole(partialCompanyDTO: ConsoleHookCompany): Promise<Company> {
|
||||
logger.info(`Method not implemented, ${partialCompanyDTO}.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
async updateLocalUserFromConsole(userDTO: ConsoleHookUser): Promise<User> {
|
||||
logger.info("Remote: updateLocalUserFromConsole");
|
||||
|
||||
if (!userDTO) {
|
||||
throw CrudException.badRequest("User not found on Console");
|
||||
}
|
||||
|
||||
const roles = userDTO.roles.filter(
|
||||
role => role.applications === undefined || role.applications.find(a => a.code === "tdrive"),
|
||||
);
|
||||
|
||||
//REMOVE LATER
|
||||
logger.info(`Roles are: ${roles}.`);
|
||||
|
||||
let user = await gr.services.users.getByConsoleId(userDTO.email);
|
||||
|
||||
if (!user) {
|
||||
if (!userDTO.email) {
|
||||
throw CrudException.badRequest("Email is required");
|
||||
}
|
||||
|
||||
let username = userDTO.email
|
||||
.split("@")[0]
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[^a-zA-Z0-9]/g, "")
|
||||
.replace(/ +/g, "_");
|
||||
|
||||
if (await gr.services.users.isEmailAlreadyInUse(userDTO.email)) {
|
||||
throw CrudException.badRequest("Console user not created because email already exists");
|
||||
}
|
||||
|
||||
username = await gr.services.users.getAvailableUsername(username);
|
||||
if (!username) {
|
||||
throw CrudException.badRequest("Console user not created because username already exists");
|
||||
}
|
||||
|
||||
user = getInstance({});
|
||||
user.username_canonical = (username || "").toLocaleLowerCase();
|
||||
user.email_canonical = userDTO.email;
|
||||
user.deleted = false;
|
||||
}
|
||||
|
||||
user.email_canonical = coalesce(userDTO.email, user.email_canonical);
|
||||
user.phone = "";
|
||||
user.first_name = coalesce(userDTO.name, user.first_name);
|
||||
user.last_name = coalesce(userDTO.surname, user.last_name);
|
||||
user.identity_provider = "console";
|
||||
user.identity_provider_id = userDTO.email;
|
||||
user.mail_verified = coalesce(userDTO.isVerified, user.mail_verified);
|
||||
if (userDTO.preference) {
|
||||
user.preferences = user.preferences || {};
|
||||
user.preferences.allow_tracking = coalesce(
|
||||
userDTO.preference.allowTrackingPersonalInfo,
|
||||
user.preferences?.allow_tracking,
|
||||
);
|
||||
user.preferences.language = coalesce(userDTO.preference.locale, user.preferences?.language);
|
||||
user.preferences.timezone = coalesce(userDTO.preference.timeZone, user.preferences?.timezone);
|
||||
}
|
||||
|
||||
user.picture = userDTO.avatar.value;
|
||||
|
||||
await gr.services.users.save(user);
|
||||
|
||||
//For now TDrive works with only one company as we don't get it from the SSO
|
||||
|
||||
let company = await gr.services.companies.getCompany({
|
||||
id: "00000000-0000-4000-0000-000000000000",
|
||||
});
|
||||
if (!company) {
|
||||
company = await gr.services.companies.createCompany(
|
||||
getCompanyInstance({
|
||||
id: "00000000-0000-4000-0000-000000000000",
|
||||
name: "Tdrive",
|
||||
plan: { name: "Local", limits: undefined, features: undefined },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await gr.services.companies.setUserRole(company.id, user.id, "admin");
|
||||
|
||||
await gr.services.users.save(user, { user: { id: user.id, server_request: true } });
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async removeCompanyUser(consoleUserId: string, company: Company): Promise<void> {
|
||||
logger.info("Remote: removeCompanyUser");
|
||||
|
||||
const user = await gr.services.users.getByConsoleId(consoleUserId);
|
||||
if (!user) {
|
||||
throw CrudException.notFound(`User ${consoleUserId} doesn't exists`);
|
||||
}
|
||||
await gr.services.companies.removeUserFromCompany({ id: company.id }, { id: user.id });
|
||||
}
|
||||
|
||||
async removeUser(consoleUserId: string): Promise<void> {
|
||||
logger.info("Remote: removeUser");
|
||||
|
||||
const user = await gr.services.users.getByConsoleId(consoleUserId);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User does not exists on Tdrive.");
|
||||
}
|
||||
|
||||
await gr.services.users.anonymizeAndDelete(
|
||||
{ id: user.id },
|
||||
{
|
||||
user: { id: user.id, server_request: true },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async removeCompany(companySearchKey: CompanySearchKey): Promise<void> {
|
||||
logger.info("Remote: removeCompany");
|
||||
await gr.services.companies.removeCompany(companySearchKey);
|
||||
}
|
||||
|
||||
async getUserByAccessToken(idToken: string): Promise<ConsoleHookUser> {
|
||||
const user = (await this.verifier.verifyIdToken(idToken, this.infos.client_id))?.claims as {
|
||||
sub: string;
|
||||
email: string;
|
||||
family_name: string;
|
||||
given_name: string;
|
||||
name: string;
|
||||
locale?: string;
|
||||
picture?: string;
|
||||
};
|
||||
return {
|
||||
_id: user.email || user.sub,
|
||||
roles: [] as any,
|
||||
email: user.email,
|
||||
name: user.given_name,
|
||||
surname: user.family_name,
|
||||
isVerified: true,
|
||||
preference: {
|
||||
locale: user.locale,
|
||||
timeZone: 0,
|
||||
allowTrackingPersonalInfo: true,
|
||||
},
|
||||
avatar: {
|
||||
type: "url",
|
||||
value: user.picture,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Prefix, TdriveService } from "../../core/platform/framework";
|
||||
import WebServerAPI from "../../core/platform/services/webserver/provider";
|
||||
import web from "./web";
|
||||
|
||||
@Prefix("/internal/services/console/v1")
|
||||
export default class ConsoleService extends TdriveService<undefined> {
|
||||
version = "1";
|
||||
name = "console";
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { DatabaseServiceAPI } from "../../core/platform/services/database/api";
|
||||
import { ConsoleOptions, ConsoleType } from "./types";
|
||||
import { ConsoleServiceClient } from "./client-interface";
|
||||
import { ConsoleClientFactory } from "./client-factory";
|
||||
import User from "../user/entities/user";
|
||||
import gr from "../global-resolver";
|
||||
import { Configuration, TdriveServiceProvider } from "../../core/platform/framework";
|
||||
import assert from "assert";
|
||||
import { ExecutionContext } from "../../core/platform/framework/api/crud-service";
|
||||
|
||||
export class ConsoleServiceImpl implements TdriveServiceProvider {
|
||||
version: "1";
|
||||
|
||||
consoleType: ConsoleType;
|
||||
consoleOptions: ConsoleOptions;
|
||||
services: {
|
||||
database: DatabaseServiceAPI;
|
||||
};
|
||||
private configuration: Configuration;
|
||||
|
||||
constructor(options?: ConsoleOptions) {
|
||||
this.consoleOptions = options;
|
||||
}
|
||||
|
||||
async init(): Promise<this> {
|
||||
this.configuration = new Configuration("general.accounts");
|
||||
assert(this.configuration, "console configuration is missing");
|
||||
const type = this.configuration.get("type") as ConsoleType;
|
||||
assert(type, "console configuration type is not defined");
|
||||
|
||||
const s = this.configuration.get(type) as ConsoleOptions;
|
||||
|
||||
this.consoleOptions = {
|
||||
type: type,
|
||||
authority: s.authority,
|
||||
client_id: s.client_id,
|
||||
client_secret: s.client_secret,
|
||||
audience: s.audience,
|
||||
issuer: s.issuer,
|
||||
jwks_uri: s.jwks_uri,
|
||||
redirect_uris: s.redirect_uris,
|
||||
disable_account_creation: s.disable_account_creation,
|
||||
};
|
||||
|
||||
this.consoleOptions.type = type;
|
||||
this.consoleType = type;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
getClient(): ConsoleServiceClient {
|
||||
return ConsoleClientFactory.create(this);
|
||||
}
|
||||
|
||||
async processPendingUser(user: User, context?: ExecutionContext): Promise<void> {
|
||||
await gr.services.workspaces.processPendingUser(user, null, context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { Observable } from "rxjs";
|
||||
import Company from "../user/entities/company";
|
||||
import CompanyUser from "../user/entities/company_user";
|
||||
import { CompanyUserRole } from "../user/web/types";
|
||||
import { ExecutionContext } from "../../core/platform/framework/api/crud-service";
|
||||
import { AccessToken } from "../../utils/types";
|
||||
|
||||
export interface CreateConsoleCompany {
|
||||
code: string;
|
||||
displayName: string;
|
||||
avatar: {
|
||||
type: "url";
|
||||
value: string;
|
||||
};
|
||||
country?: string;
|
||||
address?: string;
|
||||
logo?: string;
|
||||
status: string;
|
||||
domain?: string[];
|
||||
applications?: string[];
|
||||
planId?: string;
|
||||
limits?: {
|
||||
members?: number; //Old console version
|
||||
guests?: number; //Old console version
|
||||
tdrive: {
|
||||
members: number;
|
||||
guests: number;
|
||||
storage: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export type CreatedConsoleCompany = Partial<CreateConsoleCompany>;
|
||||
|
||||
export interface CreateConsoleUser {
|
||||
id?: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
name: string;
|
||||
avatar: {
|
||||
type: "url";
|
||||
value: string;
|
||||
};
|
||||
password: string;
|
||||
role: CompanyUserRole;
|
||||
skipInvite: boolean;
|
||||
inviterEmail: string;
|
||||
}
|
||||
|
||||
export interface CreateInternalUser {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export type CreatedConsoleUser = Partial<CreateConsoleUser> & { _id: string };
|
||||
|
||||
export type UpdateConsoleUserRole = Pick<CreateConsoleUser, "role"> & { id: string };
|
||||
|
||||
export type UpdatedConsoleUserRole = UpdateConsoleUserRole;
|
||||
|
||||
export type ConsoleUser = {
|
||||
id: string;
|
||||
companyCode: string;
|
||||
};
|
||||
|
||||
export type ConsoleCompany = {
|
||||
id?: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
export type CompanyCreatedStreamObject = {
|
||||
source: Company;
|
||||
destination: ConsoleCompany;
|
||||
// Creation error
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
export type UserCreatedStreamObject = {
|
||||
source: {
|
||||
user: CompanyUser;
|
||||
company: Company;
|
||||
};
|
||||
destination: ConsoleUser;
|
||||
// Creation error
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
type ReportStatus = "success" | "failure";
|
||||
|
||||
export type CompanyReport = {
|
||||
sourceId: string;
|
||||
destinationCode: string;
|
||||
status: ReportStatus;
|
||||
company: CompanyCreatedStreamObject;
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
export type UserReport = {
|
||||
sourceId: string;
|
||||
destinationId: string;
|
||||
destinationCompanyCode: string;
|
||||
status: ReportStatus;
|
||||
user: UserCreatedStreamObject;
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
export type MergeProgress = Observable<ProcessReport>;
|
||||
|
||||
export type ProcessReport = {
|
||||
type:
|
||||
| "user:updated"
|
||||
| "user:updating"
|
||||
| "user:created"
|
||||
| "user:error"
|
||||
| "company:created"
|
||||
| "processing:owner"
|
||||
| "log"
|
||||
| "company:withoutadmin";
|
||||
//error?: Error;
|
||||
message?: string;
|
||||
data?: UserReport | CompanyReport | Error | Company[];
|
||||
};
|
||||
|
||||
export type ConsoleType = "remote" | "internal";
|
||||
|
||||
export type ConsoleOptions = {
|
||||
type: ConsoleType;
|
||||
authority: string;
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
issuer: string;
|
||||
jwks_uri: string;
|
||||
audience: string;
|
||||
redirect_uris: string[];
|
||||
disable_account_creation: boolean;
|
||||
};
|
||||
|
||||
export type ConsoleHookCompany = {
|
||||
stats: string;
|
||||
limits: {
|
||||
members?: number; //Old console version
|
||||
guests?: number; //Old console version
|
||||
tdrive: {
|
||||
members: number;
|
||||
guests: number;
|
||||
storage: number;
|
||||
};
|
||||
};
|
||||
value: string;
|
||||
details: {
|
||||
code: string;
|
||||
logo: string;
|
||||
avatar: {
|
||||
value: string;
|
||||
type: string;
|
||||
};
|
||||
name: string;
|
||||
country: string;
|
||||
address: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ConsoleHookUser = {
|
||||
_id: string;
|
||||
roles: [
|
||||
{
|
||||
targetCode: string;
|
||||
roleCode: CompanyUserRole;
|
||||
status: "active" | "deactivated";
|
||||
applications: {
|
||||
code: "tdrive";
|
||||
}[];
|
||||
},
|
||||
];
|
||||
email: string;
|
||||
name: string;
|
||||
surname: string;
|
||||
isVerified: boolean;
|
||||
preference: {
|
||||
locale: string;
|
||||
timeZone: number;
|
||||
allowTrackingPersonalInfo: boolean;
|
||||
};
|
||||
avatar: {
|
||||
type: string;
|
||||
value: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ConsoleHookBodyContent = {
|
||||
company: ConsoleHookCompany;
|
||||
user: ConsoleHookUser;
|
||||
};
|
||||
|
||||
export type ConsoleHookCompanyDeletedContent = {
|
||||
companyCode: string;
|
||||
};
|
||||
|
||||
export type ConsoleHookPreferenceContent = {
|
||||
preference: {
|
||||
targetCode: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ConsoleHookBody = {
|
||||
type: string;
|
||||
content:
|
||||
| ConsoleHookBodyContent
|
||||
| ConsoleHookUser
|
||||
| ConsoleHookCompany
|
||||
| ConsoleHookCompanyDeletedContent;
|
||||
signature: string;
|
||||
secret_key?: string;
|
||||
};
|
||||
|
||||
export class ConsoleHookQueryString {
|
||||
secret_key: string;
|
||||
}
|
||||
|
||||
export class ConsoleHookResponse {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ConsoleExecutionContext extends ExecutionContext {
|
||||
options: ConsoleOptions;
|
||||
}
|
||||
|
||||
export interface AuthRequest {
|
||||
email?: string;
|
||||
password?: string;
|
||||
oidc_id_token?: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
access_token?: AccessToken;
|
||||
error?: string;
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
import {
|
||||
AuthRequest,
|
||||
AuthResponse,
|
||||
ConsoleHookBody,
|
||||
ConsoleHookBodyContent,
|
||||
ConsoleHookCompany,
|
||||
ConsoleHookCompanyDeletedContent,
|
||||
ConsoleHookPreferenceContent,
|
||||
ConsoleHookResponse,
|
||||
ConsoleHookUser,
|
||||
ConsoleType,
|
||||
} from "../types";
|
||||
import Company from "../../user/entities/company";
|
||||
import { CrudException } from "../../../core/platform/framework/api/crud-service";
|
||||
import PasswordEncoder from "../../../utils/password-encoder";
|
||||
import { AccessToken } from "../../../utils/types";
|
||||
import assert from "assert";
|
||||
import { logger } from "../../../core/platform/framework";
|
||||
import { getInstance } from "../../user/entities/user";
|
||||
import { getInstance as getCompanyInstance } from "../../../services/user/entities/company";
|
||||
import Workspace from "../../../services/workspaces/entities/workspace";
|
||||
import gr from "../../global-resolver";
|
||||
import { Configuration } from "../../../core/platform/framework";
|
||||
|
||||
export class ConsoleController {
|
||||
private passwordEncoder: PasswordEncoder;
|
||||
|
||||
constructor() {
|
||||
this.passwordEncoder = new PasswordEncoder();
|
||||
}
|
||||
|
||||
async auth(request: FastifyRequest<{ Body: AuthRequest }>): Promise<AuthResponse> {
|
||||
if (request.body.oidc_id_token) {
|
||||
return { access_token: await this.authByToken(request.body.oidc_id_token) };
|
||||
} else if (request.body.email && request.body.password) {
|
||||
return { access_token: await this.authByPassword(request.body.email, request.body.password) };
|
||||
} else {
|
||||
throw CrudException.badRequest("remote_access_token or email+password are required");
|
||||
}
|
||||
}
|
||||
|
||||
async signup(
|
||||
request: FastifyRequest<{
|
||||
Body: { email: string; password: string; first_name: string; last_name: string };
|
||||
}>,
|
||||
): Promise<AuthResponse> {
|
||||
const configuration = new Configuration("console");
|
||||
const type = configuration.get("type") as ConsoleType;
|
||||
|
||||
try {
|
||||
//Allow only if no console is set up in this case everyone will be in the same company
|
||||
//Console is set up
|
||||
if (type !== "internal") {
|
||||
throw new Error("Unable to signup in console mode");
|
||||
}
|
||||
|
||||
//Allow only if signup isn't disabled
|
||||
if (configuration.get("disable_account_creation")) {
|
||||
throw new Error("Account creation is disabled");
|
||||
}
|
||||
|
||||
const email = request.body.email.trim().toLocaleLowerCase();
|
||||
if (await gr.services.users.getByEmail(email)) {
|
||||
throw new Error("This email is already used");
|
||||
}
|
||||
|
||||
try {
|
||||
const newUser = getInstance({
|
||||
first_name: request.body.first_name,
|
||||
last_name: request.body.last_name,
|
||||
email_canonical: email,
|
||||
username_canonical: (email.replace("@", ".") || "").toLocaleLowerCase(),
|
||||
});
|
||||
const user = await gr.services.users.create(newUser);
|
||||
await gr.services.users.setPassword({ id: user.entity.id }, request.body.password);
|
||||
|
||||
//Create a global company for all users in local mode
|
||||
const companies = await gr.services.companies.getCompanies();
|
||||
let company = companies.getEntities()?.[0];
|
||||
if (!company) {
|
||||
const newCompany = getCompanyInstance({
|
||||
name: "Tdrive",
|
||||
plan: { name: "Local", limits: undefined, features: undefined },
|
||||
});
|
||||
company = await gr.services.companies.createCompany(newCompany);
|
||||
}
|
||||
await gr.services.companies.setUserRole(company.id, user.entity.id, "admin");
|
||||
|
||||
//In case someone invited us to a workspace
|
||||
await gr.services.workspaces.processPendingUser(user.entity);
|
||||
|
||||
//If user is in no workspace, then we create one for they
|
||||
const workspaces = await gr.services.workspaces.getAllForUser(
|
||||
{ userId: user.entity.id },
|
||||
{ id: company.id },
|
||||
);
|
||||
if (workspaces.length === 0) {
|
||||
gr.services.workspaces.create(
|
||||
{
|
||||
company_id: company.id,
|
||||
name: `${
|
||||
newUser.first_name || newUser.last_name || newUser.username_canonical
|
||||
}'s space`,
|
||||
} as Workspace,
|
||||
{ user: { id: user.entity.id } },
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
access_token: await this.authByPassword(request.body.email, request.body.password),
|
||||
};
|
||||
} catch (err) {
|
||||
throw new Error("An unknown error occured");
|
||||
}
|
||||
} catch (err) {
|
||||
return { error: err.toString() };
|
||||
}
|
||||
}
|
||||
|
||||
async tokenRenewal(request: FastifyRequest): Promise<AuthResponse> {
|
||||
return {
|
||||
access_token: gr.platformServices.auth.generateJWT(
|
||||
request.currentUser.id,
|
||||
request.currentUser.email,
|
||||
{
|
||||
track: request.currentUser?.allow_tracking || false,
|
||||
provider_id: request.currentUser.identity_provider_id,
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async resendVerificationEmail(
|
||||
request: FastifyRequest,
|
||||
): Promise<{ success: boolean; email: string }> {
|
||||
const user = await gr.services.users.get({ id: request.currentUser.id });
|
||||
|
||||
await gr.services.console.getClient().resendVerificationEmail(user.email_canonical);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
email: user.email_canonical,
|
||||
};
|
||||
}
|
||||
|
||||
private async getCompanyDataFromConsole(
|
||||
company: ConsoleHookCompany | ConsoleHookCompany["details"] | { code: string },
|
||||
): Promise<ConsoleHookCompany> {
|
||||
return gr.services.console
|
||||
.getClient()
|
||||
.fetchCompanyInfo(
|
||||
(company as ConsoleHookCompany["details"])?.code ||
|
||||
(company as ConsoleHookCompany)?.details?.code,
|
||||
);
|
||||
}
|
||||
|
||||
private async updateCompany(company: ConsoleHookCompany): Promise<Company> {
|
||||
const companyDTO = await this.getCompanyDataFromConsole(company);
|
||||
return gr.services.console.getClient().updateLocalCompanyFromConsole(companyDTO);
|
||||
}
|
||||
|
||||
async hook(
|
||||
request: FastifyRequest<{ Body: ConsoleHookBody }>,
|
||||
reply: FastifyReply,
|
||||
): Promise<ConsoleHookResponse> {
|
||||
try {
|
||||
logger.info(`Received event ${request.body.type}`);
|
||||
|
||||
switch (request.body.type) {
|
||||
case "user_created":
|
||||
case "company_user_added":
|
||||
case "company_user_activated":
|
||||
case "company_user_updated":
|
||||
await this.userAdded(request.body.content as ConsoleHookBodyContent);
|
||||
break;
|
||||
case "company_user_deactivated":
|
||||
case "company_user_deleted":
|
||||
await this.userDisabled(request.body.content as ConsoleHookBodyContent);
|
||||
break;
|
||||
case "user_updated":
|
||||
await this.userUpdated((request.body.content as ConsoleHookBodyContent).user._id);
|
||||
break;
|
||||
case "user_preference_updated":
|
||||
await this.userUpdated(
|
||||
(request.body.content as unknown as ConsoleHookPreferenceContent).preference.targetCode,
|
||||
);
|
||||
break;
|
||||
case "user_deleted":
|
||||
await this.userRemoved(request.body.content as ConsoleHookUser);
|
||||
break;
|
||||
case "plan_updated":
|
||||
await this.planUpdated(request.body.content as ConsoleHookBodyContent);
|
||||
break;
|
||||
case "company_deleted":
|
||||
await this.companyRemoved(request.body.content as ConsoleHookCompanyDeletedContent);
|
||||
break;
|
||||
case "company_created":
|
||||
await this.companyUpdated(request.body.content as ConsoleHookBodyContent);
|
||||
if ((request.body.content as ConsoleHookBodyContent)?.user?._id) {
|
||||
await this.userUpdated((request.body.content as ConsoleHookBodyContent).user._id);
|
||||
}
|
||||
break;
|
||||
case "company_updated":
|
||||
await this.companyUpdated(request.body.content as ConsoleHookBodyContent);
|
||||
break;
|
||||
default:
|
||||
logger.info("Event not recognized");
|
||||
throw CrudException.notImplemented("Unimplemented");
|
||||
}
|
||||
} catch (e) {
|
||||
reply.status(400);
|
||||
return {
|
||||
error: e.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
private async userAdded(content: ConsoleHookBodyContent): Promise<void> {
|
||||
const userDTO = content.user;
|
||||
const user = await gr.services.console.getClient().updateLocalUserFromConsole(userDTO);
|
||||
await this.updateCompany(content.company);
|
||||
await gr.services.console.processPendingUser(user);
|
||||
}
|
||||
|
||||
private async userDisabled(content: ConsoleHookBodyContent): Promise<void> {
|
||||
const company = await this.updateCompany(content.company);
|
||||
await gr.services.console.getClient().removeCompanyUser(content.user._id, company);
|
||||
}
|
||||
|
||||
private async userRemoved(content: ConsoleHookUser): Promise<void> {
|
||||
await gr.services.console.getClient().removeUser(content._id);
|
||||
}
|
||||
|
||||
private async userUpdated(code: string) {
|
||||
//Not implemented yet
|
||||
throw CrudException.notImplemented(`Method not implemented, ${code}.`);
|
||||
}
|
||||
|
||||
private async companyRemoved(content: ConsoleHookCompanyDeletedContent) {
|
||||
assert(content.companyCode, "content.companyCode is missing");
|
||||
|
||||
await gr.services.console.getClient().removeCompany({
|
||||
identity_provider: "console",
|
||||
identity_provider_id: content.companyCode,
|
||||
});
|
||||
}
|
||||
|
||||
private async companyUpdated(content: ConsoleHookBodyContent) {
|
||||
await this.updateCompany(content.company);
|
||||
}
|
||||
|
||||
private async planUpdated(content: ConsoleHookBodyContent) {
|
||||
await this.updateCompany(content.company);
|
||||
}
|
||||
|
||||
private async authByPassword(email: string, password: string): Promise<AccessToken> {
|
||||
const user = await gr.services.users.getByEmail(email);
|
||||
if (!user) {
|
||||
throw CrudException.forbidden("User doesn't exists");
|
||||
}
|
||||
|
||||
// allow to login in development mode with any password. This can be used to test without the console provider because the password is not stored locally...
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
const [storedPassword, salt] = await gr.services.users.getHashedPassword({
|
||||
id: user.id,
|
||||
});
|
||||
|
||||
if (!(await this.passwordEncoder.isPasswordValid(storedPassword, password, salt))) {
|
||||
throw CrudException.forbidden("Password doesn't match");
|
||||
}
|
||||
} else if (process.env.NODE_ENV === "development") {
|
||||
logger.warn("ERROR_NOTONPROD: YOU ARE RUNNING IN DEVELOPMENT MODE, AUTH IS DISABLED!!!");
|
||||
}
|
||||
|
||||
return gr.platformServices.auth.generateJWT(user.id, user.email_canonical, {
|
||||
track: user?.preferences?.allow_tracking || false,
|
||||
provider_id: user.identity_provider_id,
|
||||
});
|
||||
}
|
||||
|
||||
private async authByToken(idToken: string): Promise<AccessToken> {
|
||||
const client = gr.services.console.getClient();
|
||||
const userDTO = await client.getUserByAccessToken(idToken);
|
||||
const user = await client.updateLocalUserFromConsole(userDTO);
|
||||
if (!user) {
|
||||
throw CrudException.notFound(`User details not found for access token ${idToken}`);
|
||||
}
|
||||
return gr.platformServices.auth.generateJWT(user.id, user.email_canonical, {
|
||||
track: user?.preferences?.allow_tracking || false,
|
||||
provider_id: user.identity_provider_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { FastifyInstance, FastifyRegisterOptions } from "fastify";
|
||||
import routes from "./routes";
|
||||
|
||||
export default (
|
||||
fastify: FastifyInstance,
|
||||
options: FastifyRegisterOptions<{
|
||||
prefix: string;
|
||||
}>,
|
||||
): void => {
|
||||
fastify.log.debug("Configuring /console routes");
|
||||
fastify.register(routes, options);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { FastifyInstance, FastifyPluginCallback, FastifyRequest } from "fastify";
|
||||
import { authenticationSchema, consoleHookSchema, tokenRenewalSchema } from "./schemas";
|
||||
// import { WorkspaceBaseRequest, WorkspaceUsersBaseRequest, WorkspaceUsersRequest } from "./types";
|
||||
import { ConsoleController } from "./controller";
|
||||
import { ConsoleHookBody, ConsoleHookQueryString } from "../types";
|
||||
|
||||
const hookUrl = "/hook";
|
||||
|
||||
const routes: FastifyPluginCallback = (fastify: FastifyInstance, options, next) => {
|
||||
const controller = new ConsoleController();
|
||||
|
||||
const accessControl = async (
|
||||
request: FastifyRequest<{ Body: ConsoleHookBody; Querystring: ConsoleHookQueryString }>,
|
||||
) => {
|
||||
throw fastify.httpErrors.notImplemented(`Hook service doesn't exist anymore, ${request}.`);
|
||||
};
|
||||
|
||||
fastify.route({
|
||||
method: "POST",
|
||||
url: `${hookUrl}`,
|
||||
preHandler: [accessControl],
|
||||
schema: consoleHookSchema,
|
||||
handler: controller.hook.bind(controller),
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: "POST",
|
||||
url: "/login",
|
||||
schema: authenticationSchema,
|
||||
handler: controller.auth.bind(controller),
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: "POST",
|
||||
url: "/signup",
|
||||
handler: controller.signup.bind(controller),
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: "POST",
|
||||
url: "/token",
|
||||
preValidation: [fastify.authenticate],
|
||||
schema: tokenRenewalSchema,
|
||||
handler: controller.tokenRenewal.bind(controller),
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: "POST",
|
||||
url: "/resend-verification-email",
|
||||
preValidation: [fastify.authenticate],
|
||||
handler: controller.resendVerificationEmail.bind(controller),
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
export default routes;
|
||||
@@ -0,0 +1,77 @@
|
||||
export const consoleHookSchema = {
|
||||
querystring: {
|
||||
type: "object",
|
||||
properties: {
|
||||
secret_key: { type: "string" },
|
||||
},
|
||||
},
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: { type: "string" },
|
||||
content: { type: "object" },
|
||||
signature: { type: "string" },
|
||||
secret_key: { type: "string" },
|
||||
},
|
||||
required: ["type", "content"],
|
||||
},
|
||||
response: {
|
||||
"2xx": {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
error: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const authenticationSchema = {
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
email: { type: "string" },
|
||||
password: { type: "string" },
|
||||
oidc_id_token: { type: "string" },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
"2xx": {
|
||||
type: "object",
|
||||
properties: {
|
||||
access_token: {
|
||||
type: "object",
|
||||
properties: {
|
||||
time: { type: "number" },
|
||||
expiration: { type: "number" },
|
||||
refresh_expiration: { type: "number" },
|
||||
value: { type: "string" },
|
||||
refresh: { type: "string" },
|
||||
type: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const tokenRenewalSchema = {
|
||||
response: {
|
||||
"2xx": {
|
||||
type: "object",
|
||||
properties: {
|
||||
access_token: {
|
||||
type: "object",
|
||||
properties: {
|
||||
time: { type: "number" },
|
||||
expiration: { type: "number" },
|
||||
refresh_expiration: { type: "number" },
|
||||
value: { type: "string" },
|
||||
refresh: { type: "string" },
|
||||
type: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
export const stopWords = [
|
||||
"about",
|
||||
"above",
|
||||
"after",
|
||||
"again",
|
||||
"all",
|
||||
"also",
|
||||
"am",
|
||||
"an",
|
||||
"and",
|
||||
"another",
|
||||
"any",
|
||||
"are",
|
||||
"as",
|
||||
"at",
|
||||
"be",
|
||||
"because",
|
||||
"been",
|
||||
"before",
|
||||
"being",
|
||||
"below",
|
||||
"between",
|
||||
"both",
|
||||
"but",
|
||||
"by",
|
||||
"came",
|
||||
"can",
|
||||
"cannot",
|
||||
"come",
|
||||
"could",
|
||||
"did",
|
||||
"do",
|
||||
"does",
|
||||
"doing",
|
||||
"during",
|
||||
"each",
|
||||
"few",
|
||||
"for",
|
||||
"from",
|
||||
"further",
|
||||
"get",
|
||||
"got",
|
||||
"has",
|
||||
"had",
|
||||
"he",
|
||||
"have",
|
||||
"her",
|
||||
"here",
|
||||
"him",
|
||||
"himself",
|
||||
"his",
|
||||
"how",
|
||||
"if",
|
||||
"in",
|
||||
"into",
|
||||
"is",
|
||||
"it",
|
||||
"its",
|
||||
"itself",
|
||||
"like",
|
||||
"make",
|
||||
"many",
|
||||
"me",
|
||||
"might",
|
||||
"more",
|
||||
"most",
|
||||
"much",
|
||||
"must",
|
||||
"my",
|
||||
"myself",
|
||||
"never",
|
||||
"now",
|
||||
"of",
|
||||
"on",
|
||||
"only",
|
||||
"or",
|
||||
"other",
|
||||
"our",
|
||||
"ours",
|
||||
"ourselves",
|
||||
"out",
|
||||
"over",
|
||||
"own",
|
||||
"said",
|
||||
"same",
|
||||
"see",
|
||||
"should",
|
||||
"since",
|
||||
"so",
|
||||
"some",
|
||||
"still",
|
||||
"such",
|
||||
"take",
|
||||
"than",
|
||||
"that",
|
||||
"the",
|
||||
"their",
|
||||
"theirs",
|
||||
"them",
|
||||
"themselves",
|
||||
"then",
|
||||
"there",
|
||||
"these",
|
||||
"they",
|
||||
"this",
|
||||
"those",
|
||||
"through",
|
||||
"to",
|
||||
"too",
|
||||
"under",
|
||||
"until",
|
||||
"up",
|
||||
"very",
|
||||
"was",
|
||||
"way",
|
||||
"we",
|
||||
"well",
|
||||
"were",
|
||||
"what",
|
||||
"where",
|
||||
"when",
|
||||
"which",
|
||||
"while",
|
||||
"who",
|
||||
"whom",
|
||||
"with",
|
||||
"would",
|
||||
"why",
|
||||
"you",
|
||||
"your",
|
||||
"yours",
|
||||
"yourself",
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
"d",
|
||||
"e",
|
||||
"f",
|
||||
"g",
|
||||
"h",
|
||||
"i",
|
||||
"j",
|
||||
"k",
|
||||
"l",
|
||||
"m",
|
||||
"n",
|
||||
"o",
|
||||
"p",
|
||||
"q",
|
||||
"r",
|
||||
"s",
|
||||
"t",
|
||||
"u",
|
||||
"v",
|
||||
"w",
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"$",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
"0",
|
||||
"_",
|
||||
];
|
||||
@@ -0,0 +1,33 @@
|
||||
import { DriveFile, TYPE } from "./drive-file";
|
||||
|
||||
export default {
|
||||
index: TYPE,
|
||||
source: (entity: DriveFile) => ({
|
||||
content_keywords: entity.content_keywords,
|
||||
tags: (entity.tags || []).join(" "),
|
||||
creator: entity.creator,
|
||||
added: entity.added,
|
||||
name: entity.name,
|
||||
company_id: entity.company_id,
|
||||
}),
|
||||
mongoMapping: {
|
||||
text: {
|
||||
content_keywords: "text",
|
||||
tags: "text",
|
||||
creator: "text",
|
||||
added: "text",
|
||||
name: "text",
|
||||
company_id: "text",
|
||||
},
|
||||
},
|
||||
esMapping: {
|
||||
properties: {
|
||||
name: { type: "text", index_prefixes: { min_chars: 1 } },
|
||||
content_keywords: { type: "text", index_prefixes: { min_chars: 1 } },
|
||||
tags: { type: "keyword" },
|
||||
creator: { type: "keyword" },
|
||||
added: { type: "keyword" },
|
||||
company_id: { type: "keyword" },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Type } from "class-transformer";
|
||||
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
|
||||
import { DriveFileAccessLevel, publicAccessLevel } from "../types";
|
||||
import { FileVersion } from "./file-version";
|
||||
import search from "./drive-file.search";
|
||||
|
||||
export const TYPE = "drive_files";
|
||||
|
||||
@Entity(TYPE, {
|
||||
globalIndexes: [["company_id", "parent_id"]],
|
||||
primaryKey: [["company_id"], "id"],
|
||||
type: TYPE,
|
||||
search,
|
||||
})
|
||||
export class DriveFile {
|
||||
@Type(() => String)
|
||||
@Column("company_id", "uuid")
|
||||
company_id: string;
|
||||
|
||||
@Type(() => String)
|
||||
@Column("id", "uuid", { generator: "uuid" })
|
||||
id: string;
|
||||
|
||||
@Type(() => String)
|
||||
@Column("parent_id", "string")
|
||||
parent_id: string;
|
||||
|
||||
@Type(() => Boolean)
|
||||
@Column("is_in_trash", "boolean")
|
||||
is_in_trash: boolean;
|
||||
|
||||
@Type(() => Boolean)
|
||||
@Column("is_directory", "boolean")
|
||||
is_directory: boolean;
|
||||
|
||||
@Type(() => String)
|
||||
@Column("name", "string")
|
||||
name: string;
|
||||
|
||||
@Type(() => String)
|
||||
@Column("extension", "string")
|
||||
extension: string;
|
||||
|
||||
@Type(() => String)
|
||||
@Column("description", "string")
|
||||
description: string;
|
||||
|
||||
@Column("tags", "encoded_json")
|
||||
tags: string[];
|
||||
|
||||
@Type(() => String)
|
||||
@Column("added", "string")
|
||||
added: string;
|
||||
|
||||
@Type(() => String)
|
||||
@Column("last_modified", "string")
|
||||
last_modified: string;
|
||||
|
||||
@Column("access_info", "encoded_json")
|
||||
access_info: AccessInformation;
|
||||
|
||||
@Type(() => String)
|
||||
@Column("content_keywords", "string")
|
||||
content_keywords: string;
|
||||
|
||||
@Type(() => String)
|
||||
@Column("creator", "uuid")
|
||||
creator: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@Column("size", "number")
|
||||
size: number;
|
||||
|
||||
@Column("last_version_cache", "encoded_json")
|
||||
last_version_cache: Partial<FileVersion>;
|
||||
}
|
||||
|
||||
export type AccessInformation = {
|
||||
public?: {
|
||||
token: string;
|
||||
level: publicAccessLevel;
|
||||
};
|
||||
entities: AuthEntity[];
|
||||
};
|
||||
|
||||
type AuthEntity = {
|
||||
type: "user" | "channel" | "company" | "folder";
|
||||
id: string | "parent";
|
||||
level: publicAccessLevel | DriveFileAccessLevel;
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Type } from "class-transformer";
|
||||
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
|
||||
|
||||
export const TYPE = "drive_tdrive_tab";
|
||||
|
||||
@Entity(TYPE, {
|
||||
primaryKey: [["company_id"], "tab_id"],
|
||||
type: TYPE,
|
||||
})
|
||||
export class DriveTdriveTab {
|
||||
@Type(() => String)
|
||||
@Column("company_id", "string")
|
||||
company_id: string;
|
||||
|
||||
@Type(() => String)
|
||||
@Column("tab_id", "string")
|
||||
tab_id: string;
|
||||
|
||||
@Type(() => String)
|
||||
@Column("channel__id", "string")
|
||||
channel_id: string;
|
||||
|
||||
@Type(() => String)
|
||||
@Column("item_id", "string")
|
||||
item_id: string;
|
||||
|
||||
@Type(() => String)
|
||||
@Column("level", "string")
|
||||
level: "read" | "write";
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Type } from "class-transformer";
|
||||
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
|
||||
|
||||
export const TYPE = "drive_file_versions";
|
||||
|
||||
@Entity(TYPE, {
|
||||
primaryKey: [["drive_item_id"], "id"],
|
||||
type: TYPE,
|
||||
})
|
||||
export class FileVersion {
|
||||
@Type(() => String)
|
||||
@Column("drive_item_id", "uuid")
|
||||
drive_item_id: string;
|
||||
|
||||
@Type(() => String)
|
||||
@Column("id", "uuid", { generator: "uuid" })
|
||||
id: string;
|
||||
|
||||
@Type(() => String)
|
||||
@Column("provider", "string")
|
||||
provider: "internal" | "drive" | string;
|
||||
|
||||
@Column("file_metadata", "encoded_json")
|
||||
file_metadata: DriveFileMetadata;
|
||||
|
||||
@Type(() => Number)
|
||||
@Column("date_added", "number")
|
||||
date_added: number;
|
||||
|
||||
@Type(() => String)
|
||||
@Column("creator_id", "uuid")
|
||||
creator_id: string;
|
||||
|
||||
@Type(() => String)
|
||||
@Column("application_id", "string")
|
||||
application_id: string;
|
||||
|
||||
@Type(() => String)
|
||||
@Column("realname", "string")
|
||||
realname: string;
|
||||
|
||||
@Type(() => String)
|
||||
@Column("key", "string")
|
||||
key: string;
|
||||
|
||||
@Type(() => String)
|
||||
@Column("mode", "string")
|
||||
mode: string | "OpenSSL-2";
|
||||
|
||||
@Type(() => Number)
|
||||
@Column("file_size", "number")
|
||||
file_size: number;
|
||||
|
||||
@Type(() => String)
|
||||
@Column("filename", "string")
|
||||
filename: string;
|
||||
|
||||
@Column("data", "encoded_json")
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export type DriveFileMetadata = {
|
||||
source?: "internal" | "drive" | string;
|
||||
external_id: string;
|
||||
|
||||
name?: string;
|
||||
mime?: string;
|
||||
size?: number;
|
||||
thumbnails?: DriveFileThumbnail[];
|
||||
};
|
||||
|
||||
type DriveFileThumbnail = {
|
||||
index: number;
|
||||
id: string;
|
||||
|
||||
type: string;
|
||||
size: number;
|
||||
width: number;
|
||||
height: number;
|
||||
|
||||
url: string;
|
||||
full_url?: string;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Prefix, TdriveService } from "../../core/platform/framework";
|
||||
import WebServerAPI from "../../core/platform/services/webserver/provider";
|
||||
import web from "./web";
|
||||
|
||||
@Prefix("/internal/services/documents/v1")
|
||||
export default class DocumentsService extends TdriveService<undefined> {
|
||||
version = "1";
|
||||
name = "documents";
|
||||
|
||||
public doInit: () => Promise<this> = async () => {
|
||||
const fastify = this.context.getProvider<WebServerAPI>("webserver").getServer();
|
||||
fastify.register((instance, _options, next) => {
|
||||
web(instance, { prefix: this.prefix });
|
||||
next();
|
||||
});
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
api = (): undefined => {
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import globalResolver from "../../../../services/global-resolver";
|
||||
import { logger } from "../../../../core/platform/framework";
|
||||
import { MessageQueueHandler } from "../../../../core/platform/services/message-queue/api";
|
||||
import { DocumentsMessageQueueCallback, DocumentsMessageQueueRequest } from "../../types";
|
||||
import { extractKeywords, officeFileToString, pdfFileToString, isFileType } from "../../utils";
|
||||
import { officeExtensions, textExtensions, pdfExtensions } from "../../../../utils/mime";
|
||||
import { readableToString } from "../../../../utils/files";
|
||||
|
||||
export class DocumentsProcessor
|
||||
implements MessageQueueHandler<DocumentsMessageQueueRequest, DocumentsMessageQueueCallback>
|
||||
{
|
||||
readonly name = "DocumentsProcessor";
|
||||
|
||||
readonly topics = {
|
||||
in: "services:documents:process",
|
||||
out: "services:documents:process:callback",
|
||||
};
|
||||
|
||||
readonly options = {
|
||||
unique: true,
|
||||
ack: true,
|
||||
};
|
||||
|
||||
validate(message: DocumentsMessageQueueRequest): boolean {
|
||||
return !!(
|
||||
message &&
|
||||
message.context &&
|
||||
message.item &&
|
||||
message.item.id &&
|
||||
message.version &&
|
||||
message.version.file_metadata &&
|
||||
message.version.file_metadata.external_id &&
|
||||
!message.item.is_directory
|
||||
);
|
||||
}
|
||||
|
||||
async process(message: DocumentsMessageQueueRequest): Promise<DocumentsMessageQueueCallback> {
|
||||
logger.info(`${this.name} - process document content keywords for ${message.item.id}`);
|
||||
|
||||
return await this.generate(message);
|
||||
}
|
||||
|
||||
async generate(message: DocumentsMessageQueueRequest): Promise<DocumentsMessageQueueCallback> {
|
||||
let content_keywords = "";
|
||||
let content_strings = "";
|
||||
|
||||
try {
|
||||
const storedFile = await globalResolver.services.files.download(
|
||||
message.version.file_metadata.external_id,
|
||||
message.context,
|
||||
);
|
||||
|
||||
const extension = storedFile.name.split(".").pop();
|
||||
|
||||
if (isFileType(storedFile.mime, storedFile.name, textExtensions)) {
|
||||
logger.info("Processing text file");
|
||||
content_strings = await readableToString(storedFile.file);
|
||||
}
|
||||
|
||||
if (isFileType(storedFile.mime, storedFile.name, pdfExtensions)) {
|
||||
logger.info("Processing PDF file");
|
||||
content_strings = await pdfFileToString(storedFile.file);
|
||||
}
|
||||
|
||||
if (isFileType(storedFile.mime, storedFile.name, officeExtensions)) {
|
||||
logger.info("Processing office file");
|
||||
content_strings = await officeFileToString(storedFile.file, extension);
|
||||
}
|
||||
|
||||
content_keywords = extractKeywords(content_strings);
|
||||
} catch (error) {
|
||||
console.debug(error);
|
||||
logger.error("Failed to generate content keywords", error);
|
||||
}
|
||||
|
||||
return { content_keywords, item: message.item };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import globalResolver from "../../../global-resolver";
|
||||
import { Initializable } from "../../../../core/platform/framework";
|
||||
import { DocumentsProcessor } from "./extract-keywords";
|
||||
import { DriveFile, TYPE } from "../../entities/drive-file";
|
||||
import { DocumentsFinishedProcess } from "./save-keywords";
|
||||
|
||||
export class DocumentsEngine implements Initializable {
|
||||
async init(): Promise<this> {
|
||||
const repository = await globalResolver.database.getRepository<DriveFile>(TYPE, DriveFile);
|
||||
|
||||
globalResolver.platformServices.messageQueue.processor.addHandler(new DocumentsProcessor());
|
||||
globalResolver.platformServices.messageQueue.processor.addHandler(
|
||||
new DocumentsFinishedProcess(repository),
|
||||
);
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { logger } from "../../../../core/platform/framework";
|
||||
import { ExecutionContext } from "../../../../core/platform/framework/api/crud-service";
|
||||
import Repository from "../../../../core/platform/services/database/services/orm/repository/repository";
|
||||
import { MessageQueueHandler } from "../../../../core/platform/services/message-queue/api";
|
||||
import { DriveFile } from "../../entities/drive-file";
|
||||
import { DocumentsMessageQueueCallback } from "../../types";
|
||||
|
||||
export class DocumentsFinishedProcess
|
||||
implements MessageQueueHandler<DocumentsMessageQueueCallback, void>
|
||||
{
|
||||
constructor(private repository: Repository<DriveFile>) {}
|
||||
|
||||
readonly name = "DocumentsFinishedProcess";
|
||||
readonly topics = {
|
||||
in: "services:documents:process:callback",
|
||||
};
|
||||
|
||||
readonly options = {
|
||||
unique: true,
|
||||
ack: true,
|
||||
};
|
||||
|
||||
init? = (): Promise<this> => {
|
||||
throw Error("Method not implemented.");
|
||||
};
|
||||
|
||||
validate = (message: DocumentsMessageQueueCallback): boolean => {
|
||||
return !!(
|
||||
message &&
|
||||
message.content_keywords &&
|
||||
message.content_keywords.length &&
|
||||
message.item &&
|
||||
message.item.id
|
||||
);
|
||||
};
|
||||
|
||||
process = async (
|
||||
message: DocumentsMessageQueueCallback,
|
||||
context?: ExecutionContext,
|
||||
): Promise<void> => {
|
||||
logger.info(`${this.name} - updating drive item content keywords`);
|
||||
|
||||
try {
|
||||
const entity = await this.repository.findOne(
|
||||
{
|
||||
id: message.item.id,
|
||||
company_id: message.item.company_id,
|
||||
},
|
||||
{},
|
||||
context,
|
||||
);
|
||||
|
||||
if (!entity) {
|
||||
throw Error("Drive item not found");
|
||||
}
|
||||
|
||||
entity.content_keywords = message.content_keywords;
|
||||
|
||||
return await this.repository.save(entity, context);
|
||||
} catch (error) {
|
||||
logger.error(`${this.name} - Failed to set content keywords`, error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,840 @@
|
||||
import SearchRepository from "../../../core/platform/services/search/repository";
|
||||
import { getLogger, logger, TdriveLogger } from "../../../core/platform/framework";
|
||||
import { CrudException, ListResult } from "../../../core/platform/framework/api/crud-service";
|
||||
import Repository from "../../../core/platform/services/database/services/orm/repository/repository";
|
||||
import { PublicFile } from "../../../services/files/entities/file";
|
||||
import globalResolver from "../../../services/global-resolver";
|
||||
import { hasCompanyAdminLevel } from "../../../utils/company";
|
||||
import gr from "../../global-resolver";
|
||||
import { DriveFile, TYPE } from "../entities/drive-file";
|
||||
import { FileVersion, TYPE as FileVersionType } from "../entities/file-version";
|
||||
import {
|
||||
DriveTdriveTab as DriveTdriveTabEntity,
|
||||
TYPE as DriveTdriveTabRepoType,
|
||||
} from "../entities/drive-tdrive-tab";
|
||||
import {
|
||||
DriveExecutionContext,
|
||||
DocumentsMessageQueueRequest,
|
||||
DriveItemDetails,
|
||||
RootType,
|
||||
SearchDocumentsOptions,
|
||||
TrashType,
|
||||
CompanyExecutionContext,
|
||||
DriveTdriveTab,
|
||||
} from "../types";
|
||||
import {
|
||||
addDriveItemToArchive,
|
||||
calculateItemSize,
|
||||
canMoveItem,
|
||||
checkAccess,
|
||||
getAccessLevel,
|
||||
getDefaultDriveItem,
|
||||
getDefaultDriveItemVersion,
|
||||
getFileMetadata,
|
||||
getItemName,
|
||||
getPath,
|
||||
hasAccessLevel,
|
||||
makeStandaloneAccessLevel,
|
||||
updateItemSize,
|
||||
} from "../utils";
|
||||
import { websocketEventBus } from "../../../core/platform/services/realtime/bus";
|
||||
|
||||
import archiver from "archiver";
|
||||
import internal from "stream";
|
||||
import {
|
||||
RealtimeEntityActionType,
|
||||
ResourcePath,
|
||||
} from "../../../core/platform/services/realtime/types";
|
||||
|
||||
export class DocumentsService {
|
||||
version: "1";
|
||||
repository: Repository<DriveFile>;
|
||||
searchRepository: SearchRepository<DriveFile>;
|
||||
fileVersionRepository: Repository<FileVersion>;
|
||||
driveTdriveTabRepository: Repository<DriveTdriveTabEntity>;
|
||||
ROOT: RootType = "root";
|
||||
TRASH: TrashType = "trash";
|
||||
logger: TdriveLogger = getLogger("Documents Service");
|
||||
|
||||
async init(): Promise<this> {
|
||||
try {
|
||||
this.repository = await globalResolver.database.getRepository<DriveFile>(TYPE, DriveFile);
|
||||
this.searchRepository = globalResolver.platformServices.search.getRepository<DriveFile>(
|
||||
TYPE,
|
||||
DriveFile,
|
||||
);
|
||||
this.fileVersionRepository = await globalResolver.database.getRepository<FileVersion>(
|
||||
FileVersionType,
|
||||
FileVersion,
|
||||
);
|
||||
this.driveTdriveTabRepository =
|
||||
await globalResolver.database.getRepository<DriveTdriveTabEntity>(
|
||||
DriveTdriveTabRepoType,
|
||||
DriveTdriveTabEntity,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error while initializing Documents Service", error);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a drive element
|
||||
*
|
||||
* @param {string} id - the id of the DriveFile to fetch or "trash" or an empty string for root folder.
|
||||
* @param {DriveExecutionContext} context
|
||||
* @returns {Promise<DriveItemDetails>}
|
||||
*/
|
||||
get = async (id: string, context: DriveExecutionContext): Promise<DriveItemDetails> => {
|
||||
if (!context) {
|
||||
this.logger.error("invalid context");
|
||||
return null;
|
||||
}
|
||||
|
||||
id = id || this.ROOT;
|
||||
|
||||
//Get requested entity
|
||||
const entity =
|
||||
id === this.ROOT || id === this.TRASH
|
||||
? null
|
||||
: await this.repository.findOne(
|
||||
{
|
||||
company_id: context.company.id,
|
||||
id,
|
||||
},
|
||||
{},
|
||||
context,
|
||||
);
|
||||
|
||||
if (!entity && !(id === this.ROOT || id === this.TRASH)) {
|
||||
this.logger.error("Drive item not found");
|
||||
throw new CrudException("Item not found", 404);
|
||||
}
|
||||
|
||||
//Check access to entity
|
||||
try {
|
||||
const hasAccess = await checkAccess(id, entity, "read", this.repository, context);
|
||||
if (!hasAccess) {
|
||||
this.logger.error("user does not have access drive item ", id);
|
||||
throw Error("user does not have access to this item");
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to grant access to the drive item", error);
|
||||
throw new CrudException("User does not have access to this item or its children", 401);
|
||||
}
|
||||
|
||||
const isDirectory = entity ? entity.is_directory : true;
|
||||
|
||||
//Get entity version in case of a file
|
||||
const versions = isDirectory
|
||||
? []
|
||||
: (
|
||||
await this.fileVersionRepository.find(
|
||||
{
|
||||
drive_item_id: entity.id,
|
||||
},
|
||||
{},
|
||||
context,
|
||||
)
|
||||
).getEntities();
|
||||
|
||||
//Get children if it is a directory
|
||||
let children = isDirectory
|
||||
? (
|
||||
await this.repository.find(
|
||||
{
|
||||
company_id: context.company.id,
|
||||
parent_id: id,
|
||||
},
|
||||
{},
|
||||
context,
|
||||
)
|
||||
).getEntities()
|
||||
: [];
|
||||
|
||||
//Check each children for access
|
||||
const accessMap: { [key: string]: boolean } = {};
|
||||
await Promise.all(
|
||||
children.map(async child => {
|
||||
accessMap[child.id] = await checkAccess(child.id, child, "read", this.repository, context);
|
||||
}),
|
||||
);
|
||||
children = children.filter(child => accessMap[child.id]);
|
||||
|
||||
//Return complete object
|
||||
return {
|
||||
path: await getPath(id, this.repository, false, context),
|
||||
item:
|
||||
entity ||
|
||||
({
|
||||
id,
|
||||
parent_id: null,
|
||||
name: id === this.ROOT ? "root" : id === this.TRASH ? "trash" : "unknown",
|
||||
size: await calculateItemSize(
|
||||
id === this.ROOT ? this.ROOT : "trash",
|
||||
this.repository,
|
||||
context,
|
||||
),
|
||||
} as DriveFile),
|
||||
versions: versions,
|
||||
children: children,
|
||||
access: await getAccessLevel(id, entity, this.repository, context),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a DriveFile item.
|
||||
*
|
||||
* @param {PublicFile} file - the multipart file
|
||||
* @param {Partial<DriveFile>} content - the DriveFile item to create
|
||||
* @param {Partial<FileVersion>} version - the DriveFile version.
|
||||
* @param {DriveExecutionContext} context - the company execution context.
|
||||
* @returns {Promise<DriveFile>} - the created DriveFile
|
||||
*/
|
||||
create = async (
|
||||
file: PublicFile | null,
|
||||
content: Partial<DriveFile>,
|
||||
version: Partial<FileVersion>,
|
||||
context: DriveExecutionContext,
|
||||
): Promise<DriveFile> => {
|
||||
try {
|
||||
const driveItem = getDefaultDriveItem(content, context);
|
||||
const driveItemVersion = getDefaultDriveItemVersion(version, context);
|
||||
|
||||
const hasAccess = await checkAccess(
|
||||
driveItem.parent_id,
|
||||
null,
|
||||
"write",
|
||||
this.repository,
|
||||
context,
|
||||
);
|
||||
if (!hasAccess) {
|
||||
this.logger.error("User does not have access to parent drive item", driveItem.parent_id);
|
||||
throw Error("User does not have access to this item parent");
|
||||
}
|
||||
|
||||
if (file || driveItem.is_directory === false) {
|
||||
let fileToProcess;
|
||||
|
||||
if (file) {
|
||||
fileToProcess = file;
|
||||
} else if (driveItemVersion.file_metadata.external_id) {
|
||||
fileToProcess = await globalResolver.services.files.getFile(
|
||||
{
|
||||
id: driveItemVersion.file_metadata.external_id,
|
||||
company_id: driveItem.company_id,
|
||||
},
|
||||
context,
|
||||
{ waitForThumbnail: true },
|
||||
);
|
||||
}
|
||||
|
||||
if (fileToProcess) {
|
||||
driveItem.size = fileToProcess.upload_data.size;
|
||||
driveItem.is_directory = false;
|
||||
driveItem.extension = fileToProcess.metadata.name.split(".").pop();
|
||||
driveItemVersion.filename = driveItemVersion.filename || fileToProcess.metadata.name;
|
||||
driveItemVersion.file_size = fileToProcess.upload_data.size;
|
||||
driveItemVersion.file_metadata.external_id = fileToProcess.id;
|
||||
driveItemVersion.file_metadata.mime = fileToProcess.metadata.mime;
|
||||
driveItemVersion.file_metadata.size = fileToProcess.upload_data.size;
|
||||
driveItemVersion.file_metadata.name = fileToProcess.metadata.name;
|
||||
driveItemVersion.file_metadata.thumbnails = fileToProcess.thumbnails;
|
||||
if (context.user.application_id) {
|
||||
driveItemVersion.application_id = context.user.application_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
driveItem.name = await getItemName(
|
||||
driveItem.parent_id,
|
||||
driveItem.id,
|
||||
driveItem.name,
|
||||
driveItem.is_directory,
|
||||
this.repository,
|
||||
context,
|
||||
);
|
||||
|
||||
await this.repository.save(driveItem);
|
||||
driveItemVersion.drive_item_id = driveItem.id;
|
||||
|
||||
await this.fileVersionRepository.save(driveItemVersion);
|
||||
driveItem.last_version_cache = driveItemVersion;
|
||||
|
||||
await this.repository.save(driveItem);
|
||||
await updateItemSize(driveItem.parent_id, this.repository, context);
|
||||
|
||||
this.notifyWebsocket(driveItem.parent_id, context);
|
||||
|
||||
globalResolver.platformServices.messageQueue.publish<DocumentsMessageQueueRequest>(
|
||||
"services:documents:process",
|
||||
{
|
||||
data: {
|
||||
item: driveItem,
|
||||
version: driveItemVersion,
|
||||
context,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return driveItem;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to create drive item", error);
|
||||
throw new CrudException("Failed to create item", 500);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates a DriveFile item
|
||||
*
|
||||
* @param {string} id - the id of the DriveFile to update.
|
||||
* @param {Partial<DriveFile>} content - the updated content
|
||||
* @param {DriveExecutionContext} context - the company execution context
|
||||
* @returns {Promise<DriveFile>} - the updated DriveFile
|
||||
*/
|
||||
update = async (
|
||||
id: string,
|
||||
content: Partial<DriveFile>,
|
||||
context: CompanyExecutionContext,
|
||||
): Promise<DriveFile> => {
|
||||
if (!context) {
|
||||
this.logger.error("invalid execution context");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
let oldParent = null;
|
||||
const level = await getAccessLevel(id, null, this.repository, context);
|
||||
const hasAccess = hasAccessLevel("write", level);
|
||||
|
||||
if (!hasAccess) {
|
||||
this.logger.error("user does not have access drive item ", id);
|
||||
throw Error("user does not have access to this item");
|
||||
}
|
||||
|
||||
const item = await this.repository.findOne({
|
||||
company_id: context.company.id,
|
||||
id,
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
this.logger.error("Drive item not found");
|
||||
throw Error("Item not found");
|
||||
}
|
||||
|
||||
if (content.id && content.id !== id) {
|
||||
this.logger.error("content mismatch");
|
||||
throw Error("content mismatch");
|
||||
}
|
||||
|
||||
const updatable = ["access_info", "name", "tags", "parent_id", "description"];
|
||||
|
||||
for (const key of updatable) {
|
||||
if ((content as any)[key]) {
|
||||
if (
|
||||
key === "parent_id" &&
|
||||
!(await canMoveItem(item.id, content.parent_id, this.repository, context))
|
||||
) {
|
||||
throw Error("Move operation not permitted");
|
||||
} else {
|
||||
oldParent = item.parent_id;
|
||||
}
|
||||
|
||||
if (key === "name") {
|
||||
item.name = await getItemName(
|
||||
content.parent_id || item.parent_id,
|
||||
item.id,
|
||||
content.name,
|
||||
item.is_directory,
|
||||
this.repository,
|
||||
context,
|
||||
);
|
||||
} else {
|
||||
(item as any)[key] = (content as any)[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//We cannot do a change that would make the item unreachable
|
||||
if (
|
||||
level === "manage" &&
|
||||
!(await checkAccess(item.id, item, "manage", this.repository, context))
|
||||
) {
|
||||
throw new Error("Cannot change access level to make the item unreachable");
|
||||
}
|
||||
|
||||
await this.repository.save(item);
|
||||
await updateItemSize(item.parent_id, this.repository, context);
|
||||
|
||||
if (oldParent) {
|
||||
await updateItemSize(oldParent, this.repository, context);
|
||||
this.notifyWebsocket(oldParent, context);
|
||||
}
|
||||
|
||||
this.notifyWebsocket(item.parent_id, context);
|
||||
|
||||
if (item.parent_id === this.TRASH) {
|
||||
//When moving to trash we recompute the access level to make them flat
|
||||
item.access_info = await makeStandaloneAccessLevel(
|
||||
item.company_id,
|
||||
item.id,
|
||||
item,
|
||||
this.repository,
|
||||
);
|
||||
}
|
||||
|
||||
return item;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to update drive item", error);
|
||||
throw new CrudException("Failed to update item", 500);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* deletes or moves to Trash a Drive Document and its children
|
||||
*
|
||||
* @param {string} id - the item id
|
||||
* @param {DriveExecutionContext} context - the execution context
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
delete = async (
|
||||
id: string | RootType | TrashType,
|
||||
item?: DriveFile,
|
||||
context?: DriveExecutionContext,
|
||||
): Promise<void> => {
|
||||
if (!id) {
|
||||
//We can't remove the root folder
|
||||
return;
|
||||
}
|
||||
|
||||
//In the case of the trash we definitively delete the items
|
||||
if (id === "trash") {
|
||||
//Only administrators can execute this action
|
||||
const role = await gr.services.companies.getUserRole(context.company.id, context.user?.id);
|
||||
if (hasCompanyAdminLevel(role) === false) {
|
||||
throw new CrudException("Only administrators can empty the trash", 403);
|
||||
}
|
||||
|
||||
try {
|
||||
const itemsInTrash = await this.repository.find(
|
||||
{
|
||||
company_id: context.company.id,
|
||||
parent_id: "trash",
|
||||
},
|
||||
{},
|
||||
context,
|
||||
);
|
||||
await Promise.all(
|
||||
itemsInTrash.getEntities().map(async item => {
|
||||
await this.delete(item.id, item, context);
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to empty trash", error);
|
||||
throw new CrudException("Failed to empty trash", 500);
|
||||
}
|
||||
|
||||
return;
|
||||
} else {
|
||||
item =
|
||||
item ||
|
||||
(await this.repository.findOne({
|
||||
company_id: context.company.id,
|
||||
id,
|
||||
}));
|
||||
|
||||
if (!item) {
|
||||
this.logger.error("item to delete not found");
|
||||
throw new CrudException("Drive item not found", 404);
|
||||
}
|
||||
|
||||
try {
|
||||
if (!(await checkAccess(item.id, item, "manage", this.repository, context))) {
|
||||
this.logger.error("user does not have access drive item ", id);
|
||||
throw Error("user does not have access to this item");
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to grant access to the drive item", error);
|
||||
throw new CrudException("User does not have access to this item or its children", 401);
|
||||
}
|
||||
|
||||
const previousParentId = item.parent_id;
|
||||
if (
|
||||
item.parent_id === this.TRASH ||
|
||||
(await getPath(item.parent_id, this.repository, true, context))[0].id === this.TRASH
|
||||
) {
|
||||
//This item is already in trash, we can delete it definitively
|
||||
|
||||
if (item.is_directory) {
|
||||
//We delete the children
|
||||
const children = await this.repository.find(
|
||||
{
|
||||
company_id: context.company.id,
|
||||
parent_id: item.id,
|
||||
},
|
||||
{},
|
||||
context,
|
||||
);
|
||||
await Promise.all(
|
||||
children.getEntities().map(async child => {
|
||||
await this.delete(child.id, child, context);
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
//Delete the version and stored file
|
||||
const itemVersions = await this.fileVersionRepository.find(
|
||||
{
|
||||
drive_item_id: item.id,
|
||||
},
|
||||
{},
|
||||
context,
|
||||
);
|
||||
await Promise.all(
|
||||
itemVersions.getEntities().map(async version => {
|
||||
await this.fileVersionRepository.remove(version);
|
||||
await gr.services.files.delete(version.file_metadata.external_id, context);
|
||||
}),
|
||||
);
|
||||
}
|
||||
await this.repository.remove(item);
|
||||
} else {
|
||||
//This item is not in trash, we move it to trash
|
||||
item.parent_id = this.TRASH;
|
||||
await this.update(item.id, item, context);
|
||||
}
|
||||
await updateItemSize(previousParentId, this.repository, context);
|
||||
|
||||
this.notifyWebsocket(previousParentId, context);
|
||||
}
|
||||
|
||||
this.notifyWebsocket("trash", context);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a Drive item version
|
||||
*
|
||||
* @param {string} id - the Drive item id to create a version for.
|
||||
* @param {Partial<FileVersion>} version - the version item.
|
||||
* @param {DriveExecutionContext} context - the company execution context
|
||||
* @returns {Promise<FileVersion>} - the created FileVersion
|
||||
*/
|
||||
createVersion = async (
|
||||
id: string,
|
||||
version: Partial<FileVersion>,
|
||||
context: DriveExecutionContext,
|
||||
): Promise<FileVersion> => {
|
||||
if (!context) {
|
||||
this.logger.error("invalid execution context");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const hasAccess = await checkAccess(id, null, "write", this.repository, context);
|
||||
if (!hasAccess) {
|
||||
this.logger.error("user does not have access drive item ", id);
|
||||
throw Error("user does not have access to this item");
|
||||
}
|
||||
|
||||
const item = await this.repository.findOne(
|
||||
{
|
||||
id,
|
||||
company_id: context.company.id,
|
||||
},
|
||||
{},
|
||||
context,
|
||||
);
|
||||
|
||||
if (!item) {
|
||||
throw Error("Drive item not found");
|
||||
}
|
||||
|
||||
if (item.is_directory) {
|
||||
throw Error("cannot create version for a directory");
|
||||
}
|
||||
|
||||
const driveItemVersion = getDefaultDriveItemVersion(version, context);
|
||||
const metadata = await getFileMetadata(driveItemVersion.file_metadata.external_id, context);
|
||||
|
||||
driveItemVersion.file_size = metadata.size;
|
||||
driveItemVersion.file_metadata.size = metadata.size;
|
||||
driveItemVersion.file_metadata.name = metadata.name;
|
||||
driveItemVersion.file_metadata.mime = metadata.mime;
|
||||
driveItemVersion.file_metadata.thumbnails = metadata.thumbnails;
|
||||
driveItemVersion.drive_item_id = item.id;
|
||||
if (context.user.application_id) {
|
||||
driveItemVersion.application_id = context.user.application_id;
|
||||
}
|
||||
|
||||
await this.fileVersionRepository.save(driveItemVersion);
|
||||
|
||||
item.last_version_cache = driveItemVersion;
|
||||
item.size = driveItemVersion.file_size;
|
||||
|
||||
await this.repository.save(item);
|
||||
|
||||
this.notifyWebsocket(item.parent_id, context);
|
||||
await updateItemSize(item.parent_id, this.repository, context);
|
||||
|
||||
globalResolver.platformServices.messageQueue.publish<DocumentsMessageQueueRequest>(
|
||||
"services:documents:process",
|
||||
{
|
||||
data: {
|
||||
item,
|
||||
version: driveItemVersion,
|
||||
context,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return driveItemVersion;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to create Drive item version", error);
|
||||
throw new CrudException("Failed to create Drive item version", 500);
|
||||
}
|
||||
};
|
||||
|
||||
downloadGetToken = async (
|
||||
ids: string[],
|
||||
versionId: string | null,
|
||||
context: DriveExecutionContext,
|
||||
): Promise<string> => {
|
||||
for (const id of ids) {
|
||||
const item = await this.get(id, context);
|
||||
if (!item) {
|
||||
throw new CrudException("Drive item not found", 404);
|
||||
}
|
||||
}
|
||||
|
||||
return globalResolver.platformServices.auth.sign({
|
||||
ids,
|
||||
version_id: versionId,
|
||||
company_id: context.company.id,
|
||||
user_id: context.user?.id,
|
||||
});
|
||||
};
|
||||
|
||||
applyDownloadTokenToContext = async (
|
||||
ids: string[],
|
||||
versionId: string | null,
|
||||
token: string,
|
||||
context: DriveExecutionContext,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const v = globalResolver.platformServices.auth.verifyTokenObject<{
|
||||
ids: string[];
|
||||
version_id: string;
|
||||
company_id: string;
|
||||
user_id: string;
|
||||
}>(token);
|
||||
if (
|
||||
ids.some(a => !(v?.ids || [])?.includes(a)) ||
|
||||
(v?.version_id && versionId && v?.version_id !== versionId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.company = { id: v.company_id };
|
||||
context.user = { id: v.user_id };
|
||||
} catch (e) {
|
||||
if (token) throw new CrudException("Invalid token", 401);
|
||||
}
|
||||
};
|
||||
|
||||
download = async (
|
||||
id: string,
|
||||
versionId: string | null,
|
||||
context: DriveExecutionContext,
|
||||
): Promise<{
|
||||
archive?: archiver.Archiver;
|
||||
file?: {
|
||||
file: internal.Readable;
|
||||
name: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
};
|
||||
}> => {
|
||||
const item = await this.get(id, context);
|
||||
|
||||
if (item.item.is_directory) {
|
||||
return { archive: await this.createZip([id], context) };
|
||||
}
|
||||
|
||||
let version = item.item.last_version_cache;
|
||||
if (versionId) version = item.versions.find(version => version.id === versionId);
|
||||
if (!version) {
|
||||
throw new CrudException("Version not found", 404);
|
||||
}
|
||||
|
||||
const fileId = version.file_metadata.external_id;
|
||||
const file = await globalResolver.services.files.download(fileId, context);
|
||||
|
||||
return { file };
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a zip archive containing the drive items.
|
||||
*
|
||||
* @param {string[]} ids - the drive item list
|
||||
* @param {DriveExecutionContext} context - the execution context
|
||||
* @returns {Promise<archiver.Archiver>} the created archive.
|
||||
*/
|
||||
createZip = async (
|
||||
ids: string[] = [],
|
||||
context: DriveExecutionContext,
|
||||
): Promise<archiver.Archiver> => {
|
||||
if (!context) {
|
||||
this.logger.error("invalid execution context");
|
||||
return null;
|
||||
}
|
||||
|
||||
const archive = archiver("zip", {
|
||||
zlib: { level: 9 },
|
||||
});
|
||||
|
||||
for (const id of ids) {
|
||||
if (!(await checkAccess(id, null, "read", this.repository, context))) {
|
||||
this.logger.warn(`not enough permissions to download ${id}, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await addDriveItemToArchive(id, null, archive, this.repository, context);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.logger.warn("failed to add item to archive", error);
|
||||
}
|
||||
}
|
||||
|
||||
archive.finalize();
|
||||
|
||||
return archive;
|
||||
};
|
||||
|
||||
notifyWebsocket = async (id: string, context: DriveExecutionContext) => {
|
||||
websocketEventBus.publish(RealtimeEntityActionType.Event, {
|
||||
type: "documents:updated",
|
||||
room: ResourcePath.get(`/companies/${context.company.id}/documents/item/${id}`),
|
||||
entity: {
|
||||
companyId: context.company.id,
|
||||
id: id,
|
||||
},
|
||||
resourcePath: null,
|
||||
result: null,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Search for Drive items.
|
||||
*
|
||||
* @param {SearchDocumentsOptions} options - the search optins.
|
||||
* @param {DriveExecutionContext} context - the execution context.
|
||||
* @returns {Promise<ListResult<DriveFile>>} - the search result.
|
||||
*/
|
||||
search = async (
|
||||
options: SearchDocumentsOptions,
|
||||
context?: DriveExecutionContext,
|
||||
): Promise<ListResult<DriveFile>> => {
|
||||
const result = await this.searchRepository.search(
|
||||
{},
|
||||
{
|
||||
pagination: {
|
||||
limitStr: "100",
|
||||
},
|
||||
...(options.company_id ? { $in: [["company_id", [options.company_id]]] } : {}),
|
||||
...(options.creator ? { $in: [["creator", [options.creator]]] } : {}),
|
||||
...(options.added ? { $in: [["added", [options.added]]] } : {}),
|
||||
$text: {
|
||||
$search: options.search,
|
||||
},
|
||||
},
|
||||
context,
|
||||
);
|
||||
|
||||
// Use Promise.all to check access on each item in parallel
|
||||
const filteredResult = await Promise.all(
|
||||
result.getEntities().filter(async item => {
|
||||
try {
|
||||
// Check access for each item
|
||||
const hasAccess = await checkAccess(item.id, null, "read", this.repository, context);
|
||||
// Return true if the user has access
|
||||
return hasAccess;
|
||||
} catch (error) {
|
||||
this.logger.warn("failed to check item access", error);
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return new ListResult(result.type, filteredResult, result.nextPage);
|
||||
};
|
||||
|
||||
getTab = async (tabId: string, context: CompanyExecutionContext): Promise<DriveTdriveTab> => {
|
||||
const tab = await this.driveTdriveTabRepository.findOne(
|
||||
{ company_id: context.company.id, tab_id: tabId },
|
||||
{},
|
||||
context,
|
||||
);
|
||||
return tab;
|
||||
};
|
||||
|
||||
setTab = async (
|
||||
tabId: string,
|
||||
channelId: string,
|
||||
itemId: string,
|
||||
level: "read" | "write",
|
||||
context: CompanyExecutionContext,
|
||||
): Promise<DriveTdriveTab> => {
|
||||
const hasAccess = await checkAccess(itemId, null, "manage", this.repository, context);
|
||||
|
||||
if (!hasAccess) {
|
||||
throw new CrudException("Not enough permissions", 403);
|
||||
}
|
||||
|
||||
const previousTabConfiguration = await this.getTab(tabId, context);
|
||||
const item = await this.repository.findOne(
|
||||
{
|
||||
company_id: context.company.id,
|
||||
id: itemId,
|
||||
},
|
||||
{},
|
||||
context,
|
||||
);
|
||||
|
||||
await this.driveTdriveTabRepository.save(
|
||||
Object.assign(new DriveTdriveTabEntity(), {
|
||||
company_id: context.company.id,
|
||||
tab_id: tabId,
|
||||
channel_id: channelId,
|
||||
item_id: itemId,
|
||||
level,
|
||||
}),
|
||||
context,
|
||||
);
|
||||
|
||||
await this.update(
|
||||
item.id,
|
||||
{
|
||||
...item,
|
||||
access_info: {
|
||||
...item.access_info,
|
||||
entities: [
|
||||
...(item.access_info?.entities || []).filter(
|
||||
e =>
|
||||
!previousTabConfiguration ||
|
||||
!(e.type === "channel" && e.id !== previousTabConfiguration.channel_id),
|
||||
),
|
||||
{
|
||||
type: "channel",
|
||||
id: channelId,
|
||||
level: level === "write" ? "write" : "read",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
context,
|
||||
);
|
||||
|
||||
return await this.getTab(tabId, context);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ExecutionContext } from "../../core/platform/framework/api/crud-service";
|
||||
import { DriveFile } from "./entities/drive-file";
|
||||
import { FileVersion } from "./entities/file-version";
|
||||
|
||||
export interface CompanyExecutionContext extends ExecutionContext {
|
||||
company: { id: string };
|
||||
}
|
||||
|
||||
export type DriveExecutionContext = CompanyExecutionContext & {
|
||||
public_token?: string;
|
||||
};
|
||||
|
||||
export type RequestParams = {
|
||||
company_id: string;
|
||||
};
|
||||
|
||||
export type ItemRequestParams = RequestParams & {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type DriveItemDetails = {
|
||||
path: DriveFile[];
|
||||
item?: DriveFile;
|
||||
versions?: FileVersion[];
|
||||
children: DriveFile[];
|
||||
access: DriveFileAccessLevel | "none";
|
||||
};
|
||||
|
||||
export type DriveFileAccessLevel = "read" | "write" | "manage";
|
||||
export type publicAccessLevel = "write" | "read" | "none";
|
||||
|
||||
export type RootType = "root";
|
||||
export type TrashType = "trash";
|
||||
|
||||
export type DownloadZipBodyRequest = {
|
||||
items: string[];
|
||||
};
|
||||
|
||||
export type SearchDocumentsOptions = {
|
||||
search?: string;
|
||||
company_id?: string;
|
||||
creator?: string;
|
||||
added?: string;
|
||||
};
|
||||
|
||||
export type SearchDocumentsBody = {
|
||||
search?: string;
|
||||
company_id?: string;
|
||||
creator?: string;
|
||||
added?: string;
|
||||
};
|
||||
|
||||
export type DocumentsMessageQueueRequest = {
|
||||
item: DriveFile;
|
||||
version: FileVersion;
|
||||
context: CompanyExecutionContext;
|
||||
};
|
||||
|
||||
export type DocumentsMessageQueueCallback = {
|
||||
item: DriveFile;
|
||||
content_keywords: string;
|
||||
};
|
||||
|
||||
export type exportKeywordPayload = {
|
||||
file_id: string;
|
||||
company_id: string;
|
||||
};
|
||||
|
||||
export type DriveTdriveTab = {
|
||||
company_id: string;
|
||||
tab_id: string;
|
||||
channel_id: string;
|
||||
item_id: string;
|
||||
level: "read" | "write";
|
||||
};
|
||||
@@ -0,0 +1,802 @@
|
||||
import mimes from "../../utils/mime";
|
||||
import { merge } from "lodash";
|
||||
import { DriveFile } from "./entities/drive-file";
|
||||
import {
|
||||
CompanyExecutionContext,
|
||||
DriveExecutionContext,
|
||||
DriveFileAccessLevel,
|
||||
RootType,
|
||||
TrashType,
|
||||
} from "./types";
|
||||
import crypto from "crypto";
|
||||
import { FileVersion, DriveFileMetadata } from "./entities/file-version";
|
||||
import globalResolver from "../global-resolver";
|
||||
import Repository from "../../core/platform/services/database/services/orm/repository/repository";
|
||||
import archiver from "archiver";
|
||||
import { Readable } from "stream";
|
||||
import { stopWords } from "./const";
|
||||
import unoconv from "unoconv-promise";
|
||||
import {
|
||||
writeToTemporaryFile,
|
||||
cleanFiles,
|
||||
getTmpFile,
|
||||
readFromTemporaryFile,
|
||||
readableToBuffer,
|
||||
} from "../../utils/files";
|
||||
import PdfParse from "pdf-parse";
|
||||
import _ from "lodash";
|
||||
import { logger } from "../../core/platform/framework";
|
||||
|
||||
/**
|
||||
* Returns the default DriveFile object using existing data
|
||||
*
|
||||
* @param {Partial<DriveFile>} item - the partial drive file item.
|
||||
* @param {CompanyExecutionContext} context - the company execution context
|
||||
* @returns {DriveFile} - the Default DriveFile
|
||||
*/
|
||||
export const getDefaultDriveItem = (
|
||||
item: Partial<DriveFile>,
|
||||
context: CompanyExecutionContext,
|
||||
): DriveFile => {
|
||||
const defaultDriveItem = merge<DriveFile, Partial<DriveFile>>(new DriveFile(), {
|
||||
company_id: context.company.id,
|
||||
added: item.added || new Date().getTime().toString(),
|
||||
creator: item.creator || context.user?.id,
|
||||
is_directory: item.is_directory || false,
|
||||
is_in_trash: item.is_in_trash || false,
|
||||
last_modified: new Date().getTime().toString(),
|
||||
parent_id: item.parent_id || "root",
|
||||
content_keywords: item.content_keywords || "",
|
||||
description: item.description || "",
|
||||
access_info: item.access_info || {
|
||||
entities: [
|
||||
{
|
||||
id: "parent",
|
||||
type: "folder",
|
||||
level: "manage",
|
||||
},
|
||||
{
|
||||
id: item.company_id,
|
||||
type: "company",
|
||||
level: "none",
|
||||
},
|
||||
{
|
||||
id: context.user?.id,
|
||||
type: "user",
|
||||
level: "manage",
|
||||
},
|
||||
],
|
||||
public: {
|
||||
level: "none",
|
||||
token: generateAccessToken(),
|
||||
},
|
||||
},
|
||||
extension: item.extension || "",
|
||||
last_version_cache: item.last_version_cache,
|
||||
name: item.name || "",
|
||||
size: item.size || 0,
|
||||
tags: item.tags || [],
|
||||
});
|
||||
|
||||
if (item.id) {
|
||||
defaultDriveItem.id = item.id;
|
||||
}
|
||||
|
||||
return defaultDriveItem;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the default FileVersion item.
|
||||
*
|
||||
* @param {Partial<FileVersion>} version - the partial version item
|
||||
* @param {CompanyExecutionContext} context - the execution context
|
||||
* @returns
|
||||
*/
|
||||
export const getDefaultDriveItemVersion = (
|
||||
version: Partial<FileVersion>,
|
||||
context: CompanyExecutionContext,
|
||||
): FileVersion => {
|
||||
const defaultVersion = merge(new FileVersion(), {
|
||||
application_id: version.application_id || "",
|
||||
creator_id: version.creator_id || context.user?.id,
|
||||
data: version.data || {},
|
||||
date_added: version.date_added || new Date().getTime(),
|
||||
drive_item_id: version.drive_item_id || "",
|
||||
file_metadata: version.file_metadata || {},
|
||||
file_size: version.file_size || 0,
|
||||
filename: version.filename || "",
|
||||
key: version.key || "",
|
||||
mode: version.mode || "OpenSSL-2",
|
||||
provider: version.provider,
|
||||
realname: version.realname,
|
||||
});
|
||||
|
||||
if (version.id) {
|
||||
defaultVersion.id = version.id;
|
||||
}
|
||||
|
||||
return defaultVersion;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a random sha1 access token
|
||||
*
|
||||
* @returns {String} - the random access token ( sha1 hex digest ).
|
||||
*/
|
||||
export const generateAccessToken = (): string => {
|
||||
const randomBytes = crypto.randomBytes(64);
|
||||
|
||||
return crypto.createHash("sha1").update(randomBytes).digest("hex");
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the level meets the required level.
|
||||
*
|
||||
* @param {publicAccessLevel | DriveFileAccessLevel} requiredLevel
|
||||
* @param {publicAccessLevel} level
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const hasAccessLevel = (
|
||||
requiredLevel: DriveFileAccessLevel | "none",
|
||||
level: DriveFileAccessLevel | "none",
|
||||
): boolean => {
|
||||
if (requiredLevel === level) return true;
|
||||
|
||||
if (requiredLevel === "write") {
|
||||
return level === "manage";
|
||||
}
|
||||
|
||||
if (requiredLevel === "read") {
|
||||
return level === "manage" || level === "write";
|
||||
}
|
||||
|
||||
return requiredLevel === "none";
|
||||
};
|
||||
|
||||
/**
|
||||
* checks the current user is a guest
|
||||
*
|
||||
* @param {CompanyExecutionContext} context
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export const isCompanyGuest = async (context: CompanyExecutionContext): Promise<boolean> => {
|
||||
const userRole = await globalResolver.services.companies.getUserRole(
|
||||
context.company.id,
|
||||
context.user?.id,
|
||||
);
|
||||
|
||||
return userRole === "guest" || !userRole;
|
||||
};
|
||||
|
||||
/**
|
||||
* checks the current user is a admin
|
||||
*
|
||||
* @param {CompanyExecutionContext} context
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export const isCompanyAdmin = async (context: CompanyExecutionContext): Promise<boolean> => {
|
||||
const userRole = await globalResolver.services.companies.getUserRole(
|
||||
context.company.id,
|
||||
context.user?.id,
|
||||
);
|
||||
|
||||
return userRole === "admin";
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the size of the Drive Item
|
||||
*
|
||||
* @param {DriveFile} item - The file or directory
|
||||
* @param {Repository<DriveFile>} repository - the database repository
|
||||
* @param {CompanyExecutionContext} context - the execution context
|
||||
* @returns {Promise<number>} - the size of the Drive Item
|
||||
*/
|
||||
export const calculateItemSize = async (
|
||||
item: DriveFile | TrashType | RootType,
|
||||
repository: Repository<DriveFile>,
|
||||
context: CompanyExecutionContext,
|
||||
): Promise<number> => {
|
||||
if (item === "trash") {
|
||||
const trashedItems = await repository.find(
|
||||
{ company_id: context.company.id, parent_id: "trash" },
|
||||
{},
|
||||
context,
|
||||
);
|
||||
|
||||
return trashedItems.getEntities().reduce((acc, curr) => acc + curr.size, 0);
|
||||
}
|
||||
|
||||
if (item === "root" || !item) {
|
||||
const rootFolderItems = await repository.find(
|
||||
{ company_id: context.company.id, parent_id: "root" },
|
||||
{},
|
||||
context,
|
||||
);
|
||||
|
||||
return rootFolderItems.getEntities().reduce((acc, curr) => acc + curr.size, 0);
|
||||
}
|
||||
|
||||
if (item.is_directory) {
|
||||
const children = await repository.find(
|
||||
{
|
||||
company_id: context.company.id,
|
||||
parent_id: item.id,
|
||||
},
|
||||
{},
|
||||
context,
|
||||
);
|
||||
|
||||
return children.getEntities().reduce((acc, curr) => acc + curr.size, 0);
|
||||
}
|
||||
|
||||
return item.size;
|
||||
};
|
||||
|
||||
/**
|
||||
* Recalculates and updates the Drive item size
|
||||
*
|
||||
* @param {string} id - the item id
|
||||
* @param {Repository<DriveFile>} repository
|
||||
* @param {CompanyExecutionContext} context - the execution context
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const updateItemSize = async (
|
||||
id: string,
|
||||
repository: Repository<DriveFile>,
|
||||
context: CompanyExecutionContext,
|
||||
): Promise<void> => {
|
||||
if (!id || id === "root" || id === "trash") return;
|
||||
|
||||
const item = await repository.findOne({ id, company_id: context.company.id });
|
||||
|
||||
if (!item) {
|
||||
throw Error("Drive item doesn't exist");
|
||||
}
|
||||
|
||||
item.size = await calculateItemSize(item, repository, context);
|
||||
|
||||
await repository.save(item);
|
||||
|
||||
if (item.parent_id === "root" || item.parent_id === "trash") {
|
||||
return;
|
||||
}
|
||||
|
||||
await updateItemSize(item.parent_id, repository, context);
|
||||
};
|
||||
|
||||
/**
|
||||
* gets the path for the driveitem
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {Repository<DriveFile>} repository
|
||||
* @param {boolean} ignoreAccess
|
||||
* @param {CompanyExecutionContext} context
|
||||
* @returns
|
||||
*/
|
||||
export const getPath = async (
|
||||
id: string,
|
||||
repository: Repository<DriveFile>,
|
||||
ignoreAccess?: boolean,
|
||||
context?: DriveExecutionContext,
|
||||
): Promise<DriveFile[]> => {
|
||||
id = id || "root";
|
||||
if (id === "root" || id === "trash")
|
||||
return !context.public_token || ignoreAccess
|
||||
? [
|
||||
{
|
||||
id,
|
||||
name: id === "root" ? "Home" : "Trash",
|
||||
} as DriveFile,
|
||||
]
|
||||
: [];
|
||||
|
||||
const item = await repository.findOne({
|
||||
id,
|
||||
company_id: context.company.id,
|
||||
});
|
||||
|
||||
if (!item || (!(await checkAccess(id, item, "read", repository, context)) && !ignoreAccess)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...(await getPath(item.parent_id, repository, ignoreAccess, context)), item];
|
||||
};
|
||||
|
||||
/**
|
||||
* checks if access can be granted for the drive item
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {DriveFile | null} item
|
||||
* @param {DriveFileAccessLevel} level
|
||||
* @param {Repository<DriveFile>} repository
|
||||
* @param {CompanyExecutionContext} context
|
||||
* @param {string} token
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export const checkAccess = async (
|
||||
id: string,
|
||||
item: DriveFile | null,
|
||||
level: DriveFileAccessLevel,
|
||||
repository: Repository<DriveFile>,
|
||||
context: CompanyExecutionContext & { public_token?: string; tdrive_tab_token?: string },
|
||||
): Promise<boolean> => {
|
||||
if (context.user?.server_request) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const grantedLevel = await getAccessLevel(id, item, repository, context);
|
||||
const hasAccess = hasAccessLevel(level, grantedLevel);
|
||||
logger.info(
|
||||
`Got level ${grantedLevel} for drive item ${id} and required ${level} - returning ${hasAccess}`,
|
||||
);
|
||||
return hasAccess;
|
||||
};
|
||||
|
||||
/**
|
||||
* get maximum level for the drive item
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {DriveFile | null} item
|
||||
* @param {Repository<DriveFile>} repository
|
||||
* @param {CompanyExecutionContext} context
|
||||
* @param {string} token
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export const getAccessLevel = async (
|
||||
id: string,
|
||||
item: DriveFile | null,
|
||||
repository: Repository<DriveFile>,
|
||||
context: CompanyExecutionContext & { public_token?: string; tdrive_tab_token?: string },
|
||||
): Promise<DriveFileAccessLevel | "none"> => {
|
||||
if (!id || id === "root")
|
||||
return !context?.user?.id ? "none" : (await isCompanyGuest(context)) ? "read" : "manage";
|
||||
if (id === "trash")
|
||||
return (await isCompanyGuest(context)) || !context?.user?.id
|
||||
? "none"
|
||||
: (await isCompanyAdmin(context))
|
||||
? "manage"
|
||||
: "write";
|
||||
|
||||
const publicToken = context.public_token;
|
||||
|
||||
try {
|
||||
item =
|
||||
item ||
|
||||
(await repository.findOne({
|
||||
id,
|
||||
company_id: context.company.id,
|
||||
}));
|
||||
|
||||
if (!item) {
|
||||
throw Error("Drive item doesn't exist");
|
||||
}
|
||||
|
||||
/*
|
||||
* Specific user or channel rule is applied first. Then less restrictive level will be chosen
|
||||
* between the parent folder and company accesses.
|
||||
*/
|
||||
|
||||
//Public access
|
||||
if (publicToken) {
|
||||
if (!item.access_info.public.token) return "none";
|
||||
const { token: itemToken, level: itemLevel } = item.access_info.public;
|
||||
if (itemToken === publicToken) return itemLevel;
|
||||
}
|
||||
|
||||
const accessEntities = item.access_info.entities || [];
|
||||
const otherLevels = [];
|
||||
|
||||
//From there a user must be logged in
|
||||
if (context?.user?.id) {
|
||||
//Users
|
||||
const matchingUser = accessEntities.find(a => a.type === "user" && a.id === context.user?.id);
|
||||
if (matchingUser) return matchingUser.level;
|
||||
|
||||
//Channels
|
||||
if (context.tdrive_tab_token) {
|
||||
try {
|
||||
const [channelId] = context.tdrive_tab_token.split("+"); //First item will be the channel id
|
||||
const matchingChannel = accessEntities.find(
|
||||
a => a.type === "channel" && a.id === channelId,
|
||||
);
|
||||
if (matchingChannel) return matchingChannel.level;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
|
||||
//Companies
|
||||
const matchingCompany = accessEntities.find(
|
||||
a => a.type === "company" && a.id === context.company.id,
|
||||
);
|
||||
if (matchingCompany) otherLevels.push(matchingCompany.level);
|
||||
}
|
||||
|
||||
//Parent folder
|
||||
const maxParentFolderLevel =
|
||||
accessEntities.find(a => a.type === "folder" && a.id === "parent")?.level || "none";
|
||||
if (maxParentFolderLevel === "none") {
|
||||
otherLevels.push(maxParentFolderLevel);
|
||||
} else {
|
||||
const parentFolderLevel = await getAccessLevel(item.parent_id, null, repository, context);
|
||||
otherLevels.push(parentFolderLevel);
|
||||
}
|
||||
|
||||
//Return least restrictive level of otherLevels
|
||||
return otherLevels.reduce(
|
||||
(previousValue, b) =>
|
||||
hasAccessLevel(b as DriveFileAccessLevel, previousValue as DriveFileAccessLevel)
|
||||
? previousValue
|
||||
: b,
|
||||
"none",
|
||||
) as DriveFileAccessLevel | "none";
|
||||
} catch (error) {
|
||||
throw Error(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Isolate access level information from parent folder logic
|
||||
* Used when putting folder in the trash
|
||||
* @param id
|
||||
* @param item
|
||||
* @param repository
|
||||
*/
|
||||
export const makeStandaloneAccessLevel = async (
|
||||
companyId: string,
|
||||
id: string,
|
||||
item: DriveFile | null,
|
||||
repository: Repository<DriveFile>,
|
||||
options: { removePublicAccess?: boolean } = { removePublicAccess: true },
|
||||
): Promise<DriveFile["access_info"]> => {
|
||||
item =
|
||||
item ||
|
||||
(await repository.findOne({
|
||||
id,
|
||||
company_id: companyId,
|
||||
}));
|
||||
|
||||
if (!item) {
|
||||
throw Error("Drive item doesn't exist");
|
||||
}
|
||||
|
||||
const accessInfo = _.cloneDeep(item.access_info);
|
||||
|
||||
if (options?.removePublicAccess && accessInfo?.public?.level) accessInfo.public.level = "none";
|
||||
|
||||
const parentFolderAccess = accessInfo.entities.find(
|
||||
a => a.type === "folder" && a.id === "parent",
|
||||
);
|
||||
|
||||
if (!parentFolderAccess || parentFolderAccess.level === "none") {
|
||||
return accessInfo;
|
||||
} else if (item.parent_id !== "root" && item.parent_id !== "trash") {
|
||||
// Get limitations from parent folder
|
||||
const accessEntitiesFromParent = await makeStandaloneAccessLevel(
|
||||
companyId,
|
||||
item.parent_id,
|
||||
null,
|
||||
repository,
|
||||
options,
|
||||
);
|
||||
|
||||
let mostRestrictiveFolderLevel = parentFolderAccess.level as DriveFileAccessLevel | "none";
|
||||
|
||||
const keptEntities = accessEntitiesFromParent.entities.filter(a => {
|
||||
if (["user", "channel"].includes(a.type)) {
|
||||
return !accessInfo.entities.find(b => b.type === a.type && b.id === a.id);
|
||||
} else {
|
||||
if (a.type === "folder" && a.id === "parent") {
|
||||
mostRestrictiveFolderLevel = hasAccessLevel(a.level, mostRestrictiveFolderLevel)
|
||||
? a.level
|
||||
: mostRestrictiveFolderLevel;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
accessInfo.entities = accessInfo.entities.map(a => {
|
||||
if (a.type === "folder" && a.id === "parent") {
|
||||
a.level = mostRestrictiveFolderLevel;
|
||||
}
|
||||
return a;
|
||||
}) as DriveFile["access_info"]["entities"];
|
||||
|
||||
accessInfo.entities = [...accessInfo.entities, ...keptEntities];
|
||||
}
|
||||
|
||||
return accessInfo;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds drive items to an archive recursively
|
||||
*
|
||||
* @param {string} id - the drive item id
|
||||
* @param {DriveFile | null } entity - the drive item entity
|
||||
* @param {archiver.Archiver} archive - the archive
|
||||
* @param {Repository<DriveFile>} repository - the repository
|
||||
* @param {CompanyExecutionContext} context - the execution context
|
||||
* @param {string} prefix - folder prefix
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const addDriveItemToArchive = async (
|
||||
id: string,
|
||||
entity: DriveFile | null,
|
||||
archive: archiver.Archiver,
|
||||
repository: Repository<DriveFile>,
|
||||
context: CompanyExecutionContext,
|
||||
prefix?: string,
|
||||
): Promise<void> => {
|
||||
const item = entity || (await repository.findOne({ id, company_id: context.company.id }));
|
||||
|
||||
if (!item) {
|
||||
throw Error("item not found");
|
||||
}
|
||||
|
||||
if (!item.is_directory) {
|
||||
const file_id = item.last_version_cache.file_metadata.external_id;
|
||||
const file = await globalResolver.services.files.download(file_id, context);
|
||||
|
||||
if (!file) {
|
||||
throw Error("file not found");
|
||||
}
|
||||
|
||||
archive.append(file.file, { name: file.name, prefix: prefix ?? "" });
|
||||
return;
|
||||
} else {
|
||||
const items = await repository.find({
|
||||
parent_id: item.id,
|
||||
company_id: context.company.id,
|
||||
});
|
||||
|
||||
for (const child of items.getEntities()) {
|
||||
await addDriveItemToArchive(
|
||||
child.id,
|
||||
child,
|
||||
archive,
|
||||
repository,
|
||||
context,
|
||||
`${prefix || ""}${item.name}/`,
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the most popular 250 keywords from a text.
|
||||
*
|
||||
* @param {string} data - file data string.
|
||||
* @returns {string}
|
||||
*/
|
||||
export const extractKeywords = (data: string): string => {
|
||||
const words = data.toLowerCase().split(/[^a-zA-Z']+/);
|
||||
const filteredWords = words.filter(word => !stopWords.includes(word) && word.length > 3);
|
||||
|
||||
const wordFrequency = filteredWords.reduce((acc: Record<string, number>, word: string) => {
|
||||
acc[word] = (acc[word] || 0) + 1;
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const sortedFrequency = Object.entries(wordFrequency)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.reduce((acc: Record<string, number>, [key, val]) => {
|
||||
acc[key] = val;
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return Object.keys(sortedFrequency).slice(0, 250).join(" ");
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts an office file stream into a human readable string.
|
||||
*
|
||||
* @param {Readable} file - the input file stream.
|
||||
* @param {string} extension - the file extension.
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export const officeFileToString = async (file: Readable, extension: string): Promise<string> => {
|
||||
const officeFilePath = await writeToTemporaryFile(file, extension);
|
||||
const outputPath = getTmpFile(".pdf");
|
||||
|
||||
try {
|
||||
await unoconv.run({
|
||||
file: officeFilePath,
|
||||
output: outputPath,
|
||||
});
|
||||
|
||||
cleanFiles([officeFilePath]);
|
||||
|
||||
return await pdfFileToString(outputPath);
|
||||
} catch (error) {
|
||||
cleanFiles([officeFilePath]);
|
||||
throw Error(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a PDF file stream into a human readable string.
|
||||
*
|
||||
* @param {Readable | string} file - the input file stream or path.
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export const pdfFileToString = async (file: Readable | string): Promise<string> => {
|
||||
let inputBuffer: Buffer;
|
||||
|
||||
try {
|
||||
if (typeof file === "string") {
|
||||
inputBuffer = await readFromTemporaryFile(file);
|
||||
cleanFiles([file]);
|
||||
} else {
|
||||
inputBuffer = await readableToBuffer(file);
|
||||
}
|
||||
|
||||
const result = await PdfParse(inputBuffer);
|
||||
|
||||
return result.text;
|
||||
} catch (error) {
|
||||
if (typeof file === "string") {
|
||||
cleanFiles([file]);
|
||||
}
|
||||
|
||||
throw Error(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* returns the file metadata.
|
||||
*
|
||||
* @param {string} fileId - the file id
|
||||
* @param {CompanyExecutionContext} context - the execution context
|
||||
* @returns {DriveFileMetadata}
|
||||
*/
|
||||
export const getFileMetadata = async (
|
||||
fileId: string,
|
||||
context: CompanyExecutionContext,
|
||||
): Promise<DriveFileMetadata> => {
|
||||
const file = await globalResolver.services.files.getFile(
|
||||
{
|
||||
id: fileId,
|
||||
company_id: context.company.id,
|
||||
},
|
||||
context,
|
||||
{ ...(context.user.server_request ? {} : { waitForThumbnail: true }) },
|
||||
);
|
||||
|
||||
if (!file) {
|
||||
throw Error("File doesn't exist");
|
||||
}
|
||||
|
||||
return {
|
||||
source: "internal",
|
||||
external_id: fileId,
|
||||
mime: file.metadata.mime,
|
||||
name: file.metadata.name,
|
||||
size: file.upload_data.size,
|
||||
thumbnails: file.thumbnails,
|
||||
} as DriveFileMetadata;
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds a suitable name for an item based on items inside the same folder.
|
||||
*
|
||||
* @param {string} parent_id - the parent id.
|
||||
* @param {string} name - the item name.
|
||||
* @param {Repository<DriveFile>} repository - the drive repository.
|
||||
* @param {CompanyExecutionContext} context - the execution context.
|
||||
* @returns {Promise<string>} - the drive item name.
|
||||
*/
|
||||
export const getItemName = async (
|
||||
parent_id: string,
|
||||
id: string,
|
||||
name: string,
|
||||
is_directory: boolean,
|
||||
repository: Repository<DriveFile>,
|
||||
context: CompanyExecutionContext,
|
||||
): Promise<string> => {
|
||||
try {
|
||||
let newName = name.substring(0, 255);
|
||||
let exists = true;
|
||||
const children = await repository.find(
|
||||
{
|
||||
parent_id,
|
||||
company_id: context.company.id,
|
||||
},
|
||||
{},
|
||||
context,
|
||||
);
|
||||
|
||||
while (exists) {
|
||||
exists = !!children
|
||||
.getEntities()
|
||||
.find(
|
||||
child => child.name === newName && child.is_directory === is_directory && child.id !== id,
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
const ext = newName.split(".").pop();
|
||||
newName =
|
||||
ext && ext !== newName ? `${newName.slice(0, -ext.length - 1)}-2.${ext}` : `${newName}-2`;
|
||||
}
|
||||
}
|
||||
|
||||
return newName;
|
||||
} catch (error) {
|
||||
throw Error("Failed to get item name");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if an item can be moved to its destination
|
||||
* An item cannot be moved to itself or any of its derived chilren.
|
||||
*
|
||||
* @param {string} source - the to be moved item id.
|
||||
* @param {string} target - the to be moved to item id.
|
||||
* @param {string} repository - the Drive item repository.
|
||||
* @param {CompanyExecutionContex} context - the execution context.
|
||||
* @returns {Promise<boolean>} - whether the move is possible or not.
|
||||
*/
|
||||
export const canMoveItem = async (
|
||||
source: string,
|
||||
target: string,
|
||||
repository: Repository<DriveFile>,
|
||||
context: CompanyExecutionContext,
|
||||
): Promise<boolean> => {
|
||||
if (source === target) return false;
|
||||
if (target === "root" || target === "trash") return true;
|
||||
|
||||
const item = await repository.findOne({
|
||||
id: source,
|
||||
company_id: context.company.id,
|
||||
});
|
||||
|
||||
if (!item.is_directory) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const targetItem = await repository.findOne({
|
||||
id: target,
|
||||
company_id: context.company.id,
|
||||
});
|
||||
|
||||
if (!targetItem || !targetItem.is_directory) {
|
||||
throw Error("target item doesn't exist or not a directory");
|
||||
}
|
||||
|
||||
if (!(await checkAccess(target, targetItem, "write", repository, context))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
throw Error("Item not found");
|
||||
}
|
||||
|
||||
const children = (
|
||||
await repository.find({
|
||||
parent_id: source,
|
||||
company_id: context.company.id,
|
||||
})
|
||||
).getEntities();
|
||||
|
||||
if (children.some(child => child.id === target)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const child of children) {
|
||||
if (child.is_directory && !(await canMoveItem(child.id, target, repository, context))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export function isFileType(fileMime: string, fileName: string, requiredExtensions: string[]): any {
|
||||
const extension = fileName.split(".").pop();
|
||||
const secondaryExtensions = Object.keys(mimes).filter(k => mimes[k] === fileMime);
|
||||
const fileExtensions = [extension, ...secondaryExtensions];
|
||||
return fileExtensions.some(e => requiredExtensions.includes(e));
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { logger } from "../../../../core/platform/framework";
|
||||
import { CrudException, ListResult } from "../../../../core/platform/framework/api/crud-service";
|
||||
import { File } from "../../../../services/files/entities/file";
|
||||
import { UploadOptions } from "../../../../services/files/types";
|
||||
import globalResolver from "../../../../services/global-resolver";
|
||||
import { PaginationQueryParameters, ResourceWebsocket } from "../../../../utils/types";
|
||||
import { DriveFile } from "../../entities/drive-file";
|
||||
import { FileVersion } from "../../entities/file-version";
|
||||
import {
|
||||
CompanyExecutionContext,
|
||||
DriveExecutionContext,
|
||||
DriveItemDetails,
|
||||
DriveTdriveTab,
|
||||
ItemRequestParams,
|
||||
RequestParams,
|
||||
SearchDocumentsBody,
|
||||
SearchDocumentsOptions,
|
||||
} from "../../types";
|
||||
|
||||
export class DocumentsController {
|
||||
/**
|
||||
* Creates a DriveFile item
|
||||
*
|
||||
* @param {FastifyRequest} request
|
||||
* @returns
|
||||
*/
|
||||
create = async (
|
||||
request: FastifyRequest<{
|
||||
Params: RequestParams;
|
||||
Querystring: Record<string, string>;
|
||||
Body: {
|
||||
item: Partial<DriveFile>;
|
||||
version: Partial<FileVersion>;
|
||||
};
|
||||
}>,
|
||||
): Promise<DriveFile> => {
|
||||
try {
|
||||
const context = getDriveExecutionContext(request);
|
||||
|
||||
let createdFile: File = null;
|
||||
if (request.isMultipart()) {
|
||||
const file = await request.file();
|
||||
const q = request.query;
|
||||
const options: UploadOptions = {
|
||||
totalChunks: parseInt(q.resumableTotalChunks || q.total_chunks) || 1,
|
||||
totalSize: parseInt(q.resumableTotalSize || q.total_size) || 0,
|
||||
chunkNumber: parseInt(q.resumableChunkNumber || q.chunk_number) || 1,
|
||||
filename: q.resumableFilename || q.filename || file?.filename || undefined,
|
||||
type: q.resumableType || q.type || file?.mimetype || undefined,
|
||||
waitForThumbnail: !!q.thumbnail_sync,
|
||||
};
|
||||
|
||||
createdFile = await globalResolver.services.files.save(null, file, options, context);
|
||||
}
|
||||
|
||||
const { item, version } = request.body;
|
||||
|
||||
return await globalResolver.services.documents.documents.create(
|
||||
createdFile,
|
||||
item,
|
||||
version,
|
||||
context,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Failed to create Drive item", error);
|
||||
throw new CrudException("Failed to create Drive item", 500);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes a DriveFile item or empty the trash or delete root folder contents
|
||||
*
|
||||
* @param {FastifyRequest} request
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
delete = async (
|
||||
request: FastifyRequest<{ Params: ItemRequestParams; Querystring: { public_token?: string } }>,
|
||||
reply: FastifyReply,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const context = getDriveExecutionContext(request);
|
||||
|
||||
await globalResolver.services.documents.documents.delete(request.params.id, null, context);
|
||||
|
||||
reply.status(200).send();
|
||||
} catch (error) {
|
||||
logger.error("Failed to delete drive item", error);
|
||||
throw new CrudException("Failed to delete drive item", 500);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Lists the drive root folder.
|
||||
*
|
||||
* @param {FastifyRequest} request
|
||||
* @returns {Promise<DriveItemDetails>}
|
||||
*/
|
||||
listRootFolder = async (
|
||||
request: FastifyRequest<{
|
||||
Params: RequestParams;
|
||||
Querystring: PaginationQueryParameters & { public_token?: string };
|
||||
}>,
|
||||
): Promise<DriveItemDetails> => {
|
||||
const context = getDriveExecutionContext(request);
|
||||
|
||||
return await globalResolver.services.documents.documents.get(null, context);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches a DriveFile item.
|
||||
*
|
||||
* @param {FastifyRequest} request
|
||||
* @returns {Promise<DriveItemDetails>}
|
||||
*/
|
||||
get = async (
|
||||
request: FastifyRequest<{
|
||||
Params: ItemRequestParams;
|
||||
Querystring: PaginationQueryParameters & { public_token?: string };
|
||||
}>,
|
||||
): Promise<DriveItemDetails & { websockets: ResourceWebsocket[] }> => {
|
||||
const context = getDriveExecutionContext(request);
|
||||
const { id } = request.params;
|
||||
|
||||
return {
|
||||
...(await globalResolver.services.documents.documents.get(id, context)),
|
||||
websockets: request.currentUser?.id
|
||||
? globalResolver.platformServices.realtime.sign(
|
||||
[{ room: `/companies/${context.company.id}/documents/item/${id}` }],
|
||||
request.currentUser?.id,
|
||||
)
|
||||
: [],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Update drive item
|
||||
*
|
||||
* @param {FastifyRequest} request
|
||||
* @returns {Promise<DriveFile>}
|
||||
*/
|
||||
update = async (
|
||||
request: FastifyRequest<{
|
||||
Params: ItemRequestParams;
|
||||
Body: Partial<DriveFile>;
|
||||
Querystring: { public_token?: string };
|
||||
}>,
|
||||
): Promise<DriveFile> => {
|
||||
const context = getDriveExecutionContext(request);
|
||||
const { id } = request.params;
|
||||
const update = request.body;
|
||||
|
||||
if (!id) throw new CrudException("Missing id", 400);
|
||||
|
||||
return await globalResolver.services.documents.documents.update(id, update, context);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a drive file version.
|
||||
*
|
||||
* @param {FastifyRequest} request
|
||||
* @returns {Promise<FileVersion>}
|
||||
*/
|
||||
createVersion = async (
|
||||
request: FastifyRequest<{
|
||||
Params: ItemRequestParams;
|
||||
Body: Partial<FileVersion>;
|
||||
Querystring: { public_token?: string };
|
||||
}>,
|
||||
): Promise<FileVersion> => {
|
||||
const context = getDriveExecutionContext(request);
|
||||
const { id } = request.params;
|
||||
const version = request.body;
|
||||
|
||||
if (!id) throw new CrudException("Missing id", 400);
|
||||
|
||||
return await globalResolver.services.documents.documents.createVersion(id, version, context);
|
||||
};
|
||||
|
||||
downloadGetToken = async (
|
||||
request: FastifyRequest<{
|
||||
Params: ItemRequestParams;
|
||||
Querystring: { version_id?: string; items?: string; public_token?: string };
|
||||
}>,
|
||||
): Promise<{ token: string }> => {
|
||||
const ids = (request.query.items || "").split(",");
|
||||
const context = getDriveExecutionContext(request);
|
||||
return {
|
||||
token: await globalResolver.services.documents.documents.downloadGetToken(
|
||||
ids,
|
||||
request.query.version_id,
|
||||
context,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Shortcut to download a file (you can also use the file-service directly).
|
||||
* If the item is a folder, a zip will be automatically generated.
|
||||
*
|
||||
* @param {FastifyRequest} request
|
||||
* @param {FastifyReply} reply
|
||||
*/
|
||||
download = async (
|
||||
request: FastifyRequest<{
|
||||
Params: ItemRequestParams;
|
||||
Querystring: { version_id?: string; token?: string; public_token?: string };
|
||||
}>,
|
||||
response: FastifyReply,
|
||||
): Promise<void> => {
|
||||
const context = getDriveExecutionContext(request);
|
||||
const id = request.params.id || "";
|
||||
const versionId = request.query.version_id || null;
|
||||
const token = request.query.token;
|
||||
await globalResolver.services.documents.documents.applyDownloadTokenToContext(
|
||||
[id],
|
||||
versionId,
|
||||
token,
|
||||
context,
|
||||
);
|
||||
|
||||
try {
|
||||
const archiveOrFile = await globalResolver.services.documents.documents.download(
|
||||
id,
|
||||
versionId,
|
||||
context,
|
||||
);
|
||||
|
||||
if (archiveOrFile.archive) {
|
||||
const archive = archiveOrFile.archive;
|
||||
|
||||
archive.on("finish", () => {
|
||||
response.status(200);
|
||||
});
|
||||
|
||||
archive.on("error", () => {
|
||||
response.internalServerError();
|
||||
});
|
||||
|
||||
archive.pipe(response.raw);
|
||||
} else if (archiveOrFile.file) {
|
||||
const data = archiveOrFile.file;
|
||||
const filename = data.name.replace(/[^a-zA-Z0-9 -_.]/g, "");
|
||||
|
||||
response.header("Content-disposition", `attachment; filename="${filename}"`);
|
||||
if (data.size) response.header("Content-Length", data.size);
|
||||
response.type(data.mime);
|
||||
response.send(data.file);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("failed to download file", error);
|
||||
throw new CrudException("Failed to download file", 500);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Downloads a zip archive containing the drive items.
|
||||
*
|
||||
* @param {FastifyRequest} request
|
||||
* @param {FastifyReply} reply
|
||||
*/
|
||||
downloadZip = async (
|
||||
request: FastifyRequest<{
|
||||
Params: RequestParams;
|
||||
Querystring: { token?: string; items: string; public_token?: string };
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
): Promise<void> => {
|
||||
const context = getDriveExecutionContext(request);
|
||||
let ids = (request.query.items || "").split(",");
|
||||
const token = request.query.token;
|
||||
|
||||
await globalResolver.services.documents.documents.applyDownloadTokenToContext(
|
||||
ids,
|
||||
null,
|
||||
token,
|
||||
context,
|
||||
);
|
||||
|
||||
if (ids[0] === "root") {
|
||||
const items = await globalResolver.services.documents.documents.get(ids[0], context);
|
||||
ids = items.children.map(item => item.id);
|
||||
}
|
||||
|
||||
try {
|
||||
const archive = await globalResolver.services.documents.documents.createZip(ids, context);
|
||||
|
||||
archive.on("finish", () => {
|
||||
reply.status(200);
|
||||
});
|
||||
|
||||
archive.on("error", () => {
|
||||
reply.internalServerError();
|
||||
});
|
||||
|
||||
archive.pipe(reply.raw);
|
||||
} catch (error) {
|
||||
logger.error("failed to send zip file", error);
|
||||
throw new CrudException("Failed to create zip file", 500);
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Search for documents.
|
||||
*
|
||||
* @param {FastifyRequest} request
|
||||
* @returns {Promise<ListResult<DriveFile>>}
|
||||
*/
|
||||
search = async (
|
||||
request: FastifyRequest<{
|
||||
Params: RequestParams;
|
||||
Body: SearchDocumentsBody;
|
||||
Querystring: { public_token?: string };
|
||||
}>,
|
||||
): Promise<ListResult<DriveFile>> => {
|
||||
try {
|
||||
const context = getDriveExecutionContext(request);
|
||||
const { search = "", added = "", company_id = "", creator = "" } = request.body;
|
||||
|
||||
const options: SearchDocumentsOptions = {
|
||||
company_id: company_id || context.company.id,
|
||||
...(search ? { search } : {}),
|
||||
...(added ? { added } : {}),
|
||||
...(creator ? { creator } : {}),
|
||||
};
|
||||
|
||||
if (!Object.keys(options).length) {
|
||||
throw Error("Search options are empty");
|
||||
}
|
||||
|
||||
return await globalResolver.services.documents.documents.search(options, context);
|
||||
} catch (error) {
|
||||
logger.error("error while searching for document", error);
|
||||
throw new CrudException("Failed to search for documents", 500);
|
||||
}
|
||||
};
|
||||
|
||||
getTab = async (
|
||||
request: FastifyRequest<{
|
||||
Params: { tab_id: string; company_id: string };
|
||||
}>,
|
||||
): Promise<DriveTdriveTab> => {
|
||||
const context = getCompanyExecutionContext(request);
|
||||
const { tab_id } = request.params;
|
||||
|
||||
return await globalResolver.services.documents.documents.getTab(tab_id, context);
|
||||
};
|
||||
|
||||
setTab = async (
|
||||
request: FastifyRequest<{
|
||||
Params: { tab_id: string; company_id: string };
|
||||
Body: DriveTdriveTab;
|
||||
}>,
|
||||
): Promise<DriveTdriveTab> => {
|
||||
const context = getCompanyExecutionContext(request);
|
||||
const { tab_id } = request.params;
|
||||
|
||||
if (!request.body.channel_id || !request.body.item_id)
|
||||
throw new Error("Missing parameters (channel_id, item_id)");
|
||||
|
||||
return await globalResolver.services.documents.documents.setTab(
|
||||
tab_id,
|
||||
request.body.channel_id,
|
||||
request.body.item_id,
|
||||
request.body.level,
|
||||
context,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the company execution context
|
||||
*
|
||||
* @param { FastifyRequest<{ Params: { company_id: string } }>} req
|
||||
* @returns {CompanyExecutionContext}
|
||||
*/
|
||||
const getDriveExecutionContext = (
|
||||
req: FastifyRequest<{ Params: { company_id: string }; Querystring: { public_token?: string } }>,
|
||||
): DriveExecutionContext => ({
|
||||
public_token: req.query.public_token,
|
||||
user: req.currentUser,
|
||||
company: { id: req.params.company_id },
|
||||
url: req.url,
|
||||
method: req.routerMethod,
|
||||
reqId: req.id,
|
||||
transport: "http",
|
||||
});
|
||||
|
||||
function getCompanyExecutionContext(
|
||||
request: FastifyRequest<{
|
||||
Params: { company_id: string };
|
||||
}>,
|
||||
): CompanyExecutionContext {
|
||||
return {
|
||||
user: request.currentUser,
|
||||
company: { id: request.params.company_id },
|
||||
url: request.url,
|
||||
method: request.routerMethod,
|
||||
reqId: request.id,
|
||||
transport: "http",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./documents";
|
||||
@@ -0,0 +1,12 @@
|
||||
import fastifyCaching from "@fastify/caching";
|
||||
import { FastifyInstance, FastifyRegisterOptions } from "fastify";
|
||||
import routes from "./routes";
|
||||
|
||||
export default (
|
||||
fastify: FastifyInstance,
|
||||
options: FastifyRegisterOptions<{ prefix: string }>,
|
||||
): void => {
|
||||
fastify.log.debug("configuring /internal/services/documents/v1 routes");
|
||||
fastify.register(fastifyCaching, { expiresIn: 31536000, privacy: fastifyCaching.privacy.PUBLIC });
|
||||
fastify.register(routes, options);
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import { FastifyInstance, FastifyPluginCallback } from "fastify";
|
||||
import { DocumentsController } from "./controllers";
|
||||
import { createDocumentSchema, createVersionSchema } from "./schemas";
|
||||
|
||||
const baseUrl = "/companies/:company_id";
|
||||
const serviceUrl = `${baseUrl}/item`;
|
||||
|
||||
const routes: FastifyPluginCallback = (fastify: FastifyInstance, _options, next) => {
|
||||
const documentsController = new DocumentsController();
|
||||
|
||||
fastify.route({
|
||||
method: "GET",
|
||||
url: `${serviceUrl}`,
|
||||
preValidation: [fastify.authenticateOptional],
|
||||
handler: documentsController.listRootFolder.bind(documentsController),
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: "GET",
|
||||
url: `${serviceUrl}/:id`,
|
||||
preValidation: [fastify.authenticateOptional],
|
||||
handler: documentsController.get.bind(documentsController),
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: "POST",
|
||||
url: serviceUrl,
|
||||
preValidation: [fastify.authenticateOptional],
|
||||
schema: createDocumentSchema,
|
||||
handler: documentsController.create.bind(documentsController),
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: "POST",
|
||||
url: `${serviceUrl}/:id`,
|
||||
preValidation: [fastify.authenticateOptional],
|
||||
handler: documentsController.update.bind(documentsController),
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: "DELETE",
|
||||
url: `${serviceUrl}/:id`,
|
||||
preValidation: [fastify.authenticateOptional],
|
||||
handler: documentsController.delete.bind(documentsController),
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: "POST",
|
||||
url: `${serviceUrl}/:id/version`,
|
||||
preValidation: [fastify.authenticateOptional],
|
||||
schema: createVersionSchema,
|
||||
handler: documentsController.createVersion.bind(documentsController),
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: "GET",
|
||||
url: `${serviceUrl}/download/token`,
|
||||
preValidation: [fastify.authenticateOptional],
|
||||
handler: documentsController.downloadGetToken.bind(documentsController),
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: "GET",
|
||||
url: `${serviceUrl}/:id/download`,
|
||||
preValidation: [fastify.authenticateOptional],
|
||||
handler: documentsController.download.bind(documentsController),
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: "GET",
|
||||
url: `${serviceUrl}/download/zip`,
|
||||
preValidation: [fastify.authenticateOptional],
|
||||
handler: documentsController.downloadZip.bind(documentsController),
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: "POST",
|
||||
url: `${baseUrl}/search`,
|
||||
preValidation: [fastify.authenticate],
|
||||
handler: documentsController.search.bind(documentsController),
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: "GET",
|
||||
url: `${baseUrl}/tabs/:tab_id`,
|
||||
preValidation: [fastify.authenticate],
|
||||
handler: documentsController.getTab.bind(documentsController),
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: "POST",
|
||||
url: `${baseUrl}/tabs/:tab_id`,
|
||||
preValidation: [fastify.authenticate],
|
||||
handler: documentsController.setTab.bind(documentsController),
|
||||
});
|
||||
|
||||
return next();
|
||||
};
|
||||
|
||||
export default routes;
|
||||
@@ -0,0 +1,108 @@
|
||||
const fileVersionSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
provider: { type: "string" },
|
||||
drive_item_id: { type: "string" },
|
||||
file_metadata: {
|
||||
type: "object",
|
||||
properties: {
|
||||
source: { type: "string" },
|
||||
external_id: { type: "string" },
|
||||
name: { type: "string" },
|
||||
mime: { type: "string" },
|
||||
size: { type: "string" },
|
||||
thumbnails: {
|
||||
type: "object",
|
||||
properties: {
|
||||
index: { type: "number" },
|
||||
id: { type: "string" },
|
||||
type: { type: "string" },
|
||||
size: { type: "number" },
|
||||
width: { type: "number" },
|
||||
height: { type: "number" },
|
||||
url: { type: "string" },
|
||||
full_url: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
date_added: { type: "string" },
|
||||
creator_id: { type: "string" },
|
||||
application_id: { type: "string" },
|
||||
realname: { type: "string" },
|
||||
key: { type: "string" },
|
||||
mode: { type: "string" },
|
||||
file_size: { type: "number" },
|
||||
filename: { type: "string" },
|
||||
data: {},
|
||||
},
|
||||
};
|
||||
|
||||
const documentSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
company_id: { type: "string" },
|
||||
parent_id: { type: "string" },
|
||||
is_in_trash: { type: "boolean" },
|
||||
is_directory: { type: "boolean" },
|
||||
name: { type: "string" },
|
||||
extension: { type: "string" },
|
||||
description: { type: "string" },
|
||||
tags: { type: "array" },
|
||||
added: { type: "string" },
|
||||
last_modified: { type: "string" },
|
||||
access_info: {
|
||||
type: "object",
|
||||
properties: {
|
||||
public: {
|
||||
type: "object",
|
||||
properties: {
|
||||
token: { type: "string" },
|
||||
level: { type: "string" },
|
||||
},
|
||||
},
|
||||
entities: { type: "array" },
|
||||
},
|
||||
},
|
||||
content_keywords: { type: "string" },
|
||||
hidden_data: {},
|
||||
root_group_folder: { type: "string" },
|
||||
creator: { type: "string" },
|
||||
size: { type: "number" },
|
||||
detached_file: { type: "boolean" },
|
||||
has_preview: { type: "boolean" },
|
||||
shared: { type: "boolean" },
|
||||
url: { type: "string" },
|
||||
preview_link: { type: "string" },
|
||||
object_link_cache: { type: "string" },
|
||||
external_storage: { type: "boolean" },
|
||||
last_user: { type: "string" },
|
||||
attachements: { type: "array" },
|
||||
last_version_cache: fileVersionSchema,
|
||||
},
|
||||
};
|
||||
|
||||
export const createDocumentSchema = {
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
item: { type: "object" },
|
||||
version: { type: "object" },
|
||||
},
|
||||
required: ["item", "version"],
|
||||
},
|
||||
response: {
|
||||
"2xx": documentSchema,
|
||||
},
|
||||
};
|
||||
|
||||
export const createVersionSchema = {
|
||||
body: {
|
||||
type: "object",
|
||||
},
|
||||
response: {
|
||||
"2xx": fileVersionSchema,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Type } from "class-transformer";
|
||||
import _ from "lodash";
|
||||
import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators";
|
||||
|
||||
@Entity("files", {
|
||||
primaryKey: [["company_id"], "id"],
|
||||
type: "files",
|
||||
})
|
||||
export class File {
|
||||
@Type(() => String)
|
||||
@Column("company_id", "uuid")
|
||||
company_id: string;
|
||||
|
||||
@Type(() => String)
|
||||
@Column("id", "uuid", { generator: "uuid" })
|
||||
id: string;
|
||||
|
||||
@Type(() => String)
|
||||
@Column("user_id", "encoded_string")
|
||||
user_id: string;
|
||||
|
||||
@Column("application_id", "encoded_string")
|
||||
application_id: null | string;
|
||||
|
||||
@Column("encryption_key", "encoded_string")
|
||||
encryption_key: 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;
|
||||
|
||||
@Column("metadata", "encoded_json")
|
||||
metadata: null | {
|
||||
name?: string;
|
||||
mime?: string;
|
||||
thumbnails_status?: "done" | "error" | "waiting";
|
||||
};
|
||||
|
||||
@Column("thumbnails", "encoded_json")
|
||||
thumbnails: Thumbnail[];
|
||||
|
||||
@Column("upload_data", "encoded_json")
|
||||
upload_data: null | {
|
||||
size: number;
|
||||
chunks: number;
|
||||
};
|
||||
|
||||
getPublicObject(): PublicFile {
|
||||
return _.pick(
|
||||
this,
|
||||
"company_id",
|
||||
"id",
|
||||
"user_id",
|
||||
"application_id",
|
||||
"updated_at",
|
||||
"created_at",
|
||||
"metadata",
|
||||
"thumbnails",
|
||||
"upload_data",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type PublicFile = Pick<
|
||||
File,
|
||||
| "company_id"
|
||||
| "id"
|
||||
| "user_id"
|
||||
| "application_id"
|
||||
| "updated_at"
|
||||
| "created_at"
|
||||
| "metadata"
|
||||
| "thumbnails"
|
||||
| "upload_data"
|
||||
>;
|
||||
|
||||
export type Thumbnail = {
|
||||
index: number;
|
||||
id: string;
|
||||
|
||||
type: string;
|
||||
size: number;
|
||||
width: number;
|
||||
height: number;
|
||||
|
||||
url: string;
|
||||
full_url?: string;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Prefix, TdriveService } from "../../core/platform/framework";
|
||||
import WebServerAPI from "../../core/platform/services/webserver/provider";
|
||||
import web from "./web";
|
||||
|
||||
@Prefix("/internal/services/files/v1")
|
||||
export default class FilesService extends TdriveService<undefined> {
|
||||
version = "1";
|
||||
name = "files";
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import { Readable } from "stream";
|
||||
import { Multipart } from "fastify-multipart";
|
||||
import { UploadOptions } from "../types";
|
||||
import { File } from "../entities/file";
|
||||
import Repository from "../../../../src/core/platform/services/database/services/orm/repository/repository";
|
||||
import { CompanyExecutionContext } from "../web/types";
|
||||
import { logger } from "../../../core/platform/framework";
|
||||
import { getDownloadRoute, getThumbnailRoute } from "../web/routes";
|
||||
import {
|
||||
CrudException,
|
||||
DeleteResult,
|
||||
ExecutionContext,
|
||||
} from "../../../core/platform/framework/api/crud-service";
|
||||
import gr from "../../global-resolver";
|
||||
|
||||
export class FileServiceImpl {
|
||||
version: "1";
|
||||
repository: Repository<File>;
|
||||
private algorithm = "aes-256-cbc";
|
||||
private max_preview_file_size = 50000000;
|
||||
|
||||
async init(): Promise<this> {
|
||||
try {
|
||||
await Promise.all([(this.repository = await gr.database.getRepository<File>("files", File))]);
|
||||
} catch (err) {
|
||||
logger.error("Error while initializing files service", err);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
async save(
|
||||
id: string,
|
||||
file: Multipart,
|
||||
options: UploadOptions,
|
||||
context: CompanyExecutionContext,
|
||||
): Promise<File> {
|
||||
const userId = context.user?.id;
|
||||
const applicationId: string | null = context.user?.application_id || null;
|
||||
|
||||
let entity: File = null;
|
||||
if (id) {
|
||||
entity = await this.repository.findOne(
|
||||
{
|
||||
company_id: context.company.id,
|
||||
id: id,
|
||||
},
|
||||
{},
|
||||
context,
|
||||
);
|
||||
if (!entity) {
|
||||
throw new Error(`This file ${id} does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!entity) {
|
||||
entity = new File();
|
||||
entity.company_id = `${context.company.id}`;
|
||||
entity.metadata = null;
|
||||
entity.thumbnails = [];
|
||||
|
||||
const iv = randomBytes(8).toString("hex");
|
||||
const secret_key = randomBytes(16).toString("hex");
|
||||
entity.encryption_key = `${secret_key}.${iv}`;
|
||||
|
||||
entity.user_id = userId;
|
||||
entity.application_id = applicationId;
|
||||
entity.upload_data = null;
|
||||
|
||||
this.repository.save(entity, context);
|
||||
}
|
||||
|
||||
if (file) {
|
||||
// Detect a new file upload
|
||||
// Only applications can overwrite a file.
|
||||
// Users alone can only write an empty file.
|
||||
if (applicationId || !entity.upload_data?.size || context.user.server_request) {
|
||||
if (
|
||||
//If there was any change to the file
|
||||
entity.upload_data?.size !== options.totalSize ||
|
||||
entity.metadata?.name !== options.filename
|
||||
) {
|
||||
entity.metadata = {
|
||||
name: options.filename,
|
||||
mime: options.type,
|
||||
thumbnails_status: "done",
|
||||
};
|
||||
entity.upload_data = {
|
||||
size: options.totalSize,
|
||||
chunks: options.totalChunks || 1,
|
||||
};
|
||||
this.repository.save(entity, context);
|
||||
}
|
||||
}
|
||||
|
||||
let totalUploadedSize = 0;
|
||||
file.file.on("data", function (chunk) {
|
||||
totalUploadedSize += chunk.length;
|
||||
});
|
||||
await gr.platformServices.storage.write(getFilePath(entity), file.file, {
|
||||
chunkNumber: options.chunkNumber,
|
||||
encryptionAlgo: this.algorithm,
|
||||
encryptionKey: entity.encryption_key,
|
||||
});
|
||||
|
||||
if (entity.upload_data.chunks === 1 && totalUploadedSize) {
|
||||
entity.upload_data.size = totalUploadedSize;
|
||||
await this.repository.save(entity, context);
|
||||
}
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
async exists(id: string, companyId: string, context?: CompanyExecutionContext): Promise<boolean> {
|
||||
const entity = await this.getFile({ id, company_id: companyId }, context);
|
||||
return !!entity;
|
||||
}
|
||||
|
||||
async download(
|
||||
id: string,
|
||||
context: CompanyExecutionContext,
|
||||
): Promise<{ file: Readable; name: string; mime: string; size: number }> {
|
||||
const entity = await this.get(id, context);
|
||||
if (!entity) {
|
||||
throw "File not found";
|
||||
}
|
||||
|
||||
const readable = await gr.platformServices.storage.read(getFilePath(entity), {
|
||||
totalChunks: entity.upload_data.chunks,
|
||||
encryptionAlgo: this.algorithm,
|
||||
encryptionKey: entity.encryption_key,
|
||||
});
|
||||
|
||||
return {
|
||||
file: readable,
|
||||
name: entity.metadata.name,
|
||||
mime: entity.metadata.mime,
|
||||
size: entity.upload_data.size,
|
||||
};
|
||||
}
|
||||
|
||||
async thumbnail(
|
||||
id: string,
|
||||
index: string,
|
||||
context: CompanyExecutionContext,
|
||||
): Promise<{ file: Readable; type: string; size: number }> {
|
||||
const entity = await this.get(id, context);
|
||||
|
||||
if (!entity) {
|
||||
throw "File not found";
|
||||
}
|
||||
|
||||
const thumbnail = entity.thumbnails[parseInt(index)];
|
||||
if (!thumbnail) {
|
||||
throw `Thumbnail ${parseInt(index)} not found`;
|
||||
}
|
||||
|
||||
const thumbnailPath = `${getFilePath(entity)}/thumbnails/${thumbnail.id}`;
|
||||
|
||||
const readable = await gr.platformServices.storage.read(thumbnailPath, {
|
||||
encryptionAlgo: this.algorithm,
|
||||
encryptionKey: entity.encryption_key,
|
||||
});
|
||||
|
||||
return {
|
||||
file: readable,
|
||||
type: thumbnail.type,
|
||||
size: thumbnail.size,
|
||||
};
|
||||
}
|
||||
|
||||
get(id: string, context: CompanyExecutionContext): Promise<File> {
|
||||
if (!id || !context.company.id) {
|
||||
return null;
|
||||
}
|
||||
return this.getFile({ id, company_id: context.company.id }, context);
|
||||
}
|
||||
|
||||
async getFile(
|
||||
pk: Pick<File, "company_id" | "id">,
|
||||
context?: ExecutionContext,
|
||||
options?: {
|
||||
waitForThumbnail?: boolean;
|
||||
},
|
||||
): Promise<File> {
|
||||
let entity = await this.repository.findOne(pk, {}, context);
|
||||
|
||||
if (options?.waitForThumbnail) {
|
||||
for (let i = 1; i < 100; i++) {
|
||||
if (entity.metadata.thumbnails_status === "done") {
|
||||
break;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, i * 200));
|
||||
entity = await this.repository.findOne(pk, {}, context);
|
||||
}
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
getThumbnailRoute(file: File, index: string) {
|
||||
return getThumbnailRoute(file, index);
|
||||
}
|
||||
|
||||
getDownloadRoute(file: File) {
|
||||
return getDownloadRoute(file);
|
||||
}
|
||||
|
||||
async delete(id: string, context: CompanyExecutionContext): Promise<DeleteResult<File>> {
|
||||
const fileToDelete = await this.get(id, context);
|
||||
|
||||
if (!fileToDelete) {
|
||||
throw new CrudException("File not found", 404);
|
||||
}
|
||||
|
||||
await this.repository.remove(fileToDelete, context);
|
||||
|
||||
const path = getFilePath(fileToDelete);
|
||||
|
||||
await gr.platformServices.storage.remove(path, {
|
||||
totalChunks: fileToDelete.upload_data.chunks,
|
||||
});
|
||||
|
||||
return new DeleteResult("files", fileToDelete, true);
|
||||
}
|
||||
}
|
||||
|
||||
function getFilePath(entity: File): string {
|
||||
return `/tdrive/files/${entity.company_id}/${entity.user_id || "anonymous"}/${entity.id}`;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type UploadOptions = {
|
||||
filename: string;
|
||||
type: string;
|
||||
totalSize: number;
|
||||
totalChunks: number;
|
||||
chunkNumber: number;
|
||||
waitForThumbnail: boolean;
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { File, PublicFile } from "./entities/file";
|
||||
|
||||
export const formatPublicFile = (file: Partial<File | PublicFile>): PublicFile => {
|
||||
if ((file as Partial<File>).getPublicObject) file = (file as Partial<File>).getPublicObject();
|
||||
return {
|
||||
...file,
|
||||
thumbnails: [
|
||||
...file.thumbnails.map(thumbnail => ({
|
||||
...thumbnail,
|
||||
full_url: thumbnail.url.match(/https?:\/\//)
|
||||
? "/internal/services/files/v1/" + thumbnail.url.replace(/^\//, "")
|
||||
: thumbnail.url,
|
||||
})),
|
||||
],
|
||||
} as PublicFile;
|
||||
};
|
||||
|
||||
export const fileIsMedia = (file: Partial<File>): boolean => {
|
||||
return (
|
||||
file.metadata?.mime?.startsWith("video/") ||
|
||||
file.metadata?.mime?.startsWith("audio/") ||
|
||||
file.metadata?.mime?.startsWith("image/")
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { Multipart } from "fastify-multipart";
|
||||
import { ResourceDeleteResponse } from "../../../../utils/types";
|
||||
import { CompanyExecutionContext } from "../types";
|
||||
import { UploadOptions } from "../../types";
|
||||
import { PublicFile } from "../../entities/file";
|
||||
import gr from "../../../global-resolver";
|
||||
|
||||
export class FileController {
|
||||
async save(
|
||||
request: FastifyRequest<{
|
||||
Params: { company_id: string; id: string };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
Querystring: any;
|
||||
}>,
|
||||
): Promise<{ resource: PublicFile }> {
|
||||
const context = getCompanyExecutionContext(request);
|
||||
|
||||
let file: null | Multipart = null;
|
||||
if (request.isMultipart()) {
|
||||
file = await request.file();
|
||||
}
|
||||
const q = request.query;
|
||||
const options: UploadOptions = {
|
||||
totalChunks: parseInt(q.resumableTotalChunks || q.total_chunks) || 1,
|
||||
totalSize: parseInt(q.resumableTotalSize || q.total_size) || 0,
|
||||
chunkNumber: parseInt(q.resumableChunkNumber || q.chunk_number) || 1,
|
||||
filename: q.resumableFilename || q.filename || file?.filename || undefined,
|
||||
type: q.resumableType || q.type || file?.mimetype || undefined,
|
||||
waitForThumbnail: q.thumbnail_sync,
|
||||
};
|
||||
|
||||
const id = request.params.id;
|
||||
const result = await gr.services.files.save(id, file, options, context);
|
||||
|
||||
return {
|
||||
resource: result.getPublicObject(),
|
||||
};
|
||||
}
|
||||
|
||||
async download(
|
||||
request: FastifyRequest<{ Params: { company_id: string; id: string } }>,
|
||||
response: FastifyReply,
|
||||
): Promise<void> {
|
||||
const context = getCompanyExecutionContext(request);
|
||||
const params = request.params;
|
||||
const data = await gr.services.files.download(params.id, context);
|
||||
const filename = data.name.replace(/[^a-zA-Z0-9 -_.]/g, "");
|
||||
|
||||
response.header("Content-disposition", `attachment; filename="${filename}"`);
|
||||
if (data.size) response.header("Content-Length", data.size);
|
||||
response.type(data.mime);
|
||||
response.send(data.file);
|
||||
}
|
||||
|
||||
async thumbnail(
|
||||
request: FastifyRequest<{ Params: { company_id: string; id: string; index: string } }>,
|
||||
response: FastifyReply,
|
||||
): Promise<void> {
|
||||
const context = getCompanyExecutionContext(request);
|
||||
const params = request.params;
|
||||
try {
|
||||
const data = await gr.services.files.thumbnail(params.id, params.index, context);
|
||||
|
||||
response.header("Content-disposition", "inline");
|
||||
response.expires(new Date(new Date().getTime() + 1000 * 60 * 60 * 24 * 365));
|
||||
if (data.size) response.header("Content-Length", data.size);
|
||||
response.type(data.type);
|
||||
response.send(data.file);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
response.statusCode = 500;
|
||||
response.send("");
|
||||
}
|
||||
}
|
||||
|
||||
async get(
|
||||
request: FastifyRequest<{ Params: { company_id: string; id: string } }>,
|
||||
): Promise<{ resource: PublicFile }> {
|
||||
const context = getCompanyExecutionContext(request);
|
||||
const params = request.params;
|
||||
const resource = await gr.services.files.get(params.id, context);
|
||||
|
||||
return { resource: resource.getPublicObject() };
|
||||
}
|
||||
|
||||
async delete(
|
||||
request: FastifyRequest<{ Params: { company_id: string; id: string } }>,
|
||||
): Promise<ResourceDeleteResponse> {
|
||||
const params = request.params;
|
||||
const context = getCompanyExecutionContext(request);
|
||||
|
||||
const deleteResult = await gr.services.files.delete(params.id, context);
|
||||
|
||||
return { status: deleteResult.deleted ? "success" : "error" };
|
||||
}
|
||||
}
|
||||
|
||||
function getCompanyExecutionContext(
|
||||
request: FastifyRequest<{
|
||||
Params: { company_id: string };
|
||||
}>,
|
||||
): CompanyExecutionContext {
|
||||
return {
|
||||
user: request.currentUser,
|
||||
|
||||
company: { id: request.params.company_id },
|
||||
url: request.url,
|
||||
method: request.routerMethod,
|
||||
reqId: request.id,
|
||||
transport: "http",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./files";
|
||||
@@ -0,0 +1,12 @@
|
||||
import { FastifyInstance, FastifyRegisterOptions } from "fastify";
|
||||
import fastifyCaching from "@fastify/caching";
|
||||
import routes from "./routes";
|
||||
|
||||
export default (
|
||||
fastify: FastifyInstance,
|
||||
options: FastifyRegisterOptions<{ prefix: string }>,
|
||||
): void => {
|
||||
fastify.log.debug("Configuring /internal/services/files/v1 routes");
|
||||
fastify.register(fastifyCaching, { expiresIn: 31536000, privacy: fastifyCaching.privacy.PUBLIC });
|
||||
fastify.register(routes, options);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { FastifyInstance, FastifyPluginCallback } from "fastify";
|
||||
import { FileController } from "./controllers";
|
||||
import { File } from "../entities/file";
|
||||
|
||||
const filesUrl = "/companies/:company_id/files";
|
||||
|
||||
const routes: FastifyPluginCallback = (fastify: FastifyInstance, options, next) => {
|
||||
const fileController = new FileController();
|
||||
|
||||
fastify.route({
|
||||
method: "POST",
|
||||
url: filesUrl,
|
||||
preValidation: [fastify.authenticate],
|
||||
handler: fileController.save.bind(fileController),
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: "POST",
|
||||
url: `${filesUrl}/:id`,
|
||||
preValidation: [fastify.authenticate],
|
||||
handler: fileController.save.bind(fileController),
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: "GET",
|
||||
url: `${filesUrl}/:id/download`,
|
||||
handler: fileController.download.bind(fileController),
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: "GET",
|
||||
url: `${filesUrl}/:id`,
|
||||
preValidation: [fastify.authenticate],
|
||||
handler: fileController.get.bind(fileController),
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: "GET",
|
||||
url: `${filesUrl}/:id/thumbnails/:index`,
|
||||
handler: fileController.thumbnail.bind(fileController),
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: "DELETE",
|
||||
url: `${filesUrl}/:id`,
|
||||
preValidation: [fastify.authenticate],
|
||||
handler: fileController.delete.bind(fileController),
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
export const getDownloadRoute = (file: File) => {
|
||||
return filesUrl.replace(":company_id", file.company_id) + `/${file.id}/download`;
|
||||
};
|
||||
|
||||
export const getThumbnailRoute = (file: File, index: string) => {
|
||||
return `/internal/services/files/v1/companies/${file.company_id}/files/${file.id}/thumbnails/${index}`;
|
||||
};
|
||||
|
||||
export default routes;
|
||||
@@ -0,0 +1,13 @@
|
||||
export const filesSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
preferences: {
|
||||
type: "object",
|
||||
},
|
||||
id: { type: "string" },
|
||||
name: { type: "string" },
|
||||
size: { type: "number" },
|
||||
width: { type: "number" },
|
||||
height: { type: "number" },
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ExecutionContext } from "../../../core/platform/framework/api/crud-service";
|
||||
|
||||
export interface CompanyExecutionContext extends ExecutionContext {
|
||||
company: { id: string };
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { Initializable, TdriveServiceProvider } from "../../core/platform/framework/api";
|
||||
|
||||
export interface GeneralServiceAPI extends TdriveServiceProvider, Initializable {}
|
||||
@@ -0,0 +1,30 @@
|
||||
import WebServerAPI from "../../core/platform/services/webserver/provider";
|
||||
import { Consumes, Prefix, TdriveService } from "../../core/platform/framework";
|
||||
import { GeneralServiceAPI } from "./api";
|
||||
import web from "./web/index";
|
||||
import { ServerConfiguration } from "./types";
|
||||
|
||||
@Prefix("/internal/services/general/v1")
|
||||
@Consumes(["webserver"])
|
||||
export default class GeneralService extends TdriveService<GeneralServiceAPI> {
|
||||
version = "1";
|
||||
name = "general";
|
||||
service: GeneralServiceAPI;
|
||||
|
||||
api(): GeneralServiceAPI {
|
||||
return this.service;
|
||||
}
|
||||
|
||||
public async doInit(): Promise<this> {
|
||||
const fastify = this.context.getProvider<WebServerAPI>("webserver").getServer();
|
||||
|
||||
const configuration = this.configuration.get<ServerConfiguration["configuration"]>();
|
||||
|
||||
fastify.register((instance, _opts, next) => {
|
||||
web(instance, { prefix: this.prefix, configuration: configuration });
|
||||
next();
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Languages } from "./types";
|
||||
|
||||
export const languages: Languages = {
|
||||
default: "en",
|
||||
availables: ["en", "fr", "es", "vn", "ru"],
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
export type Languages = {
|
||||
default: string;
|
||||
availables: string[];
|
||||
};
|
||||
|
||||
export type ServerConfiguration = {
|
||||
status: "ready";
|
||||
version: {
|
||||
current: string;
|
||||
minimal: {
|
||||
web: string;
|
||||
mobile: string;
|
||||
};
|
||||
};
|
||||
configuration: {
|
||||
help_url: string | null;
|
||||
pricing_plan_url: string | null;
|
||||
app_download_url: string | null;
|
||||
app_grid: { logo: string; name: string; url: string }[];
|
||||
mobile: {
|
||||
mobile_redirect: string;
|
||||
mobile_appstore: string;
|
||||
mobile_googleplay: string;
|
||||
};
|
||||
accounts: {
|
||||
type: "remote" | "internal";
|
||||
remote: null | {
|
||||
authority: string;
|
||||
client_id: string;
|
||||
account_management_url: string;
|
||||
company_management_url: string;
|
||||
company_subscription_url: string;
|
||||
collaborators_management_url: string;
|
||||
};
|
||||
internal: null | {
|
||||
disable_account_creation: boolean;
|
||||
disable_email_verification: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { FastifyInstance, FastifyRegisterOptions } from "fastify";
|
||||
import { ServerConfiguration } from "../types";
|
||||
import routes from "./routes";
|
||||
|
||||
export default (
|
||||
fastify: FastifyInstance,
|
||||
options: FastifyRegisterOptions<{
|
||||
prefix: string;
|
||||
configuration: ServerConfiguration["configuration"];
|
||||
}>,
|
||||
): void => {
|
||||
fastify.log.debug("Configuring /internal/services/general/v1 routes");
|
||||
fastify.register(routes, options);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user