Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/serious-eggs-type.md
Original file line number Diff line number Diff line change
@@ -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
30 changes: 21 additions & 9 deletions apps/meteor/app/apps/server/bridges/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down
113 changes: 113 additions & 0 deletions apps/meteor/tests/data/apps/app-packages/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,116 @@ export class NestedRequestsApp extends App implements IPostMessageSent {
```

</details>

#### 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 }`.

<details>
<summary>App source code</summary>

**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<void> {
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<IApiResponse> {
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<IApiResponse> {
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 }));
}
}
```

</details>
2 changes: 2 additions & 0 deletions apps/meteor/tests/data/apps/app-packages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Binary file not shown.
87 changes: 87 additions & 0 deletions apps/meteor/tests/end-to-end/apps/update-status-text.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading