diff --git a/tdrive/backend/node/src/core/platform/services/email-pusher/index.ts b/tdrive/backend/node/src/core/platform/services/email-pusher/index.ts index 81a9ffe7..dcdc9984 100644 --- a/tdrive/backend/node/src/core/platform/services/email-pusher/index.ts +++ b/tdrive/backend/node/src/core/platform/services/email-pusher/index.ts @@ -15,7 +15,6 @@ import { convert } from "html-to-text"; import path from "path"; import { existsSync } from "fs"; import axios from "axios"; -import short, { Translator } from "short-uuid"; import { joinURL } from "../../../../utils/urls"; export default class EmailPusherClass @@ -43,6 +42,7 @@ export default class EmailPusherClass }); this.interface = this.configuration.get("email_interface", ""); this.platformUrl = this.configuration.get("platform_url", ""); + this.debug = this.configuration.get("debug", false); if (this.interface === "smtp") { const useTLS = this.configuration.get("smtp_tls", "false") == "true"; const smtpConfig: SMTPClientConfigType = { @@ -60,7 +60,6 @@ export default class EmailPusherClass this.apiUrl = this.configuration.get("endpoint", ""); this.apiKey = this.configuration.get("api_key", ""); this.sender = this.configuration.get("sender", ""); - this.debug = this.configuration.get("debug", false); } return this; @@ -81,20 +80,10 @@ export default class EmailPusherClass ): Promise { try { language = ["en", "fr"].find(l => language.toLocaleLowerCase().includes(l)) || "en"; - const translator: Translator = short(); const templatePath = path.join(__dirname, "templates", language, `${template}.eta`); const subjectPath = path.join(__dirname, "templates", language, `${template}.subject.eta`); - const encodedCompanyId = translator.fromUUID(data.notifications[0].item.company_id); - const encodedItemId = translator.fromUUID(data.notifications[0].item.id); - const previewType = data.notifications[0].item.is_directory ? "d" : "preview"; - const encodedUrl = joinURL([ - this.platformUrl, - "client", - encodedCompanyId, - "v/shared_with_me", - previewType, - encodedItemId, - ]); + const urlComponents = data.notifications[0].urlComponents; + const encodedUrl = joinURL([this.platformUrl, ...urlComponents]); if (!existsSync(templatePath)) { throw Error(`template not found: ${templatePath}`); @@ -118,7 +107,7 @@ export default class EmailPusherClass return { html, text, subject }; } 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) { - this.logger.info("EMAIL::SENT ", { emailObject }); + this.logger.info({ emailObject }, "EMAIL::SENT"); } else { if (this.interface === "smtp") { try { diff --git a/tdrive/backend/node/src/core/platform/services/email-pusher/types.ts b/tdrive/backend/node/src/core/platform/services/email-pusher/types.ts index 25c45f4f..5d5ef9e3 100644 --- a/tdrive/backend/node/src/core/platform/services/email-pusher/types.ts +++ b/tdrive/backend/node/src/core/platform/services/email-pusher/types.ts @@ -9,6 +9,7 @@ export type EmailBuilderDataPayload = { notifications?: { type: string; item: DriveFile; + urlComponents: string[]; }[]; }; diff --git a/tdrive/backend/node/src/services/documents/services/engine/index.ts b/tdrive/backend/node/src/services/documents/services/engine/index.ts index a0b17933..4c2b7a56 100644 --- a/tdrive/backend/node/src/services/documents/services/engine/index.ts +++ b/tdrive/backend/node/src/services/documents/services/engine/index.ts @@ -7,6 +7,8 @@ import { DocumentsProcessor } from "./extract-keywords"; import Repository from "../../../../core/platform/services/database/services/orm/repository/repository"; import { DriveFile, TYPE } from "../../entities/drive-file"; import { DocumentsFinishedProcess } from "./save-keywords"; +import { generateEncodedUrlComponents } from "../../utils"; + export class DocumentsEngine implements Initializable { private documentRepository: Repository; @@ -24,6 +26,7 @@ export class DocumentsEngine implements Initializable { logger.error(`Error dispatching document event. Unknown event type: ${event}`); return; // Early return on unknown event type } + const urlComponents = generateEncodedUrlComponents(e, receiver.id); try { const { html, text, subject } = await globalResolver.platformServices.emailPusher.build( @@ -37,10 +40,12 @@ export class DocumentsEngine implements Initializable { { type: event, item: e.item, + urlComponents, }, ], }, ); + logger.info(`Sending email notification to ${receiver.email_canonical}`); await globalResolver.platformServices.emailPusher.send(receiver.email_canonical, { subject, diff --git a/tdrive/backend/node/src/services/documents/services/index.ts b/tdrive/backend/node/src/services/documents/services/index.ts index 43bb13d3..3b91eb96 100644 --- a/tdrive/backend/node/src/services/documents/services/index.ts +++ b/tdrive/backend/node/src/services/documents/services/index.ts @@ -30,6 +30,7 @@ import { DriveFileAccessLevel, DriveItemDetails, DriveTdriveTab, + NotificationActionType, RootType, SearchDocumentsOptions, TrashType, @@ -457,6 +458,7 @@ export class DocumentsService { gr.services.documents.engine.notifyDocumentShared({ context, item: driveItem, + type: NotificationActionType.UPDATE, notificationEmitter: context.user.id, notificationReceiver: parentItem.creator, }); @@ -628,6 +630,7 @@ export class DocumentsService { gr.services.documents.engine.notifyDocumentShared({ context, item, + type: NotificationActionType.DIRECT, notificationEmitter: context.user.id, notificationReceiver: sharedWith[0].id, }); @@ -1004,6 +1007,7 @@ export class DocumentsService { gr.services.documents.engine.notifyDocumentVersionUpdated({ context, item, + type: NotificationActionType.UPDATE, notificationEmitter: context.user.id, notificationReceiver: item.creator, }); @@ -1761,6 +1765,7 @@ export class DocumentsService { await gr.services.documents.engine.notifyDocumentAVScanAlert({ context, item, + type: NotificationActionType.DIRECT, notificationEmitter: context.user.id, notificationReceiver: context.user.id, }); diff --git a/tdrive/backend/node/src/services/documents/types.ts b/tdrive/backend/node/src/services/documents/types.ts index 74084356..78f45bc4 100644 --- a/tdrive/backend/node/src/services/documents/types.ts +++ b/tdrive/backend/node/src/services/documents/types.ts @@ -125,9 +125,15 @@ export const eventToTemplateMap: Record = { [DocumentEvents.DOCUMENT_SAHRED]: "notification-document-shared", }; +export enum NotificationActionType { + DIRECT = "direct", + UPDATE = "update", +} + export type NotificationPayloadType = { context: CompanyExecutionContext; item: DriveFile; + type: NotificationActionType; notificationEmitter: string; notificationReceiver: string; }; diff --git a/tdrive/backend/node/src/services/documents/utils.ts b/tdrive/backend/node/src/services/documents/utils.ts index 8e5963f6..dad901ef 100644 --- a/tdrive/backend/node/src/services/documents/utils.ts +++ b/tdrive/backend/node/src/services/documents/utils.ts @@ -21,10 +21,13 @@ import { checkAccess, generateAccessToken } from "./services/access-check"; import { CompanyExecutionContext, DriveExecutionContext, + NotificationActionType, + NotificationPayloadType, RootType, SharedWithMeType, TrashType, } from "./types"; +import short, { Translator } from "short-uuid"; const ROOT: RootType = "root"; const TRASH: TrashType = "trash"; @@ -673,3 +676,43 @@ export const getKeywordsOfFile = async ( } 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; +}; diff --git a/tdrive/backend/node/test/unit/core/services/documents/document-notification-url-components.test.ts b/tdrive/backend/node/test/unit/core/services/documents/document-notification-url-components.test.ts new file mode 100644 index 00000000..ef6072bb --- /dev/null +++ b/tdrive/backend/node/test/unit/core/services/documents/document-notification-url-components.test.ts @@ -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) => ({ + 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 = {}, + ) => ({ + 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); + }); + }); +}); diff --git a/tdrive/frontend/src/app/features/router/services/router-service.ts b/tdrive/frontend/src/app/features/router/services/router-service.ts index 19d9a7d7..5c7799b9 100644 --- a/tdrive/frontend/src/app/features/router/services/router-service.ts +++ b/tdrive/frontend/src/app/features/router/services/router-service.ts @@ -79,7 +79,6 @@ class RouterServices extends Observable { UUIDsToTranslate: Readonly = [ 'companyId', - 'itemId', 'workspaceId', 'channelId', 'messageId', diff --git a/tdrive/frontend/src/app/views/client/body/drive/browser.tsx b/tdrive/frontend/src/app/views/client/body/drive/browser.tsx index 3dd05a4b..07bd1241 100644 --- a/tdrive/frontend/src/app/views/client/body/drive/browser.tsx +++ b/tdrive/frontend/src/app/views/client/body/drive/browser.tsx @@ -78,7 +78,7 @@ export default memo( : 'member'; setTdriveTabToken(tdriveTabContextToken || null); const [ filter ] = useRecoilState(SharedWithMeFilterState); - const { viewId, dirId } = useRouteState(); + const { viewId, dirId, itemId } = useRouteState(); const [sortLabel] = useRecoilState(DriveItemSort) const [parentId, _setParentId] = useRecoilState( DriveCurrentFolderAtom({ @@ -259,6 +259,26 @@ export default memo( }; }, [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 ( <> {viewId == 'shared-with-me' ? ( diff --git a/tdrive/frontend/src/app/views/client/body/drive/documents/document-row.tsx b/tdrive/frontend/src/app/views/client/body/drive/documents/document-row.tsx index 20586b21..bc00ce3e 100644 --- a/tdrive/frontend/src/app/views/client/body/drive/documents/document-row.tsx +++ b/tdrive/frontend/src/app/views/client/body/drive/documents/document-row.tsx @@ -54,6 +54,7 @@ export const DocumentRow = ({ : 'hover:bg-zinc-500 hover:bg-opacity-10 ') + (className || '') } + id={`DR-${item.id}`} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} onClick={e => {