Skip to content

Commit d98cab1

Browse files
committed
feat: status expiration backend and API
1 parent 423cdbe commit d98cab1

14 files changed

Lines changed: 143 additions & 25 deletions

File tree

apps/meteor/app/api/server/helpers/getUserFromParams.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ export async function getUserFromParams<T extends boolean = false>(
1010
user?: string;
1111
},
1212
full?: T,
13-
): Promise<T extends true ? IUser : Pick<IUser, '_id' | 'username' | 'name' | 'status' | 'statusText' | 'roles'>> {
13+
): Promise<
14+
T extends true ? IUser : Pick<IUser, '_id' | 'username' | 'name' | 'status' | 'statusText' | 'statusSource' | 'statusExpiresAt' | 'roles'>
15+
> {
1416
let user;
1517

16-
const projection = full ? {} : { username: 1, name: 1, status: 1, statusText: 1, roles: 1 };
18+
const projection = full ? {} : { username: 1, name: 1, status: 1, statusText: 1, statusSource: 1, statusExpiresAt: 1, roles: 1 };
1719
if (params.userId?.trim()) {
1820
user = await Users.findOneById(params.userId, { projection });
1921
} else if (params.username?.trim()) {

apps/meteor/app/api/server/v1/users.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -660,7 +660,7 @@ API.v1.addRoute(
660660
if (!canViewFullOtherUserInfo) {
661661
return API.v1.forbidden();
662662
}
663-
const escapedEmail = escapeRegExp(this.queryParams.email as string);
663+
const escapedEmail = escapeRegExp(this.queryParams.email);
664664
nonEmptyQuery['emails.address'] = {
665665
$regex: `^${escapedEmail}$`,
666666
$options: 'i',
@@ -1533,6 +1533,8 @@ API.v1.get(
15331533
status: 1,
15341534
utcOffset: 1,
15351535
statusText: 1,
1536+
statusSource: 1,
1537+
statusExpiresAt: 1,
15361538
avatarETag: 1,
15371539
},
15381540
};
@@ -1931,6 +1933,7 @@ API.v1
19311933
body: ajv.compile<{
19321934
status?: UserStatus;
19331935
message?: string;
1936+
expiresAt?: string;
19341937
userId?: string;
19351938
username?: string;
19361939
user?: string;
@@ -1939,6 +1942,7 @@ API.v1
19391942
properties: {
19401943
status: { type: 'string', enum: ['online', 'away', 'offline', 'busy'] },
19411944
message: { type: 'string', nullable: true },
1945+
expiresAt: { type: 'string', nullable: true },
19421946
userId: { type: 'string' },
19431947
username: { type: 'string' },
19441948
user: { type: 'string' },
@@ -2009,6 +2013,16 @@ API.v1
20092013
statusDefault: status,
20102014
statusSource: 'manual',
20112015
...(this.bodyParams.message != null && { statusText: this.bodyParams.message }),
2016+
...(this.bodyParams.expiresAt &&
2017+
(() => {
2018+
const date = new Date(this.bodyParams.expiresAt);
2019+
if (isNaN(date.getTime())) {
2020+
throw new Meteor.Error('error-invalid-date', 'Invalid expiresAt date string', {
2021+
method: 'users.setStatus',
2022+
});
2023+
}
2024+
return { statusExpiresAt: date };
2025+
})()),
20122026
});
20132027
}
20142028

@@ -2026,12 +2040,14 @@ API.v1
20262040
authRequired: true,
20272041
query: isUsersGetStatusParamsGET,
20282042
response: {
2029-
200: ajv.compile<{ _id: string; status: string; connectionStatus?: string }>({
2043+
200: ajv.compile<{ _id: string; status: string; connectionStatus?: string; statusSource?: string; statusExpiresAt?: string }>({
20302044
type: 'object',
20312045
properties: {
20322046
_id: { type: 'string' },
20332047
status: statusType,
20342048
connectionStatus: { type: 'string', nullable: true },
2049+
statusSource: { type: 'string', nullable: true },
2050+
statusExpiresAt: { type: 'string', nullable: true },
20352051
success: { type: 'boolean', enum: [true] },
20362052
},
20372053
required: ['_id', 'status', 'success'],
@@ -2045,18 +2061,20 @@ API.v1
20452061
if (isUserFromParams(this.queryParams, this.userId, this.user)) {
20462062
return API.v1.success({
20472063
_id: this.userId,
2048-
// message: user.statusText,
20492064
connectionStatus: (this.user.statusConnection || 'offline') as 'online' | 'offline' | 'away' | 'busy',
20502065
status: (this.user.status || 'offline') as 'online' | 'offline' | 'away' | 'busy',
2066+
...(this.user.statusSource && { statusSource: this.user.statusSource }),
2067+
...(this.user.statusExpiresAt && { statusExpiresAt: this.user.statusExpiresAt.toISOString() }),
20512068
});
20522069
}
20532070

20542071
const user = await getUserFromParams(this.queryParams);
20552072

20562073
return API.v1.success({
20572074
_id: user._id,
2058-
// message: user.statusText,
20592075
status: (user.status || 'offline') as 'online' | 'offline' | 'away' | 'busy',
2076+
...(user.statusSource && { statusSource: user.statusSource }),
2077+
...(user.statusExpiresAt && { statusExpiresAt: user.statusExpiresAt.toISOString() }),
20602078
});
20612079
},
20622080
);

apps/meteor/app/lib/server/functions/getFullUserData.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export const defaultFields = {
1818
bio: 1,
1919
reason: 1,
2020
statusText: 1,
21+
statusSource: 1,
22+
statusExpiresAt: 1,
2123
avatarETag: 1,
2224
federated: 1,
2325
statusLivechat: 1,

apps/meteor/app/utils/server/functions/getBaseUserFields.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export const getBaseUserFields = (allowServiceKeys = false): UserFields => ({
1010
'status': 1,
1111
'statusDefault': 1,
1212
'statusText': 1,
13+
'statusSource': 1,
14+
'statusExpiresAt': 1,
1315
'statusConnection': 1,
1416
'bio': 1,
1517
'avatarOrigin': 1,

apps/meteor/server/modules/listeners/listeners.module.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ export class ListenersModule {
157157
});
158158

159159
service.onEvent('presence.status', ({ user }) => {
160-
const { _id, username, name, status, statusText, roles } = user;
160+
const { _id, username, name, status, statusText, statusSource, statusExpiresAt, roles } = user;
161161
if (!status || !username) {
162162
return;
163163
}
@@ -172,16 +172,29 @@ export class ListenersModule {
172172
diff: {
173173
status,
174174
...(statusText && { statusText }),
175+
...(statusSource && { statusSource }),
176+
...(statusExpiresAt && { statusExpiresAt }),
175177
},
176178
unset: {
177179
...(!statusText && { statusText: 1 }),
180+
...(!statusSource && { statusSource: 1 }),
181+
...(!statusExpiresAt && { statusExpiresAt: 1 }),
178182
},
179183
});
180184

181-
notifications.notifyLoggedInThisInstance('user-status', [_id, username, STATUS_MAP[status], statusText, name, roles]);
185+
notifications.notifyLoggedInThisInstance('user-status', [
186+
_id,
187+
username,
188+
STATUS_MAP[status],
189+
statusText,
190+
name,
191+
roles,
192+
statusSource,
193+
statusExpiresAt,
194+
]);
182195

183196
if (_id) {
184-
notifications.sendPresence(_id, username, STATUS_MAP[status], statusText);
197+
notifications.sendPresence(_id, username, STATUS_MAP[status], statusText, statusSource, statusExpiresAt);
185198
}
186199
});
187200

apps/meteor/server/modules/notifications/notifications.module.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Authorization, MediaCall, VideoConf, Settings } from '@rocket.chat/core-services';
2-
import type { ISubscription, IOmnichannelRoom, IUser, IUserDataEvent } from '@rocket.chat/core-typings';
2+
import type { ISubscription, IOmnichannelRoom, IUser, IUserDataEvent, PresenceSource } from '@rocket.chat/core-typings';
33
import type { StreamerCallbackArgs, StreamKeys, StreamNames } from '@rocket.chat/ddp-client';
44
import { Rooms, Subscriptions, Users } from '@rocket.chat/models';
55

@@ -508,7 +508,10 @@ export class NotificationsModule {
508508
return this.streamUser.emitWithoutBroadcast(`${userId}/${eventName}`, ...args);
509509
}
510510

511-
sendPresence(uid: string, ...args: [username: string, status?: 0 | 1 | 2 | 3, statusText?: string]): void {
511+
sendPresence(
512+
uid: string,
513+
...args: [username: string, status?: 0 | 1 | 2 | 3, statusText?: string, statusSource?: PresenceSource, statusExpiresAt?: Date]
514+
): void {
512515
emit(uid, [args]);
513516
return this.streamPresence.emitWithoutBroadcast(uid, args);
514517
}

apps/meteor/tests/end-to-end/api/users.ts

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -132,10 +132,11 @@ const preferences = {
132132

133133
const getUserStatus = (userId: IUser['_id']) =>
134134
new Promise<{
135+
_id: string;
135136
status: 'online' | 'offline' | 'away' | 'busy';
136-
message?: string;
137-
_id?: string;
138137
connectionStatus?: 'online' | 'offline' | 'away' | 'busy';
138+
statusSource?: string;
139+
statusExpiresAt?: string;
139140
}>((resolve) => {
140141
void request
141142
.get(api('users.getStatus'))
@@ -5205,8 +5206,8 @@ describe('[Users]', () => {
52055206
.expect(200)
52065207
.expect((res) => {
52075208
expect(res.body).to.have.property('success', true);
5209+
expect(res.body).to.have.property('_id', credentials['X-User-Id']);
52085210
expect(res.body).to.have.property('status');
5209-
expect(res.body._id).to.be.equal(credentials['X-User-Id']);
52105211
})
52115212
.end(done);
52125213
});
@@ -5219,8 +5220,8 @@ describe('[Users]', () => {
52195220
.expect(200)
52205221
.expect((res) => {
52215222
expect(res.body).to.have.property('success', true);
5223+
expect(res.body).to.have.property('_id', 'rocket.cat');
52225224
expect(res.body).to.have.property('status');
5223-
expect(res.body._id).to.be.equal('rocket.cat');
52245225
})
52255226
.end(done);
52265227
});
@@ -5316,7 +5317,6 @@ describe('[Users]', () => {
53165317
expect(res.body).to.have.property('success', true);
53175318
void getUserStatus(credentials['X-User-Id']).then((status) => {
53185319
expect(status.status).to.be.equal('busy');
5319-
expect(status.message).to.be.equal('test');
53205320
});
53215321
})
53225322
.end(done);
@@ -5380,6 +5380,46 @@ describe('[Users]', () => {
53805380
expect(res.body).to.have.property('status', 'error');
53815381
});
53825382
});
5383+
5384+
it('should set status with expiresAt and return statusSource and statusExpiresAt in getStatus', async () => {
5385+
const expiresAt = new Date(Date.now() + 3600_000).toISOString();
5386+
5387+
await request
5388+
.post(api('users.setStatus'))
5389+
.set(credentials)
5390+
.send({
5391+
status: 'busy',
5392+
message: 'focus time',
5393+
expiresAt,
5394+
})
5395+
.expect('Content-Type', 'application/json')
5396+
.expect(200)
5397+
.expect((res) => {
5398+
expect(res.body).to.have.property('success', true);
5399+
});
5400+
5401+
const status = await getUserStatus(credentials['X-User-Id']);
5402+
// display status is offline because the test user has no active DDP session;
5403+
// the busy claim is persisted in statusDefault and takes effect on reconnect
5404+
expect(status.status).to.be.equal('offline');
5405+
expect(status).to.have.property('statusSource', 'manual');
5406+
expect(status).to.have.property('statusExpiresAt');
5407+
expect(new Date(status.statusExpiresAt!).getTime()).to.be.closeTo(new Date(expiresAt).getTime(), 2000);
5408+
});
5409+
5410+
it('should not return statusExpiresAt when expiresAt is not set', async () => {
5411+
await request
5412+
.post(api('users.setStatus'))
5413+
.set(credentials)
5414+
.send({
5415+
status: 'online',
5416+
})
5417+
.expect(200);
5418+
5419+
const status = await getUserStatus(credentials['X-User-Id']);
5420+
expect(status.status).to.be.equal('online');
5421+
expect(status).to.not.have.property('statusExpiresAt');
5422+
});
53835423
});
53845424

53855425
describe('[/users.removeOtherTokens]', () => {

ee/packages/presence/src/Presence.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ export class Presence extends ServiceClass implements IPresence {
350350
}
351351

352352
private broadcast(
353-
user: Pick<IUser, '_id' | 'username' | 'status' | 'statusText' | 'roles'>,
353+
user: Pick<IUser, '_id' | 'username' | 'status' | 'statusText' | 'statusSource' | 'statusExpiresAt' | 'roles'>,
354354
previousStatus: UserStatus | undefined,
355355
): void {
356356
if (!this.broadcastEnabled) {

packages/core-services/src/events/Events.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ export type EventSignatures = {
159159
};
160160
}): void;
161161
'presence.status'(data: {
162-
user: Pick<IUser, '_id' | 'username' | 'status' | 'statusText' | 'name' | 'roles'>;
162+
user: Pick<IUser, '_id' | 'username' | 'status' | 'statusText' | 'statusSource' | 'statusExpiresAt' | 'name' | 'roles'>;
163163
previousStatus: UserStatus | undefined;
164164
}): void;
165165
'watch.messages'(data: { message: IMessage }): void;

packages/core-typings/src/IMeApiUser.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ type MeProjectedUserFields = Pick<
2828
| 'status'
2929
| 'statusDefault'
3030
| 'statusText'
31+
| 'statusSource'
32+
| 'statusExpiresAt'
3133
| 'statusConnection'
3234
| 'bio'
3335
| 'avatarOrigin'

0 commit comments

Comments
 (0)