Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[NEW] Rocket.cat message for users when an app previously requested is installed #27672

Merged
merged 22 commits into from
Jan 11, 2023
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4759d5b
feat: list all non sent app requests from an workspace
Dec 26, 2022
a3c3b00
feat: add a function to notify users via rocket.cat and call it when …
Dec 27, 2022
5eaa624
feat: add i18n text to app request end users notification
Dec 27, 2022
8a4ff2d
feat: mark all app requests for end users as sent
Dec 27, 2022
38a6532
feat: handle all messag sending on server
Dec 27, 2022
2792267
chore: move mark as sent action, and also making the code simpler
Dec 28, 2022
77864ae
chore: move mark as sent action, and also making the code simpler
Dec 28, 2022
09ffbed
ref: change to notify users via a cronjob instead of via client side
Dec 29, 2022
9c2aaa5
Merge branch 'feat/install-app-request-notification' of github.com:Ro…
Dec 29, 2022
389b4b1
ref: remove app request notification from client side
Dec 29, 2022
a7d65bb
chore: change cronjob time
Dec 30, 2022
5c8fa1b
feat: log errors
Dec 30, 2022
5bf6b41
fix: typecheck errors
Dec 30, 2022
ae832af
ref: extract app requests related cronjob to its own file
Dec 30, 2022
74b55f2
feat: add learn more url on rocket.cat notification
Dec 30, 2022
5e7a480
Bump the time interval to 12 hours instead of 24
graywolf336 Jan 6, 2023
4fe611e
Add default query parameters
graywolf336 Jan 6, 2023
3ac1dce
Whoops, use the correct js syntax.
graywolf336 Jan 6, 2023
8d7f492
Merge branch 'develop' into feat/install-app-request-notification
graywolf336 Jan 6, 2023
221d816
Prefer async function over `Promise` method chaining
tassoevan Jan 11, 2023
3c2b3de
Switch to cursor from toArray
graywolf336 Jan 11, 2023
07c68fb
Merge branch 'develop' into feat/install-app-request-notification
graywolf336 Jan 11, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion apps/meteor/app/apps/client/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { IPermission } from '@rocket.chat/apps-engine/definition/permission
import type { IAppStorageItem } from '@rocket.chat/apps-engine/server/storage/IAppStorageItem';
import { Meteor } from 'meteor/meteor';
import { Tracker } from 'meteor/tracker';
import type { AppScreenshot, Serialized } from '@rocket.chat/core-typings';
import type { AppScreenshot, AppRequestFilter, Pagination, IRestResponse, Serialized, AppRequest } from '@rocket.chat/core-typings';

import type { App } from '../../../client/views/admin/apps/types';
import { dispatchToastMessage } from '../../../client/lib/toast';
Expand Down Expand Up @@ -234,6 +234,28 @@ class AppClientOrchestrator {
throw new Error('Failed to build external url');
}

public async appRequests(
appId: string,
filter: AppRequestFilter,
sort: string,
pagination: Pagination,
): Promise<IRestResponse<AppRequest>> {
try {
const response: IRestResponse<AppRequest> = await APIClient.get(
`/apps/app-request?appId=${appId}&q=${filter}&sort=${sort}&limit=${pagination.limit}&offset=${pagination.offset}`,
);

const restResponse = {
data: response.data,
meta: response.meta,
};

return restResponse;
} catch (e: unknown) {
throw new Error('Could not get the list of app requests');
}
}

public async getCategories(): Promise<Serialized<ICategory[]>> {
const result = await APIClient.get('/apps', { categories: 'true' });

Expand Down
70 changes: 70 additions & 0 deletions apps/meteor/app/apps/server/appRequestsCron.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Meteor } from 'meteor/meteor';
import { HTTP } from 'meteor/http';
import { SyncedCron } from 'meteor/littledata:synced-cron';

import { settings } from '../../settings/server';
import { Apps } from './orchestrator';
import { getWorkspaceAccessToken } from '../../cloud/server';
import { appRequestNotififyForUsers } from './marketplace/appRequestNotifyUsers';

