Skip to content
Open
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
8 changes: 8 additions & 0 deletions .changeset/migrate-thread-read-callers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@rocket.chat/meteor': patch
---

Migrate the thread read-marker client callers from the `readThreads` DDP method to `POST /v1/subscriptions.read` (now with the `tmid` field). The DDP method stays registered on the server for external SDK/mobile clients, with a deprecation log pointing at the REST route until 9.0.0 removes it.

- `ThreadChat.tsx`
- `useThreadMessagesQuery.ts`
6 changes: 6 additions & 0 deletions .changeset/rest-subscriptions-read-tmid.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rocket.chat/rest-typings': minor
'@rocket.chat/meteor': minor
---

`POST /v1/subscriptions.read` now accepts an optional `tmid` field. When provided the server marks the specific thread as read via `readThread({ user, room, tmid })` instead of marking the whole room.
14 changes: 12 additions & 2 deletions apps/meteor/app/api/server/v1/subscriptions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ISubscription } from '@rocket.chat/core-typings';
import { Rooms, Subscriptions } from '@rocket.chat/models';
import { Messages, Rooms, Subscriptions } from '@rocket.chat/models';
import {
ajv,
isSubscriptionsGetProps,
Expand All @@ -14,6 +14,7 @@ import { Meteor } from 'meteor/meteor';
import { readMessages } from '../../../../server/lib/readMessages';
import { getSubscriptions } from '../../../../server/publications/subscription';
import { unreadMessages } from '../../../message-mark-as-unread/server/unreadMessages';
import { readThreadMethod } from '../../../threads/server/functions';
import { API } from '../api';

const subscriptionsGetResponseSchema = ajv.compile<{
Expand Down Expand Up @@ -139,14 +140,23 @@ API.v1.post(
},
},
async function action() {
const { readThreads = false } = this.bodyParams;
const { readThreads = false, tmid } = this.bodyParams;
const roomId = 'rid' in this.bodyParams ? this.bodyParams.rid : this.bodyParams.roomId;

const room = await Rooms.findOneById(roomId);
if (!room) {
throw new Error('error-invalid-subscription');
}

if (tmid) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Use a presence/type check for tmid instead of truthiness. An empty string tmid is currently treated as omitted, so a malformed thread-read request can mark the entire room read.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/meteor/app/api/server/v1/subscriptions.ts, line 151:

<comment>Use a presence/type check for `tmid` instead of truthiness. An empty string `tmid` is currently treated as omitted, so a malformed thread-read request can mark the entire room read.</comment>

<file context>
@@ -139,14 +140,23 @@ API.v1.post(
 			throw new Error('error-invalid-subscription');
 		}
 
+		if (tmid) {
+			const thread = await Messages.findOneById(tmid);
+			if (thread?.rid !== room._id) {
</file context>
Suggested change
if (tmid) {
if (typeof tmid === 'string') {

const thread = await Messages.findOneById(tmid);
if (thread?.rid !== room._id) {
throw new Error('error-invalid-thread');
}
await readThreadMethod({ user: this.user, tmid });
return API.v1.success();
}
Comment on lines +151 to +158

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Branch on presence, not truthiness, for tmid.

With if (tmid), a request body containing tmid: '' skips thread validation and falls through to the room-wide read path. Treat non-null provided values as thread-read attempts so invalid IDs fail as error-invalid-thread.

Proposed fix
-		if (tmid) {
+		if (tmid !== undefined && tmid !== null) {
 			const thread = await Messages.findOneById(tmid);
 			if (thread?.rid !== room._id) {
 				throw new Error('error-invalid-thread');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (tmid) {
const thread = await Messages.findOneById(tmid);
if (thread?.rid !== room._id) {
throw new Error('error-invalid-thread');
}
await readThreadMethod({ user: this.user, tmid });
return API.v1.success();
}
if (tmid !== undefined && tmid !== null) {
const thread = await Messages.findOneById(tmid);
if (thread?.rid !== room._id) {
throw new Error('error-invalid-thread');
}
await readThreadMethod({ user: this.user, tmid });
return API.v1.success();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/meteor/app/api/server/v1/subscriptions.ts` around lines 151 - 158, The
thread-read branch in `subscriptions.ts` is using a truthiness check on `tmid`,
which lets an empty string bypass validation and fall through to the room-wide
read path. Update the `if (tmid)` logic to branch on presence instead of
truthiness so any provided `tmid` value is treated as a thread-read attempt.
Keep the existing `Messages.findOneById(tmid)` and `readThreadMethod({ user:
this.user, tmid })` flow, and ensure invalid or empty IDs still reach the
`error-invalid-thread` case.


await readMessages(room, this.userId, readThreads);

return API.v1.success();
Expand Down
34 changes: 33 additions & 1 deletion apps/meteor/app/threads/server/functions.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings';
import { isEditedMessage } from '@rocket.chat/core-typings';
import { Messages, Subscriptions, NotificationQueue } from '@rocket.chat/models';
import { Messages, Rooms, Subscriptions, NotificationQueue } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';

import { callbacks } from '../../../server/lib/callbacks';
import { canAccessRoomAsync } from '../../authorization/server';
import {
notifyOnSubscriptionChangedByRoomIdAndUserIds,
notifyOnSubscriptionChangedByRoomIdAndUserId,
} from '../../lib/server/lib/notifyListener';
import { getMentions, getUserIdsFromHighlights } from '../../lib/server/lib/notifyUsersOnMessage';
import { settings } from '../../settings/server';

export async function reply({ tmid }: { tmid?: string }, message: IMessage, parentMessage: IMessage, followers: string[]) {
if (!tmid || isEditedMessage(message)) {
Expand Down Expand Up @@ -98,3 +101,32 @@ export const readThread = async ({ user, room, tmid }: { user: IUser; room: IRoo

callbacks.runAsync('afterReadMessages', room, { uid: user._id, tmid });
};

/**
* Marks a thread as read for a user, replicating the full behavior of the
* legacy `readThreads` DDP method: it validates that threads are enabled, that
* the thread and its room exist, that the user can access the room, and runs
* the `beforeReadMessages` callback before clearing the thread unread state.
*/
export const readThreadMethod = async ({ user, tmid }: { user: IUser; tmid: IMessage['_id'] }) => {
if (!settings.get('Threads_enabled')) {
throw new Meteor.Error('error-not-allowed', 'Threads Disabled', { method: 'readThreads' });
}

const thread = await Messages.findOneById(tmid);
if (!thread) {
return;
}

const room = await Rooms.findOneById(thread.rid);
if (!room) {
throw new Meteor.Error('error-room-does-not-exist', 'This room does not exist', { method: 'readThreads' });
}

if (!(await canAccessRoomAsync(room, user))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'readThreads' });
}

await callbacks.run('beforeReadMessages', thread.rid, user._id);
await readThread({ user, room, tmid });
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { IMessage, IThreadMainMessage } from '@rocket.chat/core-typings';
import { isEditedMessage } from '@rocket.chat/core-typings';
import { Box, CheckBox, Field, FieldLabel, FieldRow } from '@rocket.chat/fuselage';
import { clientCallbacks, ContextualbarContent } from '@rocket.chat/ui-client';
import { useMethod, useTranslation, useUserPreference, useRoomToolbox } from '@rocket.chat/ui-contexts';
import { useEndpoint, useTranslation, useUserPreference, useRoomToolbox } from '@rocket.chat/ui-contexts';
import { useState, useEffect, useCallback, useId } from 'react';

import ThreadMessageList from './ThreadMessageList';
Expand Down Expand Up @@ -62,7 +62,7 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps) => {
}, [chat?.messageEditing]);

const room = useRoom();
const readThreads = useMethod('readThreads');
const markThreadRead = useEndpoint('POST', '/v1/subscriptions.read');
useEffect(() => {
clientCallbacks.add(
'streamNewMessage',
Expand All @@ -71,7 +71,7 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps) => {
return;
}

readThreads(mainMessage._id);
void markThreadRead({ rid: room._id, tmid: mainMessage._id });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Handle the dropped endpoint rejection.

This fire-and-forget call can reject, and without a .catch it will surface as an unhandled promise rejection on transient REST failures.

Suggested change
-				void markThreadRead({ rid: room._id, tmid: mainMessage._id });
+				void markThreadRead({ rid: room._id, tmid: mainMessage._id }).catch(() => undefined);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
void markThreadRead({ rid: room._id, tmid: mainMessage._id });
void markThreadRead({ rid: room._id, tmid: mainMessage._id }).catch(() => undefined);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx`
at line 74, The fire-and-forget call to markThreadRead in ThreadChat can reject
and currently has no rejection handling, which may cause an unhandled promise
rejection on transient REST failures. Update the call site in the ThreadChat
component to handle the returned promise explicitly by attaching a catch path or
equivalent error suppression/logging so the rejection is safely consumed while
keeping the non-blocking behavior.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This fire-and-forget call lacks a .catch() handler. On transient REST failures the rejected promise will surface as an unhandled promise rejection, which in strict environments can crash the process or produce noisy console errors.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx, line 74:

<comment>This fire-and-forget call lacks a `.catch()` handler. On transient REST failures the rejected promise will surface as an unhandled promise rejection, which in strict environments can crash the process or produce noisy console errors.</comment>

<file context>
@@ -71,7 +71,7 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps) => {
 				}
 
-				readThreads(mainMessage._id);
+				void markThreadRead({ rid: room._id, tmid: mainMessage._id });
 			},
 			clientCallbacks.priority.MEDIUM,
</file context>
Suggested change
void markThreadRead({ rid: room._id, tmid: mainMessage._id });
void markThreadRead({ rid: room._id, tmid: mainMessage._id }).catch(() => undefined);

},
clientCallbacks.priority.MEDIUM,
`thread-${room._id}`,
Expand All @@ -80,7 +80,7 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps) => {
return () => {
clientCallbacks.remove('streamNewMessage', `thread-${room._id}`);
};
}, [mainMessage._id, readThreads, room._id]);
}, [mainMessage._id, markThreadRead, room._id]);

const subscription = useRoomSubscription();
const sendToChannelID = useId();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { isThreadMessage, type IMessage, type IRoom, type IThreadMainMessage, type IThreadMessage } from '@rocket.chat/core-typings';
import { useMethod, useStream } from '@rocket.chat/ui-contexts';
import { useEndpoint, useStream } from '@rocket.chat/ui-contexts';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';

import { onClientMessageReceived } from '../../../../../lib/onClientMessageReceived';
import { roomsQueryKeys } from '../../../../../lib/queryKeys';
import { mapMessageFromApi } from '../../../../../lib/utils/mapMessageFromApi';
import { modifyMessageOnFilesDelete } from '../../../../../lib/utils/modifyMessageOnFilesDelete';
import {
createDeleteCriteria,
Expand All @@ -24,7 +25,8 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR

const queryClient = useQueryClient();
const queryKey = roomsQueryKeys.threadMessages(roomId, tmid);
const getThreadMessages = useMethod('getThreadMessages');
const getThreadMessages = useEndpoint('GET', '/v1/chat.getThreadMessages');
const markThreadRead = useEndpoint('POST', '/v1/subscriptions.read');

const subscribeToRoomMessages = useStream('room-messages');
const subscribeToNotifyRoom = useStream('notify-room');
Expand Down Expand Up @@ -105,10 +107,11 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR
queryFn: async () => {
const cachedMessages = queryClient.getQueryData<IThreadMessage[]>(queryKey) || [];

const messages = await getThreadMessages({ tmid });
const filtered = messages.filter(
(msg): msg is IThreadMessage => isThreadMessage(msg) && msg.tmid === tmid && msg._id !== tmid && msg._hidden !== true,
);
const { messages } = await getThreadMessages({ tmid });
void markThreadRead({ rid: roomId, tmid }).catch(() => undefined);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Mutation side-effect inside queryFn: markThreadRead POST runs on every refetch, not only on mount. This violates TanStack Query's separation of concerns and causes unnecessary API calls on window refocus, stale cache re-fetch, etc.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts, line 111:

<comment>Mutation side-effect inside queryFn: `markThreadRead` POST runs on every refetch, not only on mount. This violates TanStack Query's separation of concerns and causes unnecessary API calls on window refocus, stale cache re-fetch, etc.</comment>

<file context>
@@ -105,10 +107,11 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR
-				(msg): msg is IThreadMessage => isThreadMessage(msg) && msg.tmid === tmid && msg._id !== tmid && msg._hidden !== true,
-			);
+			const { messages } = await getThreadMessages({ tmid });
+			void markThreadRead({ rid: roomId, tmid }).catch(() => undefined);
+			const filtered = messages
+				.map((m) => mapMessageFromApi(m))
</file context>

const filtered = messages
.map((m) => mapMessageFromApi(m))
.filter((msg): msg is IThreadMessage => isThreadMessage(msg) && msg.tmid === tmid && msg._id !== tmid && msg._hidden !== true);

const sorted = mergeThreadMessages(cachedMessages, filtered);
if (unprocessedReadMessagesEvent.current) {
Expand Down
36 changes: 7 additions & 29 deletions apps/meteor/server/methods/readThreads.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import type { IMessage, IUser } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Messages, Rooms } from '@rocket.chat/models';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';

import { canAccessRoomAsync } from '../../app/authorization/server';
import { settings } from '../../app/settings/server';
import { readThread } from '../../app/threads/server/functions';
import { callbacks } from '../lib/callbacks';
import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger';
import { readThreadMethod } from '../../app/threads/server/functions';

declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
Expand All @@ -18,33 +15,14 @@ declare module '@rocket.chat/ddp-client' {

Meteor.methods<ServerMethods>({
async readThreads(tmid) {
methodDeprecationLogger.method('readThreads', '9.0.0', '/v1/subscriptions.read');
check(tmid, String);

if (!Meteor.userId() || !settings.get('Threads_enabled')) {
throw new Meteor.Error('error-not-allowed', 'Threads Disabled', {
method: 'getThreadMessages',
});
const user = (await Meteor.userAsync()) as IUser | null;
if (!user) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'readThreads' });
}

const thread = await Messages.findOneById(tmid);
if (!thread) {
return;
}

const user = (await Meteor.userAsync()) ?? undefined;

const room = await Rooms.findOneById(thread.rid);
if (!room) {
throw new Meteor.Error('error-room-does-not-exist', 'This room does not exist', { method: 'getThreadMessages' });
}

if (!(await canAccessRoomAsync(room, user))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'getThreadMessages' });
}

await callbacks.run('beforeReadMessages', thread.rid, user?._id);
if (user?._id) {
await readThread({ user: user as IUser, room, tmid });
}
await readThreadMethod({ user, tmid });
},
});
54 changes: 54 additions & 0 deletions apps/meteor/tests/end-to-end/api/subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,60 @@ describe('[Subscriptions]', () => {
expect(res.body.subscription.tunread).to.deep.equal([threadId]);
});
});

