@@ -15,7 +15,6 @@ import { convert } from "html-to-text";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { existsSync } from "fs";
|
import { existsSync } from "fs";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import short, { Translator } from "short-uuid";
|
|
||||||
import { joinURL } from "../../../../utils/urls";
|
import { joinURL } from "../../../../utils/urls";
|
||||||
|
|
||||||
export default class EmailPusherClass
|
export default class EmailPusherClass
|
||||||
@@ -43,6 +42,7 @@ export default class EmailPusherClass
|
|||||||
});
|
});
|
||||||
this.interface = this.configuration.get<string>("email_interface", "");
|
this.interface = this.configuration.get<string>("email_interface", "");
|
||||||
this.platformUrl = this.configuration.get<string>("platform_url", "");
|
this.platformUrl = this.configuration.get<string>("platform_url", "");
|
||||||
|
this.debug = this.configuration.get<boolean>("debug", false);
|
||||||
if (this.interface === "smtp") {
|
if (this.interface === "smtp") {
|
||||||
const useTLS = this.configuration.get<string>("smtp_tls", "false") == "true";
|
const useTLS = this.configuration.get<string>("smtp_tls", "false") == "true";
|
||||||
const smtpConfig: SMTPClientConfigType = {
|
const smtpConfig: SMTPClientConfigType = {
|
||||||
@@ -60,7 +60,6 @@ export default class EmailPusherClass
|
|||||||
this.apiUrl = this.configuration.get<string>("endpoint", "");
|
this.apiUrl = this.configuration.get<string>("endpoint", "");
|
||||||
this.apiKey = this.configuration.get<string>("api_key", "");
|
this.apiKey = this.configuration.get<string>("api_key", "");
|
||||||
this.sender = this.configuration.get<string>("sender", "");
|
this.sender = this.configuration.get<string>("sender", "");
|
||||||
this.debug = this.configuration.get<boolean>("debug", false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
@@ -81,20 +80,10 @@ export default class EmailPusherClass
|
|||||||
): Promise<EmailBuilderRenderedResult> {
|
): Promise<EmailBuilderRenderedResult> {
|
||||||
try {
|
try {
|
||||||
language = ["en", "fr"].find(l => language.toLocaleLowerCase().includes(l)) || "en";
|
language = ["en", "fr"].find(l => language.toLocaleLowerCase().includes(l)) || "en";
|
||||||
const translator: Translator = short();
|
|
||||||
const templatePath = path.join(__dirname, "templates", language, `${template}.eta`);
|
const templatePath = path.join(__dirname, "templates", language, `${template}.eta`);
|
||||||
const subjectPath = path.join(__dirname, "templates", language, `${template}.subject.eta`);
|
const subjectPath = path.join(__dirname, "templates", language, `${template}.subject.eta`);
|
||||||
const encodedCompanyId = translator.fromUUID(data.notifications[0].item.company_id);
|
const urlComponents = data.notifications[0].urlComponents;
|
||||||
const encodedItemId = translator.fromUUID(data.notifications[0].item.id);
|
const encodedUrl = joinURL([this.platformUrl, ...urlComponents]);
|
||||||
const previewType = data.notifications[0].item.is_directory ? "d" : "preview";
|
|
||||||
const encodedUrl = joinURL([
|
|
||||||
this.platformUrl,
|
|
||||||
"client",
|
|
||||||
encodedCompanyId,
|
|
||||||
"v/shared_with_me",
|
|
||||||
previewType,
|
|
||||||
encodedItemId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!existsSync(templatePath)) {
|
if (!existsSync(templatePath)) {
|
||||||
throw Error(`template not found: ${templatePath}`);
|
throw Error(`template not found: ${templatePath}`);
|
||||||
@@ -118,7 +107,7 @@ export default class EmailPusherClass
|
|||||||
|
|
||||||
return { html, text, subject };
|
return { html, text, subject };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failure when building email template: ${error}`);
|
this.logger.error({ error }, `Failure when building email template: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +139,7 @@ export default class EmailPusherClass
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
this.logger.info("EMAIL::SENT ", { emailObject });
|
this.logger.info({ emailObject }, "EMAIL::SENT");
|
||||||
} else {
|
} else {
|
||||||
if (this.interface === "smtp") {
|
if (this.interface === "smtp") {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type EmailBuilderDataPayload = {
|
|||||||
notifications?: {
|
notifications?: {
|
||||||
type: string;
|
type: string;
|
||||||
item: DriveFile;
|
item: DriveFile;
|
||||||
|
urlComponents: string[];
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { DocumentsProcessor } from "./extract-keywords";
|
|||||||
import Repository from "../../../../core/platform/services/database/services/orm/repository/repository";
|
import Repository from "../../../../core/platform/services/database/services/orm/repository/repository";
|
||||||
import { DriveFile, TYPE } from "../../entities/drive-file";
|
import { DriveFile, TYPE } from "../../entities/drive-file";
|
||||||
import { DocumentsFinishedProcess } from "./save-keywords";
|
import { DocumentsFinishedProcess } from "./save-keywords";
|
||||||
|
import { generateEncodedUrlComponents } from "../../utils";
|
||||||
|
|
||||||
export class DocumentsEngine implements Initializable {
|
export class DocumentsEngine implements Initializable {
|
||||||
private documentRepository: Repository<DriveFile>;
|
private documentRepository: Repository<DriveFile>;
|
||||||
|
|
||||||
@@ -24,6 +26,7 @@ export class DocumentsEngine implements Initializable {
|
|||||||
logger.error(`Error dispatching document event. Unknown event type: ${event}`);
|
logger.error(`Error dispatching document event. Unknown event type: ${event}`);
|
||||||
return; // Early return on unknown event type
|
return; // Early return on unknown event type
|
||||||
}
|
}
|
||||||
|
const urlComponents = generateEncodedUrlComponents(e, receiver.id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { html, text, subject } = await globalResolver.platformServices.emailPusher.build(
|
const { html, text, subject } = await globalResolver.platformServices.emailPusher.build(
|
||||||
@@ -37,10 +40,12 @@ export class DocumentsEngine implements Initializable {
|
|||||||
{
|
{
|
||||||
type: event,
|
type: event,
|
||||||
item: e.item,
|
item: e.item,
|
||||||
|
urlComponents,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info(`Sending email notification to ${receiver.email_canonical}`);
|
logger.info(`Sending email notification to ${receiver.email_canonical}`);
|
||||||
await globalResolver.platformServices.emailPusher.send(receiver.email_canonical, {
|
await globalResolver.platformServices.emailPusher.send(receiver.email_canonical, {
|
||||||
subject,
|
subject,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
DriveFileAccessLevel,
|
DriveFileAccessLevel,
|
||||||
DriveItemDetails,
|
DriveItemDetails,
|
||||||
DriveTdriveTab,
|
DriveTdriveTab,
|
||||||
|
NotificationActionType,
|
||||||
RootType,
|
RootType,
|
||||||
SearchDocumentsOptions,
|
SearchDocumentsOptions,
|
||||||
TrashType,
|
TrashType,
|
||||||
@@ -457,6 +458,7 @@ export class DocumentsService {
|
|||||||
gr.services.documents.engine.notifyDocumentShared({
|
gr.services.documents.engine.notifyDocumentShared({
|
||||||
context,
|
context,
|
||||||
item: driveItem,
|
item: driveItem,
|
||||||
|
type: NotificationActionType.UPDATE,
|
||||||
notificationEmitter: context.user.id,
|
notificationEmitter: context.user.id,
|
||||||
notificationReceiver: parentItem.creator,
|
notificationReceiver: parentItem.creator,
|
||||||
});
|
});
|
||||||
@@ -628,6 +630,7 @@ export class DocumentsService {
|
|||||||
gr.services.documents.engine.notifyDocumentShared({
|
gr.services.documents.engine.notifyDocumentShared({
|
||||||
context,
|
context,
|
||||||
item,
|
item,
|
||||||
|
type: NotificationActionType.DIRECT,
|
||||||
notificationEmitter: context.user.id,
|
notificationEmitter: context.user.id,
|
||||||
notificationReceiver: sharedWith[0].id,
|
notificationReceiver: sharedWith[0].id,
|
||||||
});
|
});
|
||||||
@@ -1004,6 +1007,7 @@ export class DocumentsService {
|
|||||||
gr.services.documents.engine.notifyDocumentVersionUpdated({
|
gr.services.documents.engine.notifyDocumentVersionUpdated({
|
||||||
context,
|
context,
|
||||||
item,
|
item,
|
||||||
|
type: NotificationActionType.UPDATE,
|
||||||
notificationEmitter: context.user.id,
|
notificationEmitter: context.user.id,
|
||||||
notificationReceiver: item.creator,
|
notificationReceiver: item.creator,
|
||||||
});
|
});
|
||||||
@@ -1761,6 +1765,7 @@ export class DocumentsService {
|
|||||||
await gr.services.documents.engine.notifyDocumentAVScanAlert({
|
await gr.services.documents.engine.notifyDocumentAVScanAlert({
|
||||||
context,
|
context,
|
||||||
item,
|
item,
|
||||||
|
type: NotificationActionType.DIRECT,
|
||||||
notificationEmitter: context.user.id,
|
notificationEmitter: context.user.id,
|
||||||
notificationReceiver: context.user.id,
|
notificationReceiver: context.user.id,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -125,9 +125,15 @@ export const eventToTemplateMap: Record<string, any> = {
|
|||||||
[DocumentEvents.DOCUMENT_SAHRED]: "notification-document-shared",
|
[DocumentEvents.DOCUMENT_SAHRED]: "notification-document-shared",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export enum NotificationActionType {
|
||||||
|
DIRECT = "direct",
|
||||||
|
UPDATE = "update",
|
||||||
|
}
|
||||||
|
|
||||||
export type NotificationPayloadType = {
|
export type NotificationPayloadType = {
|
||||||
context: CompanyExecutionContext;
|
context: CompanyExecutionContext;
|
||||||
item: DriveFile;
|
item: DriveFile;
|
||||||
|
type: NotificationActionType;
|
||||||
notificationEmitter: string;
|
notificationEmitter: string;
|
||||||
notificationReceiver: string;
|
notificationReceiver: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,10 +21,13 @@ import { checkAccess, generateAccessToken } from "./services/access-check";
|
|||||||
import {
|
import {
|
||||||
CompanyExecutionContext,
|
CompanyExecutionContext,
|
||||||
DriveExecutionContext,
|
DriveExecutionContext,
|
||||||
|
NotificationActionType,
|
||||||
|
NotificationPayloadType,
|
||||||
RootType,
|
RootType,
|
||||||
SharedWithMeType,
|
SharedWithMeType,
|
||||||
TrashType,
|
TrashType,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import short, { Translator } from "short-uuid";
|
||||||
|
|
||||||
const ROOT: RootType = "root";
|
const ROOT: RootType = "root";
|
||||||
const TRASH: TrashType = "trash";
|
const TRASH: TrashType = "trash";
|
||||||
@@ -673,3 +676,43 @@ export const getKeywordsOfFile = async (
|
|||||||
}
|
}
|
||||||
return extractKeywords(content_strings);
|
return extractKeywords(content_strings);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate encodedUrl for email notification
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const generateEncodedUrlComponents = (e: NotificationPayloadType, receiver: string) => {
|
||||||
|
const translator: Translator = short();
|
||||||
|
const encodedCompanyId = translator.fromUUID(e.item.company_id);
|
||||||
|
const clientPath = ["client", encodedCompanyId, "v"];
|
||||||
|
const isPersonalScope = e.item.scope === "personal";
|
||||||
|
const isDirectory = e.item.is_directory;
|
||||||
|
const itemId = isDirectory ? e.item.id : e.item.parent_id;
|
||||||
|
|
||||||
|
// Determine the scope and base view
|
||||||
|
let view: string;
|
||||||
|
switch (e.type) {
|
||||||
|
case NotificationActionType.UPDATE:
|
||||||
|
view = isPersonalScope ? `user_${receiver}` : "root";
|
||||||
|
break;
|
||||||
|
case NotificationActionType.DIRECT:
|
||||||
|
view = "shared_with_me";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unexpected NotificationActionType value: ${e.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build URL components
|
||||||
|
const urlComponents = [...clientPath, view];
|
||||||
|
|
||||||
|
// Add directory and itemId if applicable
|
||||||
|
if (e.type === NotificationActionType.UPDATE || isDirectory) {
|
||||||
|
urlComponents.push("d", itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// To highlight the file in the document browser when the user clicks on the notification
|
||||||
|
if (!isDirectory) {
|
||||||
|
urlComponents.push("preview", e.item.id);
|
||||||
|
}
|
||||||
|
return urlComponents;
|
||||||
|
};
|
||||||
|
|||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
import { expect, jest, describe, beforeEach, test } from "@jest/globals";
|
||||||
|
import { generateEncodedUrlComponents } from "../../../../../src/services/documents/utils";
|
||||||
|
import {
|
||||||
|
NotificationActionType,
|
||||||
|
NotificationPayloadType,
|
||||||
|
} from "../../../../../src/services/documents/types";
|
||||||
|
|
||||||
|
// Mock short-uuid
|
||||||
|
jest.mock("short-uuid", () => {
|
||||||
|
const mockTranslator = {
|
||||||
|
fromUUID: jest.fn((uuid: string) => `short-${uuid}`),
|
||||||
|
};
|
||||||
|
return jest.fn(() => mockTranslator);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateEncodedUrlComponents", () => {
|
||||||
|
const receiver = "receiver-id";
|
||||||
|
|
||||||
|
const mockItem = (overrides: Partial<NotificationPayloadType["item"]>) => ({
|
||||||
|
company_id: "company-uuid",
|
||||||
|
id: "item-id",
|
||||||
|
parent_id: "parent-id",
|
||||||
|
scope: "personal",
|
||||||
|
is_directory: false,
|
||||||
|
is_in_trash: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockPayload = (
|
||||||
|
type: NotificationActionType,
|
||||||
|
overrides: Partial<NotificationPayloadType> = {},
|
||||||
|
) => ({
|
||||||
|
type,
|
||||||
|
item: mockItem({}),
|
||||||
|
notificationEmitter: "user_emitter-id",
|
||||||
|
notificationReceiver: "user_receiver-id",
|
||||||
|
context: {},
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DIRECT action: user shares a directory or a file with another user", () => {
|
||||||
|
test("should link to shared with me view for a shared directory", () => {
|
||||||
|
const payload = mockPayload(NotificationActionType.DIRECT, {
|
||||||
|
item: mockItem({ is_directory: true }) as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectedResult = [
|
||||||
|
"client",
|
||||||
|
"short-company-uuid",
|
||||||
|
"v",
|
||||||
|
"shared_with_me",
|
||||||
|
"d",
|
||||||
|
"item-id",
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = generateEncodedUrlComponents(payload as NotificationPayloadType, receiver);
|
||||||
|
expect(result).toEqual(expectedResult);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should link to shared with me and preview for a shared file", () => {
|
||||||
|
const payload = mockPayload(NotificationActionType.DIRECT);
|
||||||
|
|
||||||
|
const expectedResult = [
|
||||||
|
"client",
|
||||||
|
"short-company-uuid",
|
||||||
|
"v",
|
||||||
|
"shared_with_me",
|
||||||
|
"preview",
|
||||||
|
"item-id",
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = generateEncodedUrlComponents(payload as NotificationPayloadType, receiver);
|
||||||
|
expect(result).toEqual(expectedResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("UPDATE action: user updates a direcotry or file shared with them, on twake drive or through public link", () => {
|
||||||
|
test("should link to my drive view for a directory update", () => {
|
||||||
|
const payload = mockPayload(NotificationActionType.UPDATE, {
|
||||||
|
item: mockItem({ is_directory: true }) as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectedResult = [
|
||||||
|
"client",
|
||||||
|
"short-company-uuid",
|
||||||
|
"v",
|
||||||
|
"user_receiver-id",
|
||||||
|
"d",
|
||||||
|
"item-id",
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = generateEncodedUrlComponents(payload as NotificationPayloadType, receiver);
|
||||||
|
expect(result).toEqual(expectedResult);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should link to my drive and preview for a file update", () => {
|
||||||
|
const payload = mockPayload(NotificationActionType.UPDATE);
|
||||||
|
|
||||||
|
const expectedResult = [
|
||||||
|
"client",
|
||||||
|
"short-company-uuid",
|
||||||
|
"v",
|
||||||
|
"user_receiver-id",
|
||||||
|
"d",
|
||||||
|
"parent-id",
|
||||||
|
"preview",
|
||||||
|
"item-id",
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = generateEncodedUrlComponents(payload as NotificationPayloadType, receiver);
|
||||||
|
expect(result).toEqual(expectedResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -79,7 +79,6 @@ class RouterServices extends Observable {
|
|||||||
|
|
||||||
UUIDsToTranslate: Readonly<string[]> = [
|
UUIDsToTranslate: Readonly<string[]> = [
|
||||||
'companyId',
|
'companyId',
|
||||||
'itemId',
|
|
||||||
'workspaceId',
|
'workspaceId',
|
||||||
'channelId',
|
'channelId',
|
||||||
'messageId',
|
'messageId',
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export default memo(
|
|||||||
: 'member';
|
: 'member';
|
||||||
setTdriveTabToken(tdriveTabContextToken || null);
|
setTdriveTabToken(tdriveTabContextToken || null);
|
||||||
const [ filter ] = useRecoilState(SharedWithMeFilterState);
|
const [ filter ] = useRecoilState(SharedWithMeFilterState);
|
||||||
const { viewId, dirId } = useRouteState();
|
const { viewId, dirId, itemId } = useRouteState();
|
||||||
const [sortLabel] = useRecoilState(DriveItemSort)
|
const [sortLabel] = useRecoilState(DriveItemSort)
|
||||||
const [parentId, _setParentId] = useRecoilState(
|
const [parentId, _setParentId] = useRecoilState(
|
||||||
DriveCurrentFolderAtom({
|
DriveCurrentFolderAtom({
|
||||||
@@ -259,6 +259,26 @@ export default memo(
|
|||||||
};
|
};
|
||||||
}, [parentId, loading]);
|
}, [parentId, loading]);
|
||||||
|
|
||||||
|
// Scroll to item in view
|
||||||
|
const scrollTillItemInView = itemId && itemId?.length > 0;
|
||||||
|
const scrollItemId = itemId || '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const itemInChildren = children.find(item => item.id === scrollItemId);
|
||||||
|
if (!loading && scrollTillItemInView && !itemInChildren) {
|
||||||
|
scrollViwer.current?.scrollTo(0, scrollViwer.current?.scrollHeight);
|
||||||
|
} else {
|
||||||
|
if (!loading && itemInChildren) {
|
||||||
|
// scroll to preview item using id for current preview routes
|
||||||
|
const element = document.getElementById(`DR-${scrollItemId}`);
|
||||||
|
element?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
|
||||||
|
// set it as checked to indicate it is in view
|
||||||
|
setChecked({ [scrollItemId]: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [loading, children]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{viewId == 'shared-with-me' ? (
|
{viewId == 'shared-with-me' ? (
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export const DocumentRow = ({
|
|||||||
: 'hover:bg-zinc-500 hover:bg-opacity-10 ') +
|
: 'hover:bg-zinc-500 hover:bg-opacity-10 ') +
|
||||||
(className || '')
|
(className || '')
|
||||||
}
|
}
|
||||||
|
id={`DR-${item.id}`}
|
||||||
onMouseEnter={() => setHover(true)}
|
onMouseEnter={() => setHover(true)}
|
||||||
onMouseLeave={() => setHover(false)}
|
onMouseLeave={() => setHover(false)}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
|
|||||||
Reference in New Issue
Block a user