export const appsNotifyAppRequests = Meteor.bindEnvironment(function _appsNotifyAppRequests() {
try {
const installedApps = Promise.await(Apps.installedApps({ enabled: true }));
if (!installedApps || installedApps.length === 0) {
return;
}

const workspaceUrl = settings.get<string>('Site_Url');
const token = Promise.await(getWorkspaceAccessToken());
const baseUrl = Apps.getMarketplaceUrl();
if (!baseUrl) {
Apps.debugLog(`could not load marketplace base url to send app requests notifications`);
return;
}

const options = {
headers: {
Authorization: `Bearer ${token}`,
},
};

const pendingSentUrl = `${baseUrl}/v1/app-request/sent/pending`;
const result = HTTP.get(pendingSentUrl, options);
const data = result.data?.data;
const filtered = installedApps.filter((app) => data.indexOf(app.getID()) !== -1);

filtered.forEach((app) => {
const appId = app.getID();
const appName = app.getName();

const usersNotified = Promise.await<(string | Error)[]>(
appRequestNotififyForUsers(baseUrl, workspaceUrl, appId, appName)
.then((response) => {
// Mark all app requests as sent
HTTP.post(`${baseUrl}/v1/app-request/markAsSent/${appId}`, options);
return response;
})
.catch((err) => {
Apps.debugLog(`could not send app request notifications for app ${appId}. Error: ${err}`);
return err;
}),
);

const errors = usersNotified.filter((batch) => batch instanceof Error);
if (errors.length > 0) {
Apps.debugLog(`Some batches of users could not be notified for app ${appId}. Errors: ${errors}`);
}
});
} catch (err) {
Apps.debugLog(err);
}
});

// Scheduling as every 12 hours to avoid multiple instances hiting the marketplace at the same time
SyncedCron.add({
name: 'Apps-Request-End-Users:notify',
schedule: (parser) => parser.text('every 12 hours'),
job() {
appsNotifyAppRequests();
},
});
29 changes: 29 additions & 0 deletions apps/meteor/app/apps/server/communication/rest.js
Original file line number Diff line number Diff line change
Expand Up @@ -885,5 +885,34 @@ export class AppsRestApi {
},
},
);

this.api.addRoute(
'app-request',
{ authRequired: true },
{
async get() {
const baseUrl = orchestrator.getMarketplaceUrl();
const { appId, q = '', sort = '', limit = 25, offset = 0 } = this.queryParams;
const headers = getDefaultHeaders();

const token = await getWorkspaceAccessToken();
if (token) {
headers.Authorization = `Bearer ${token}`;
}

try {
const data = HTTP.get(`${baseUrl}/v1/app-request?appId=${appId}&q=${q}&sort=${sort}&limit=${limit}&offset=${offset}`, {
headers,
});

return API.v1.success({ data });
} catch (e) {
orchestrator.getRocketChatLogger().error('Error getting all non sent app requests from the Marketplace:', e.message);

return API.v1.failure(e.message);
}
},
},
);
}
}
1 change: 1 addition & 0 deletions apps/meteor/app/apps/server/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import './cron';
import './appRequestsCron';

export { Apps, AppEvents } from './orchestrator';
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { HTTP } from 'meteor/http';
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import type { AppRequest, IUser, Pagination } from '@rocket.chat/core-typings';

import { API } from '../../../api/server';
import { getWorkspaceAccessToken } from '../../../cloud/server';
import { sendDirectMessageToUsers } from '../../../../server/lib/sendDirectMessageToUsers';

const ROCKET_CAT_USERID = 'rocket.cat';
const DEFAULT_LIMIT = 100;

const notifyBatchOfUsersError = (error: Error) => {
return new Error(`could not notify the batch of users. Error ${error}`);
};

const notifyBatchOfUsers = async (appName: string, learnMoreUrl: string, appRequests: AppRequest[]): Promise<string[]> => {
const batchRequesters = appRequests.reduce((acc: string[], appRequest: AppRequest) => {
// Prevent duplicate requesters
if (!acc.includes(appRequest.requester.id)) {
acc.push(appRequest.requester.id);
}

return acc;
}, []);

const msgFn = (user: IUser): string => {
const defaultLang = user.language || 'en';
const msg = `${TAPi18n.__('App_request_enduser_message', { appname: appName, learnmore: learnMoreUrl, lng: defaultLang })}`;

return msg;
};

try {
return await sendDirectMessageToUsers(ROCKET_CAT_USERID, batchRequesters, msgFn);
} catch (e) {
throw e;
}
};

