diff --git a/.changeset/serious-eggs-type.md b/.changeset/serious-eggs-type.md new file mode 100644 index 0000000000000..1a2758a28fe65 --- /dev/null +++ b/.changeset/serious-eggs-type.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes an issue where the apps-engine updateStatusText method isn't updating the app user status text properly diff --git a/apps/meteor/app/apps/server/bridges/users.ts b/apps/meteor/app/apps/server/bridges/users.ts index 3c27eab902521..43cd7f9ea89e1 100644 --- a/apps/meteor/app/apps/server/bridges/users.ts +++ b/apps/meteor/app/apps/server/bridges/users.ts @@ -9,6 +9,7 @@ import { Random } from '@rocket.chat/random'; import { checkUsernameAvailability } from '../../../lib/server/functions/checkUsernameAvailability'; import { deleteUser } from '../../../lib/server/functions/deleteUser'; import { getUserCreatedByApp } from '../../../lib/server/functions/getUserCreatedByApp'; +import { setStatusText } from '../../../lib/server/functions/setStatusText'; import { setUserActiveStatus } from '../../../lib/server/functions/setUserActiveStatus'; import { setUserAvatar } from '../../../lib/server/functions/setUserAvatar'; import { notifyOnUserChange, notifyOnUserChangeById } from '../../../lib/server/lib/notifyListener'; @@ -127,20 +128,31 @@ export class AppUserBridge extends UserBridge { throw new Error('User not provided'); } - if (!Object.keys(fields).length) { - return true; - } - - const { status } = fields; - delete fields.status; + const { status, statusText, ...updateFields } = fields; if (status) { - await Presence.setStatus(user.id, status as UserStatus, fields.statusText); + await Presence.setStatus(user.id, status as UserStatus, statusText); + } else if (typeof statusText === 'string') { + await setStatusText( + { + _id: user.id, + username: user.username, + name: user.name, + status: user.status as UserStatus, + roles: user.roles, + statusText: user.statusText, + }, + statusText, + ); + } + + if (!Object.keys(updateFields).length) { + return true; } - await Users.updateOne({ _id: user.id }, { $set: fields as any }); + await Users.updateOne({ _id: user.id }, { $set: updateFields as any }); - void notifyOnUserChange({ clientAction: 'updated', id: user.id, diff: fields }); + void notifyOnUserChange({ clientAction: 'updated', id: user.id, diff: updateFields }); return true; } diff --git a/apps/meteor/tests/data/apps/app-packages/README.md b/apps/meteor/tests/data/apps/app-packages/README.md index 0d4caae341d16..bdb3f214bafbe 100644 --- a/apps/meteor/tests/data/apps/app-packages/README.md +++ b/apps/meteor/tests/data/apps/app-packages/README.md @@ -180,3 +180,116 @@ export class NestedRequestsApp extends App implements IPostMessageSent { ``` + +#### Update Status Test + +File name: `update-status-test_0.0.1.zip` + +An app that provides two public API endpoints to test the `updateStatus` and `updateStatusText` bridge methods. A `username` parameter is required to specify the target user. + +**Endpoints:** + +- `POST /update-status` — Calls `updateStatus(user, statusText, status)`. Expects `{ username: string, status: string, statusText?: string }`. +- `POST /update-status-text` — Calls `updateStatusText(user, statusText)`. Expects `{ username: string, statusText: string }`. + +
+App source code + +**UpdateStatusTestApp.ts** +```typescript +import { + IAppAccessors, + IConfigurationExtend, + ILogger, +} from '@rocket.chat/apps-engine/definition/accessors'; +import { ApiSecurity, ApiVisibility } from '@rocket.chat/apps-engine/definition/api'; +import { App } from '@rocket.chat/apps-engine/definition/App'; +import { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; +import { UpdateStatusEndpoint } from './endpoints/UpdateStatusEndpoint'; +import { UpdateStatusTextEndpoint } from './endpoints/UpdateStatusTextEndpoint'; + +export class UpdateStatusTestApp extends App { + constructor(info: IAppInfo, logger: ILogger, accessors: IAppAccessors) { + super(info, logger, accessors); + } + + protected async extendConfiguration(configuration: IConfigurationExtend): Promise { + await configuration.api.provideApi({ + visibility: ApiVisibility.PUBLIC, + security: ApiSecurity.UNSECURE, + endpoints: [ + new UpdateStatusEndpoint(this), + new UpdateStatusTextEndpoint(this), + ], + }); + } +} +``` + +**endpoints/UpdateStatusEndpoint.ts** +```typescript +import { IHttp, IModify, IPersistence, IRead } from '@rocket.chat/apps-engine/definition/accessors'; +import { ApiEndpoint, IApiEndpointInfo, IApiRequest, IApiResponse } from '@rocket.chat/apps-engine/definition/api'; +import { IUser } from '@rocket.chat/apps-engine/definition/users'; + +export class UpdateStatusEndpoint extends ApiEndpoint { + public path = 'update-status'; + + public async post(request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify, http: IHttp, persis: IPersistence): Promise { + const { status, statusText = '', username } = request.content || {}; + + if (!status) { + return { status: 400, content: 'status is required' }; + } + + if (!username) { + return { status: 400, content: 'username is required' }; + } + + const user = await read.getUserReader().getByUsername(username) as IUser; + + if (!user) { + return { status: 404, content: 'User not found' }; + } + + await modify.getUpdater().getUserUpdater().updateStatus(user, statusText, status); + + return this.success(JSON.stringify({ status, statusText })); + } +} +``` + +**endpoints/UpdateStatusTextEndpoint.ts** +```typescript +import { IHttp, IModify, IPersistence, IRead } from '@rocket.chat/apps-engine/definition/accessors'; +import { ApiEndpoint, IApiEndpointInfo, IApiRequest, IApiResponse } from '@rocket.chat/apps-engine/definition/api'; +import { IUser } from '@rocket.chat/apps-engine/definition/users'; + +export class UpdateStatusTextEndpoint extends ApiEndpoint { + public path = 'update-status-text'; + + public async post(request: IApiRequest, endpoint: IApiEndpointInfo, read: IRead, modify: IModify, http: IHttp, persis: IPersistence): Promise { + const { statusText, username } = request.content || {}; + + if (typeof statusText !== 'string') { + return { status: 400, content: 'statusText is required' }; + } + + if (!username) { + return { status: 400, content: 'username is required' }; + } + + const user = await read.getUserReader().getByUsername(username) as IUser; + + if (!user) { + return { status: 404, content: 'User not found' }; + } + + await modify.getUpdater().getUserUpdater().updateStatusText(user, statusText); + + return this.success(JSON.stringify({ statusText })); + } +} +``` + +
diff --git a/apps/meteor/tests/data/apps/app-packages/index.ts b/apps/meteor/tests/data/apps/app-packages/index.ts index 968480262ba6e..4fa2e5d719f76 100644 --- a/apps/meteor/tests/data/apps/app-packages/index.ts +++ b/apps/meteor/tests/data/apps/app-packages/index.ts @@ -5,3 +5,5 @@ export const appImplementsIPreFileUpload = path.resolve(__dirname, './file-uploa export const appAPIParameterTest = path.resolve(__dirname, './api-parameter-test_0.0.1.zip'); export const appCausingNestedRequests = path.resolve(__dirname, './nested-requests_0.0.1.zip'); + +export const appUpdateStatusTest = path.resolve(__dirname, './update-status-test_0.0.1.zip'); diff --git a/apps/meteor/tests/data/apps/app-packages/update-status-test_0.0.1.zip b/apps/meteor/tests/data/apps/app-packages/update-status-test_0.0.1.zip new file mode 100644 index 0000000000000..9eacbf93a375c Binary files /dev/null and b/apps/meteor/tests/data/apps/app-packages/update-status-test_0.0.1.zip differ diff --git a/apps/meteor/tests/end-to-end/apps/update-status-text.ts b/apps/meteor/tests/end-to-end/apps/update-status-text.ts new file mode 100644 index 0000000000000..8b3deece0692b --- /dev/null +++ b/apps/meteor/tests/end-to-end/apps/update-status-text.ts @@ -0,0 +1,87 @@ +import type { App } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, request, credentials } from '../../data/api-data'; +import { appUpdateStatusTest } from '../../data/apps/app-packages'; +import { apps } from '../../data/apps/apps-data'; +import { cleanupApps, installLocalTestPackage } from '../../data/apps/helper'; +import { getUserByUsername } from '../../data/users.helper'; +import { IS_EE } from '../../e2e/config/constants'; + +const APP_USERNAME = 'update-status-test.bot'; + +(IS_EE ? describe : describe.skip)('Apps - Update App User Status', () => { + let app: App; + + before((done) => getCredentials(done)); + + before(async () => { + await cleanupApps(); + app = await installLocalTestPackage(appUpdateStatusTest); + }); + + after(() => cleanupApps()); + + describe('[updateStatusText]', () => { + it('should update the app user statusText', async () => { + const statusText = `test-status-${Date.now()}`; + + await request + .post(apps(`/public/${app.id}/update-status-text`)) + .set(credentials) + .send({ username: APP_USERNAME, statusText }) + .expect(200); + + const appUser = await getUserByUsername(APP_USERNAME); + expect(appUser.statusText).to.be.equal(statusText); + }); + + it('should clear the app user statusText', async () => { + await request + .post(apps(`/public/${app.id}/update-status-text`)) + .set(credentials) + .send({ username: APP_USERNAME, statusText: '' }) + .expect(200); + + const appUser = await getUserByUsername(APP_USERNAME); + expect(appUser.statusText).to.be.equal(''); + }); + }); + + describe('[updateStatus]', () => { + it('should update the app user statusText when status and statusText is provided', async () => { + const statusText = `busy-status-${Date.now()}`; + + await request + .post(apps(`/public/${app.id}/update-status`)) + .set(credentials) + .send({ username: APP_USERNAME, status: 'busy', statusText }) + .expect(200); + + const appUser = await getUserByUsername(APP_USERNAME); + + // We can't test the status value because the Presence service will override it with OFFLINE + // when the user doesn't have an active session/connection + // expect(appUser.status).to.equal(status); + expect(appUser.statusText).to.be.equal(statusText); + }); + + it('should update status without changing statusText', async () => { + const userBefore = await getUserByUsername(APP_USERNAME); + + await request + .post(apps(`/public/${app.id}/update-status`)) + .set(credentials) + .send({ username: APP_USERNAME, status: 'away' }) + .expect(200); + + const appUser = await getUserByUsername(APP_USERNAME); + + // We can't test the status value because the Presence service will override it with OFFLINE + // when the user doesn't have an active session/connection + // expect(appUser.status).to.equal(status); + expect(appUser.statusText).to.be.equal(userBefore.statusText); + }); + }); +});