🐛 Fix email notification link (#738)

🐛 Fix email notification link
This commit is contained in:
Montassar Ghanmy
2024-11-25 13:27:00 +01:00
committed by GitHub
parent 1481a0c596
commit 36881ce0a1
10 changed files with 205 additions and 18 deletions
@@ -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;
};
@@ -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 => {