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 17 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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
24 changes: 23 additions & 1 deletion apps/meteor/app/apps/client/orchestrator.ts
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
@@ -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
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;
graywolf336 marked this conversation as resolved.
Show resolved Hide resolved
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
@@ -1,3 +1,4 @@
import './cron';
import './appRequestsCron';

export { Apps, AppEvents } from './orchestrator';
@@ -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
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
2 changes: 1 addition & 1 deletion apps/meteor/client/views/admin/apps/AppMenu.js
Expand Up @@ -68,7 +68,7 @@ function AppMenu({ app, isAppDetailsPage, ...props }) {
(permissionsGranted) => {
setModal(null);

marketplaceActions[action]({ ...app, permissionsGranted }).then(() => {
marketplaceActions[action]({ ...app, permissionsGranted }).then(async () => {
tassoevan marked this conversation as resolved.
Show resolved Hide resolved
setLoading(false);
});
},
Expand Down
1 change: 1 addition & 0 deletions apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json
Expand Up @@ -5110,6 +5110,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
@@ -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 = await Users.findByIds(toIds, { projection: { _id: 1, username: 1, language: 1 } }).toArray();
tassoevan marked this conversation as resolved.
Show resolved Hide resolved
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
@@ -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
@@ -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
@@ -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
15 changes: 14 additions & 1 deletion packages/rest-typings/src/apps/index.ts
Expand Up @@ -4,7 +4,16 @@ import type { IExternalComponent } from '@rocket.chat/apps-engine/definition/ext
import type { IPermission } from '@rocket.chat/apps-engine/definition/permissions/IPermission';
import type { ISetting } from '@rocket.chat/apps-engine/definition/settings';
import type { IUIActionButton } from '@rocket.chat/apps-engine/definition/ui';
import type { AppScreenshot, App, FeaturedAppsSection, ILogItem } from '@rocket.chat/core-typings';
import type {
AppScreenshot,
App,
FeaturedAppsSection,
ILogItem,
Pagination,
AppRequestFilter,
IRestResponse,
AppRequest,
} from '@rocket.chat/core-typings';

export type AppsEndpoints = {
'/apps/externalComponents': {
Expand Down Expand Up @@ -119,6 +128,10 @@ export type AppsEndpoints = {
};
};

'/apps/app-request': {
GET: (params: { appId: string; q: AppRequestFilter; sort: string; pagination: Pagination }) => IRestResponse<AppRequest>;
};

'/apps': {
GET:
| ((params: { buildExternalUrl: 'true'; purchaseType?: 'buy' | 'subscription'; appId?: string; details?: 'true' | 'false' }) => {
Expand Down