export const appRequestNotififyForUsers = async (
marketplaceBaseUrl: string,
workspaceUrl: string,
appId: string,
appName: string,
): Promise<(string | Error)[]> => {
try {
const token = await getWorkspaceAccessToken();
const headers = {
Authorization: `Bearer ${token}`,
};

// First request
const pagination: Pagination = { limit: DEFAULT_LIMIT, offset: 0 };

// First request to get the total and the first batch
const data = HTTP.get(
`${marketplaceBaseUrl}/v1/app-request?appId=${appId}&q=notification-not-sent&limit=${pagination.limit}&offset=${pagination.offset}`,
{ headers },
);

const appRequests = API.v1.success({ data });
const { total } = appRequests.body.data.data.meta;

if (total === undefined || total === 0) {
return [];
}

// Calculate the number of loops - 1 because the first request was already made
const loops = Math.ceil(total / pagination.limit) - 1;
const requestsCollection = [];
const learnMore = `${workspaceUrl}admin/marketplace/all/info/${appId}`;

// Notify first batch
requestsCollection.push(
Promise.resolve(appRequests.body.data.data.data)
.then((response) => notifyBatchOfUsers(appName, learnMore, response))
.catch(notifyBatchOfUsersError),
);

// Batch requests
for (let i = 0; i < loops; i++) {
pagination.offset += pagination.limit;

const request = HTTP.get(
`${marketplaceBaseUrl}/v1/app-request?appId=${appId}&q=notification-not-sent&limit=${pagination.limit}&offset=${pagination.offset}`,
{ headers },
);

requestsCollection.push(notifyBatchOfUsers(appName, learnMore, request.data.data));
}

const finalResult = await Promise.all(requestsCollection);

// Return the list of users that were notified
return finalResult.flat();
} catch (e) {
throw e;
}
};
8 changes: 8 additions & 0 deletions apps/meteor/app/apps/server/orchestrator.js
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,14 @@ export class AppServerOrchestrator {
return this._manager.updateAppsMarketplaceInfo(apps).then(() => this._manager.get());
}

async installedApps(filter = {}) {
if (!this.isLoaded()) {
return;
}

return this._manager.get(filter);
}

async triggerEvent(event, ...payload) {
if (!this.isLoaded()) {
return;
Expand Down
8 changes: 4 additions & 4 deletions apps/meteor/client/views/admin/apps/AppMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,12 @@ function AppMenu({ app, isAppDetailsPage, ...props }) {
}, [setModal]);

const confirmAction = useCallback(
(permissionsGranted) => {
async (permissionsGranted) => {
setModal(null);

marketplaceActions[action]({ ...app, permissionsGranted }).then(() => {
setLoading(false);
});
await marketplaceActions[action]({ ...app, permissionsGranted });

setLoading(false);
},
[setModal, action, app, setLoading],
);
Expand Down
1 change: 1 addition & 0 deletions apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -5118,6 +5118,7 @@
"Version": "Version",
"Version_version": "Version __version__",
"App_version_incompatible_tooltip": "App incompatible with Rocket.Chat version",
"App_request_enduser_message": "The app you requested, __appname__ has just been installed on this workspace. [Click here to learn more](__learnmore__).",
"Video_Conference_Description": "Configure conferencing calls for your workspace.",
"Video_Chat_Window": "Video Chat",
"Video_Conference": "Conference Call",
Expand Down
34 changes: 34 additions & 0 deletions apps/meteor/server/lib/sendDirectMessageToUsers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { IUser } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';

import { SystemLogger } from './logger/system';
import { executeSendMessage } from '../../app/lib/server/methods/sendMessage';
import { createDirectMessage } from '../methods/createDirectMessage';

export async function sendDirectMessageToUsers(
fromId = 'rocket.cat',
toIds: string[],
tassoevan marked this conversation as resolved.
Show resolved Hide resolved
messageFn: (user: IUser) => string,
): Promise<string[]> {
const fromUser = await Users.findOneById(fromId, { projection: { _id: 1 } });
if (!fromUser) {
throw new Error(`User not found: ${fromId}`);
}

const users = Users.findByIds(toIds, { projection: { _id: 1, username: 1, language: 1 } });
const success: string[] = [];

users.forEach((user: IUser) => {
try {
const { rid } = createDirectMessage([user.username], fromId);
const msg = typeof messageFn === 'function' ? messageFn(user) : messageFn;

executeSendMessage(fromId, { rid, msg });
success.push(user._id);
} catch (error) {
SystemLogger.error(error);
}
});

return success;
}
27 changes: 27 additions & 0 deletions packages/core-typings/src/AppRequests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export type AppRequestFilter = 'unseen' | 'seen' | 'notification-sent' | 'notification-not-sent';

export type AppRequestEndUser = {
id: string;
username: string;
name: string;
nickname: string;
emails: string[];
};

export type AppRequest = {
id: string;
appId: string;

requester: AppRequestEndUser;
admins: AppRequestEndUser[];

workspaceId: string;
mesage: string;

seen: boolean;
seenAt: string;
notificationSent: boolean;
notificationSentAt: string;

createdAt: string;
};
17 changes: 17 additions & 0 deletions packages/core-typings/src/MarketplaceRest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export type PaginationMeta = {
total: number;
limit: number;
offset: number;
sort: string;
filter: string;
};

export type Pagination = {
offset: number;
limit: number;
};

export interface IRestResponse<T> {
data: T[];
meta: PaginationMeta;
}
2 changes: 2 additions & 0 deletions packages/core-typings/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export * from './Apps';
export * from './AppOverview';
export * from './FeaturedApps';
export * from './AppRequests';
export * from './MarketplaceRest';
export * from './IRoom';
export * from './UIKit';
export * from './IMessage';
Expand Down
Loading