it('should mark a single thread as read when a tmid is provided', async () => {
await request
.post(api('chat.sendMessage'))
.set(threadUserCredentials)
.send({
message: {
rid: testChannel._id,
msg: `@${adminUsername} making admin follow this thread`,
tmid: threadId,
},
});

await request
.post(api('subscriptions.read'))
.set(credentials)
.send({
rid: testChannel._id,
tmid: threadId,
})
.expect(200)
.expect((res) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Error-assertion test only checks error property presence but does not validate the specific error type. PR description states the endpoint returns error-invalid-thread for mismatched tmid/rid. Without verifying the errorType, the test cannot distinguish the expected error from an unrelated 400.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/meteor/tests/end-to-end/api/subscriptions.ts, line 418:

<comment>Error-assertion test only checks `error` property presence but does not validate the specific error type. PR description states the endpoint returns `error-invalid-thread` for mismatched tmid/rid. Without verifying the errorType, the test cannot distinguish the expected error from an unrelated 400.</comment>

<file context>
@@ -394,6 +394,60 @@ describe('[Subscriptions]', () => {
+						tmid: threadId,
+					})
+					.expect(200)
+					.expect((res) => {
+						expect(res.body).to.have.property('success', true);
+					});
</file context>

expect(res.body).to.have.property('success', true);
});

await request
.get(api('subscriptions.getOne'))
.set(credentials)
.query({
roomId: testChannel._id,
})
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
// removeUnreadThreadByRoomIdAndUserId only $pulls the tmid, leaving an empty array
expect(res.body.subscription).to.have.property('tunread').that.is.an('array').and.does.not.include(threadId);
Comment on lines +429 to +432

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the API contract, not the empty-array artifact.

This test will fail on a valid implementation that removes tunread entirely once the last unread thread is cleared.

Suggested change
-						// removeUnreadThreadByRoomIdAndUserId only $pulls the tmid, leaving an empty array
-						expect(res.body.subscription).to.have.property('tunread').that.is.an('array').and.does.not.include(threadId);
+						const tunread = res.body.subscription.tunread ?? [];
+						expect(tunread).to.be.an('array');
+						expect(tunread).to.not.include(threadId);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.expect((res) => {
expect(res.body).to.have.property('success', true);
// removeUnreadThreadByRoomIdAndUserId only $pulls the tmid, leaving an empty array
expect(res.body.subscription).to.have.property('tunread').that.is.an('array').and.does.not.include(threadId);
.expect((res) => {
expect(res.body).to.have.property('success', true);
const tunread = res.body.subscription.tunread ?? [];
expect(tunread).to.be.an('array');
expect(tunread).to.not.include(threadId);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/meteor/tests/end-to-end/api/subscriptions.ts` around lines 429 - 432,
The subscription test is asserting an implementation artifact in
`removeUnreadThreadByRoomIdAndUserId` by expecting `tunread` to remain as an
empty array after the last thread is cleared. Update the expectation in the
`subscriptions.ts` end-to-end test to match the API contract instead, using the
`subscription` response shape to assert that the unread-thread state no longer
contains `threadId` and allowing `tunread` to be absent when it is fully
removed.

});
});

it('should fail when the tmid does not belong to the provided room', (done) => {
void request
.post(api('subscriptions.read'))
.set(credentials)
.send({
rid: testGroup._id,
tmid: threadId,
})
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error');
})
.end(done);
});
});
});

Expand Down
12 changes: 11 additions & 1 deletion packages/rest-typings/src/v1/subscriptionsEndpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ type SubscriptionsGet = { updatedSince?: string };

type SubscriptionsGetOne = { roomId: IRoom['_id'] };

type SubscriptionsRead = { rid: IRoom['_id']; readThreads?: boolean } | { roomId: IRoom['_id']; readThreads?: boolean };
type SubscriptionsRead =
| { rid: IRoom['_id']; readThreads?: boolean; tmid?: IMessage['_id'] }
| { roomId: IRoom['_id']; readThreads?: boolean; tmid?: IMessage['_id'] };

type SubscriptionsUnread = { roomId: IRoom['_id'] } | { firstUnreadMessage: Pick<IMessage, '_id'> };

Expand Down Expand Up @@ -49,6 +51,10 @@ const SubscriptionsReadSchema = {
type: 'boolean',
nullable: true,
},
tmid: {
type: 'string',
nullable: true,
},
},
required: ['rid'],
additionalProperties: false,
Expand All @@ -63,6 +69,10 @@ const SubscriptionsReadSchema = {
type: 'boolean',
nullable: true,
},
tmid: {
type: 'string',
nullable: true,
},
},
required: ['roomId'],
additionalProperties: false,
Expand Down
Loading