diff --git a/app/api/server/api.d.ts b/app/api/server/api.d.ts index 94b44c774fe6f..1edac24cae4cb 100644 --- a/app/api/server/api.d.ts +++ b/app/api/server/api.d.ts @@ -1,77 +1,44 @@ -import { Endpoints } from '../../../definition/rest'; -import { Awaited } from '../../../definition/utils'; -import { IUser } from '../../../definition/IUser'; +import type { JoinPathPattern, Method, MethodOf, OperationParams, OperationResult, PathPattern, UrlParams } from '../../../definition/rest'; +import type { IUser } from '../../../definition/IUser'; - -export type ChangeTypeOfKeys< - T extends object, - Keys extends keyof T, - NewType -> = { - [key in keyof T]: key extends Keys ? NewType : T[key] -} - -type This = { - getPaginationItems(): ({ - offset: number; - count: number; - }); - parseJsonQuery(): ({ - sort: Record; - fields: Record; - query: Record; - }); - readonly urlParams: Record; - getUserFromParams(): IUser; -} - -type ThisLoggedIn = { - readonly user: IUser; - readonly userId: string; -} - -type ThisLoggedOut = { - readonly user: null; - readonly userId: null; -} - -type EndpointWithExtraOptions any, A> = WrappedFunction | ({ action: WrappedFunction } & (A extends true ? { twoFactorRequired: boolean } : {})); - -export type Methods = { - [K in keyof T as `${Lowercase}`]: T[K] extends (...args: any) => any ? EndpointWithExtraOptions<(this: This & (A extends true ? ThisLoggedIn : ThisLoggedOut) & Params[0]>) => ReturnType, A> : never; +type SuccessResult = { + statusCode: 200; + body: + T extends object + ? { success: true } & T + : T; }; -type Params = K extends 'GET' ? { readonly queryParams: Partial

} : K extends 'POST' ? { readonly bodyParams: Partial

} : never; - -type SuccessResult = { - statusCode: 200; - success: true; -} & T extends (undefined) ? {} : { body: T } +type FailureResult = { + statusCode: 400; + body: + T extends object + ? { success: false } & T + : ({ + success: false; + error: T; + stack: TStack; + errorType: TErrorType; + details: TErrorDetails; + }) & ( + undefined extends TErrorType + ? {} + : { errorType: TErrorType } + ) & ( + undefined extends TErrorDetails + ? {} + : { details: TErrorDetails extends string ? unknown : TErrorDetails } + ); +}; -type UnauthorizedResult = { +type UnauthorizedResult = { statusCode: 403; body: { success: false; - error: string; + error: T | 'unauthorized'; }; } -type FailureResult = { - statusCode: 400; -} & FailureBody; - -type FailureBody = Exclude extends object ? { body: T & E } : { - body: E & { error: string } & E extends Error ? { details: string } : {} & ST extends undefined ? {} : { stack: string } & ET extends undefined ? {} : { errorType: string }; -} - -type Errors = FailureResult | FailureResult | FailureResult | UnauthorizedResult; - -type WrappedFunction any> = (this: ThisParameterType, ...args: Parameters) => ReturnTypes; - -type ReturnTypes any> = PromisedOrNot> | PromisedOrNot>; - -type PromisedOrNot = Promise | T; - type Options = { permissionsRequired?: string[]; twoFactorOptions?: unknown; @@ -79,32 +46,102 @@ type Options = { authRequired?: boolean; } -export type RestEndpoints

= Methods; - -type ToLowerCaseKeys = { - [K in keyof T as `${Lowercase}`]: T[K]; +type ActionThis = { + urlParams: UrlParams; + // TODO make it unsafe + readonly queryParams: TMethod extends 'GET' ? Partial> : Record; + // TODO make it unsafe + readonly bodyParams: TMethod extends 'GET' ? Record : Partial>; + requestParams(): OperationParams; + getPaginationItems(): { + readonly offset: number; + readonly count: number; + }; + parseJsonQuery(): { + sort: Record; + fields: Record; + query: Record; + }; + getUserFromParams(): IUser; +} & ( + TOptions extends { authRequired: true } + ? { + readonly user: IUser; + readonly userId: string; + } + : { + readonly user: null; + readonly userId: null; + } +); + +export type ResultFor< + TMethod extends Method, + TPathPattern extends PathPattern +> = SuccessResult> | FailureResult | UnauthorizedResult; + +type Action = + ((this: ActionThis) => Promise>) + | ((this: ActionThis) => ResultFor); + +type Operation = Action | { + action: Action; +} & ({ twoFactorRequired: boolean }); + +type Operations = { + [M in MethodOf as Lowercase]: Operation, TPathPattern, TOptions>; }; -type ToResultType = { - [K in keyof T]: T[K] extends (...args: any) => any ? Awaited> : never; -} -export type ResultTypeEndpoints

= ToResultType>; -declare class APIClass { - addRoute

(route: P, endpoints: RestEndpoints

): void; +declare class APIClass { + addRoute< + TSubPathPattern extends string + >(subpath: TSubPathPattern, operations: Operations>): void; + + addRoute< + TSubPathPattern extends string, + TPathPattern extends JoinPathPattern + >(subpaths: TSubPathPattern[], operations: Operations): void; + + addRoute< + TSubPathPattern extends string, + TOptions extends Options + >( + subpath: TSubPathPattern, + options: TOptions, + operations: Operations, TOptions> + ): void; + + addRoute< + TSubPathPattern extends string, + TPathPattern extends JoinPathPattern, + TOptions extends Options + >( + subpaths: TSubPathPattern[], + options: TOptions, + operations: Operations + ): void; - addRoute

(route: P, options: O, endpoints: RestEndpoints): void; + success(result: T): SuccessResult; - unauthorized(msg?: string): UnauthorizedResult; + success(): SuccessResult; - failure(result: string, errorType?: ET, stack?: ST, error?: E): FailureResult; + failure< + T, + TErrorType extends string, + TStack extends string, + TErrorDetails + >( + result: T, + errorType?: TErrorType, + stack?: TStack, + error?: { details: TErrorDetails } + ): FailureResult; - failure(result: object): FailureResult; + failure(result: T): FailureResult; - failure(): FailureResult; + failure(): FailureResult; - success(): SuccessResult; - - success(result: T): SuccessResult; + unauthorized(msg?: T): UnauthorizedResult; defaultFieldsToExclude: { joinCode: 0; @@ -115,6 +152,6 @@ declare class APIClass { } export declare const API: { - v1: APIClass; + v1: APIClass<'/v1'>; default: APIClass; }; diff --git a/app/api/server/v1/banners.ts b/app/api/server/v1/banners.ts index 678d6e726cf24..790dbd138afaa 100644 --- a/app/api/server/v1/banners.ts +++ b/app/api/server/v1/banners.ts @@ -1,4 +1,3 @@ -import { Meteor } from 'meteor/meteor'; import { Match, check } from 'meteor/check'; import { API } from '../api'; @@ -54,20 +53,13 @@ import { BannerPlatform } from '../../../../definition/IBanner'; API.v1.addRoute('banners.getNew', { authRequired: true }, { // deprecated async get() { check(this.queryParams, Match.ObjectIncluding({ - platform: String, + platform: Match.OneOf(...Object.values(BannerPlatform)), bid: Match.Maybe(String), })); const { platform, bid: bannerId } = this.queryParams; - if (!platform) { - throw new Meteor.Error('error-missing-param', 'The required "platform" param is missing.'); - } - if (!Object.values(BannerPlatform).includes(platform)) { - throw new Meteor.Error('error-unknown-platform', 'Platform is unknown.'); - } - - const banners = await Banner.getBannersForUser(this.userId, platform, bannerId); + const banners = await Banner.getBannersForUser(this.userId, platform, bannerId ?? undefined); return API.v1.success({ banners }); }, @@ -122,23 +114,15 @@ API.v1.addRoute('banners.getNew', { authRequired: true }, { // deprecated API.v1.addRoute('banners/:id', { authRequired: true }, { // TODO: move to users/:id/banners async get() { check(this.urlParams, Match.ObjectIncluding({ - id: String, + id: Match.Where((id: unknown): id is string => typeof id === 'string' && Boolean(id.trim())), })); check(this.queryParams, Match.ObjectIncluding({ - platform: String, + platform: Match.OneOf(...Object.values(BannerPlatform)), })); const { platform } = this.queryParams; - - if (!platform) { - throw new Meteor.Error('error-missing-param', 'The required "platform" param is missing.'); - } - const { id } = this.urlParams; - if (!id) { - throw new Meteor.Error('error-missing-param', 'The required "id" param is missing.'); - } const banners = await Banner.getBannersForUser(this.userId, platform, id); @@ -186,17 +170,10 @@ API.v1.addRoute('banners/:id', { authRequired: true }, { // TODO: move to users/ API.v1.addRoute('banners', { authRequired: true }, { async get() { check(this.queryParams, Match.ObjectIncluding({ - platform: String, + platform: Match.OneOf(...Object.values(BannerPlatform)), })); const { platform } = this.queryParams; - if (!platform) { - throw new Meteor.Error('error-missing-param', 'The required "platform" param is missing.'); - } - - if (!Object.values(BannerPlatform).includes(platform)) { - throw new Meteor.Error('error-unknown-platform', 'Platform is unknown.'); - } const banners = await Banner.getBannersForUser(this.userId, platform); @@ -240,15 +217,11 @@ API.v1.addRoute('banners', { authRequired: true }, { API.v1.addRoute('banners.dismiss', { authRequired: true }, { async post() { check(this.bodyParams, Match.ObjectIncluding({ - bannerId: String, + bannerId: Match.Where((id: unknown): id is string => typeof id === 'string' && Boolean(id.trim())), })); const { bannerId } = this.bodyParams; - if (!bannerId || !bannerId.trim()) { - throw new Meteor.Error('error-missing-param', 'The required "bannerId" param is missing.'); - } - await Banner.dismiss(this.userId, bannerId); return API.v1.success(); }, diff --git a/app/api/server/v1/roles.ts b/app/api/server/v1/roles.ts index 95c4b34a5d7f3..ef92c3547c2d5 100644 --- a/app/api/server/v1/roles.ts +++ b/app/api/server/v1/roles.ts @@ -1,4 +1,5 @@ import { Meteor } from 'meteor/meteor'; +import { check, Match } from 'meteor/check'; import { Users } from '../../../models/server'; import { API } from '../api'; @@ -20,11 +21,11 @@ API.v1.addRoute('roles.list', { authRequired: true }, { API.v1.addRoute('roles.sync', { authRequired: true }, { async get() { - const { updatedSince } = this.queryParams; + check(this.queryParams, Match.ObjectIncluding({ + updatedSince: Match.Where((value: unknown): value is string => typeof value === 'string' && !Number.isNaN(Date.parse(value))), + })); - if (isNaN(Date.parse(updatedSince))) { - throw new Meteor.Error('error-updatedSince-param-invalid', 'The "updatedSince" query parameter must be a valid date.'); - } + const { updatedSince } = this.queryParams; return API.v1.success({ roles: { @@ -41,25 +42,23 @@ API.v1.addRoute('roles.create', { authRequired: true }, { throw new Meteor.Error('error-invalid-role-properties', 'The role properties are invalid.'); } - const roleData = { - name: this.bodyParams.name, - scope: this.bodyParams.scope, - description: this.bodyParams.description, - mandatory2fa: this.bodyParams.mandatory2fa, - }; + const { name, scope, description, mandatory2fa } = this.bodyParams; if (!await hasPermissionAsync(Meteor.userId(), 'access-permissions')) { throw new Meteor.Error('error-action-not-allowed', 'Accessing permissions is not allowed'); } - if (await Roles.findOneByIdOrName(roleData.name)) { + if (await Roles.findOneByIdOrName(name)) { throw new Meteor.Error('error-duplicate-role-names-not-allowed', 'Role name already exists'); } - if (['Users', 'Subscriptions'].includes(roleData.scope) === false) { - roleData.scope = 'Users'; - } - const roleId = (await Roles.createWithRandomId(roleData.name, roleData.scope, roleData.description, false, roleData.mandatory2fa)).insertedId; + const roleId = (await Roles.createWithRandomId( + name, + scope && ['Users', 'Subscriptions'].includes(scope) ? scope : 'Users', + description, + false, + mandatory2fa, + )).insertedId; if (settings.get('UI_DisplayRoles')) { api.broadcast('user.roleUpdate', { @@ -236,29 +235,25 @@ API.v1.addRoute('roles.removeUserFromRole', { authRequired: true }, { throw new Meteor.Error('error-invalid-role-properties', 'The role properties are invalid.'); } - const data = { - roleName: bodyParams.roleName, - username: bodyParams.username, - scope: this.bodyParams.scope, - }; + const { roleName, username, scope } = bodyParams; if (!await hasPermissionAsync(this.userId, 'access-permissions')) { throw new Meteor.Error('error-not-allowed', 'Accessing permissions is not allowed'); } - const user = Users.findOneByUsername(data.username); + const user = Users.findOneByUsername(username); if (!user) { throw new Meteor.Error('error-invalid-user', 'There is no user with this username'); } - const role = await Roles.findOneByIdOrName(data.roleName); + const role = await Roles.findOneByIdOrName(roleName); if (!role) { throw new Meteor.Error('error-invalid-roleId', 'This role does not exist'); } - if (!await hasRoleAsync(user._id, role.name, data.scope)) { + if (!await hasRoleAsync(user._id, role.name, scope)) { throw new Meteor.Error('error-user-not-in-role', 'User is not in this role'); } @@ -269,7 +264,7 @@ API.v1.addRoute('roles.removeUserFromRole', { authRequired: true }, { } } - await Roles.removeUserRoles(user._id, [role.name], data.scope); + await Roles.removeUserRoles(user._id, [role.name], scope); if (settings.get('UI_DisplayRoles')) { api.broadcast('user.roleUpdate', { @@ -279,7 +274,7 @@ API.v1.addRoute('roles.removeUserFromRole', { authRequired: true }, { _id: user._id, username: user.username, }, - scope: data.scope, + scope, }); } diff --git a/app/api/server/v1/settings.ts b/app/api/server/v1/settings.ts index b9233b1e09d13..ca0a8ac5178da 100644 --- a/app/api/server/v1/settings.ts +++ b/app/api/server/v1/settings.ts @@ -4,7 +4,7 @@ import _ from 'underscore'; import { Settings } from '../../../models/server/raw'; import { hasPermission } from '../../../authorization/server'; -import { API, ResultTypeEndpoints } from '../api'; +import { API, ResultFor } from '../api'; import { SettingsEvents, settings } from '../../../settings/server'; import { setValue } from '../../../settings/server/raw'; import { ISetting, ISettingColor, isSettingAction, isSettingColor } from '../../../../definition/ISetting'; @@ -126,7 +126,7 @@ API.v1.addRoute('settings/:_id', { authRequired: true }, { }, post: { twoFactorRequired: true, - async action(): Promise['post']> { + async action(): Promise> { if (!hasPermission(this.userId, 'edit-privileged-setting')) { return API.v1.unauthorized(); } diff --git a/app/api/server/v1/teams.ts b/app/api/server/v1/teams.ts index dab3ac1e0a82f..7cdb8120ecddc 100644 --- a/app/api/server/v1/teams.ts +++ b/app/api/server/v1/teams.ts @@ -18,6 +18,7 @@ import { isTeamsAddMembersProps } from '../../../../definition/rest/v1/teams/Tea import { isTeamsDeleteProps } from '../../../../definition/rest/v1/teams/TeamsDeleteProps'; import { isTeamsLeaveProps } from '../../../../definition/rest/v1/teams/TeamsLeaveProps'; import { isTeamsUpdateProps } from '../../../../definition/rest/v1/teams/TeamsUpdateProps'; +import { ITeam, TEAM_TYPE } from '../../../../definition/ITeam'; API.v1.addRoute('teams.list', { authRequired: true }, { async get() { @@ -59,11 +60,16 @@ API.v1.addRoute('teams.create', { authRequired: true }, { if (!hasPermission(this.userId, 'create-team')) { return API.v1.unauthorized(); } - const { name, type, members, room, owner } = this.bodyParams; - if (!name) { - return API.v1.failure('Body param "name" is required'); - } + check(this.bodyParams, Match.ObjectIncluding({ + name: String, + type: Match.OneOf(TEAM_TYPE.PRIVATE, TEAM_TYPE.PUBLIC), + members: Match.Maybe([String]), + room: Match.Maybe(Match.Any), + owner: Match.Maybe(String), + })); + + const { name, type, members, room, owner } = this.bodyParams; const team = await Team.create(this.userId, { team: { @@ -120,15 +126,34 @@ API.v1.addRoute('teams.convertToChannel', { authRequired: true }, { }, }); +const getTeamByIdOrName = async (params: { teamId: string } | { teamName: string }): Promise => { + if ('teamId' in params && params.teamId) { + return Team.getOneById(params.teamId); + } + + if ('teamName' in params && params.teamName) { + return Team.getOneByName(params.teamName); + } + + return null; +}; + API.v1.addRoute('teams.addRooms', { authRequired: true }, { async post() { - const { rooms, teamId, teamName } = this.bodyParams; - - if (!teamId && !teamName) { - return API.v1.failure('missing-teamId-or-teamName'); - } + check(this.bodyParams, Match.OneOf( + Match.ObjectIncluding({ + teamId: String, + }), + Match.ObjectIncluding({ + teamName: String, + }), + )); + + check(this.bodyParams, Match.ObjectIncluding({ + rooms: [String], + })); - const team = await (teamId ? Team.getOneById(teamId) : Team.getOneByName(teamName)); + const team = await getTeamByIdOrName(this.bodyParams); if (!team) { return API.v1.failure('team-does-not-exist'); } @@ -137,6 +162,8 @@ API.v1.addRoute('teams.addRooms', { authRequired: true }, { return API.v1.unauthorized('error-no-permission-team-channel'); } + const { rooms } = this.bodyParams; + const validRooms = await Team.addRooms(this.userId, rooms, team._id); return API.v1.success({ rooms: validRooms }); @@ -148,9 +175,8 @@ API.v1.addRoute('teams.removeRoom', { authRequired: true }, { if (!isTeamsRemoveRoomProps(this.bodyParams)) { return API.v1.failure('body-params-invalid', isTeamsRemoveRoomProps.errors?.map((error) => error.message).join('\n ')); } - const { roomId, teamId, teamName } = this.bodyParams; - const team = await (teamId ? Team.getOneById(teamId) : Team.getOneByName(teamName)); + const team = await getTeamByIdOrName(this.bodyParams); if (!team) { return API.v1.failure('team-does-not-exist'); } @@ -161,6 +187,8 @@ API.v1.addRoute('teams.removeRoom', { authRequired: true }, { const canRemoveAny = !!hasPermission(this.userId, 'view-all-team-channels', team.roomId); + const { roomId } = this.bodyParams; + const room = await Team.removeRoom(this.userId, roomId, team._id, canRemoveAny); return API.v1.success({ room }); @@ -169,6 +197,11 @@ API.v1.addRoute('teams.removeRoom', { authRequired: true }, { API.v1.addRoute('teams.updateRoom', { authRequired: true }, { async post() { + check(this.bodyParams, Match.ObjectIncluding({ + roomId: String, + isDefault: Boolean, + })); + const { roomId, isDefault } = this.bodyParams; const team = await Team.getOneByRoomId(roomId); @@ -189,15 +222,29 @@ API.v1.addRoute('teams.updateRoom', { authRequired: true }, { API.v1.addRoute('teams.listRooms', { authRequired: true }, { async get() { - const { teamId, teamName, filter, type } = this.queryParams; + check(this.queryParams, Match.OneOf( + Match.ObjectIncluding({ + teamId: String, + }), + Match.ObjectIncluding({ + teamName: String, + }), + )); + + check(this.queryParams, Match.ObjectIncluding({ + filter: Match.Maybe(String), + type: Match.Maybe(String), + })); + + const { filter, type } = this.queryParams; const { offset, count } = this.getPaginationItems(); - const team = await (teamId ? Team.getOneById(teamId) : Team.getOneByName(teamName)); + const team = await getTeamByIdOrName(this.queryParams); if (!team) { return API.v1.failure('team-does-not-exist'); } - const allowPrivateTeam = hasPermission(this.userId, 'view-all-teams', team.roomId); + const allowPrivateTeam: boolean = hasPermission(this.userId, 'view-all-teams', team.roomId); let getAllRooms = false; if (hasPermission(this.userId, 'view-all-team-channels', team.roomId)) { @@ -205,7 +252,7 @@ API.v1.addRoute('teams.listRooms', { authRequired: true }, { } const listFilter = { - name: filter, + name: filter ?? undefined, isDefault: type === 'autoJoin', getAllRooms, allowPrivateTeam, @@ -224,27 +271,36 @@ API.v1.addRoute('teams.listRooms', { authRequired: true }, { API.v1.addRoute('teams.listRoomsOfUser', { authRequired: true }, { async get() { - const { offset, count } = this.getPaginationItems(); - const { teamId, teamName, userId, canUserDelete = false } = this.queryParams; - + check(this.queryParams, Match.OneOf( + Match.ObjectIncluding({ + teamId: String, + }), + Match.ObjectIncluding({ + teamName: String, + }), + )); - if (!teamId && !teamName) { - return API.v1.failure('missing-teamId-or-teamName'); - } + check(this.queryParams, Match.ObjectIncluding({ + userId: String, + canUserDelete: Match.Maybe(Boolean), + })); - const team = await (teamId ? Team.getOneById(teamId) : Team.getOneByName(teamName!)); + const { offset, count } = this.getPaginationItems(); + const team = await getTeamByIdOrName(this.queryParams); if (!team) { return API.v1.failure('team-does-not-exist'); } const allowPrivateTeam = hasPermission(this.userId, 'view-all-teams', team.roomId); + const { userId, canUserDelete } = this.queryParams; + if (!(this.userId === userId || hasPermission(this.userId, 'view-all-team-channels', team.roomId))) { return API.v1.unauthorized(); } - const { records, total } = await Team.listRoomsOfUser(this.userId, team._id, userId, allowPrivateTeam, canUserDelete, { offset, count }); + const { records, total } = await Team.listRoomsOfUser(this.userId, team._id, userId, allowPrivateTeam, canUserDelete ?? false, { offset, count }); return API.v1.success({ rooms: records, @@ -259,23 +315,28 @@ API.v1.addRoute('teams.members', { authRequired: true }, { async get() { const { offset, count } = this.getPaginationItems(); + check(this.queryParams, Match.OneOf( + Match.ObjectIncluding({ + teamId: String, + }), + Match.ObjectIncluding({ + teamName: String, + }), + )); + check(this.queryParams, Match.ObjectIncluding({ - teamId: Match.Maybe(String), - teamName: Match.Maybe(String), status: Match.Maybe([String]), username: Match.Maybe(String), name: Match.Maybe(String), })); - const { teamId, teamName, status, username, name } = this.queryParams; - if (!teamId && !teamName) { - return API.v1.failure('missing-teamId-or-teamName'); - } + const { status, username, name } = this.queryParams; - const team = await (teamId ? Team.getOneById(teamId) : Team.getOneByName(teamName)); + const team = await getTeamByIdOrName(this.queryParams); if (!team) { return API.v1.failure('team-does-not-exist'); } + const canSeeAllMembers = hasPermission(this.userId, 'view-all-teams', team.roomId); const query = { @@ -431,16 +492,16 @@ API.v1.addRoute('teams.leave', { authRequired: true }, { API.v1.addRoute('teams.info', { authRequired: true }, { async get() { - const { teamId, teamName } = this.queryParams; - - if (!teamId && !teamName) { - return API.v1.failure('Provide either the "teamId" or "teamName"'); - } - - const teamInfo = await (teamId - ? Team.getInfoById(teamId) - : Team.getInfoByName(teamName)); - + check(this.queryParams, Match.OneOf( + Match.ObjectIncluding({ + teamId: String, + }), + Match.ObjectIncluding({ + teamName: String, + }), + )); + + const teamInfo = await getTeamByIdOrName(this.queryParams); if (!teamInfo) { return API.v1.failure('Team not found'); } @@ -498,6 +559,10 @@ API.v1.addRoute('teams.delete', { authRequired: true }, { API.v1.addRoute('teams.autocomplete', { authRequired: true }, { async get() { + check(this.queryParams, Match.ObjectIncluding({ + name: String, + })); + const { name } = this.queryParams; const teams = await Team.autocomplete(this.userId, name); diff --git a/app/models/server/raw/Sessions.ts b/app/models/server/raw/Sessions.ts index d79fd32c89133..dbd6080f18b6f 100644 --- a/app/models/server/raw/Sessions.ts +++ b/app/models/server/raw/Sessions.ts @@ -1,14 +1,14 @@ import { AggregationCursor, BulkWriteOperation, BulkWriteOpResultObject, Collection, IndexSpecification, UpdateWriteOpResult, FilterQuery } from 'mongodb'; -import { ISession as T } from '../../../../definition/ISession'; +import { ISession } from '../../../../definition/ISession'; import { BaseRaw, ModelOptionalId } from './BaseRaw'; type DestructuredDate = {year: number; month: number; day: number}; type DestructuredDateWithType = {year: number; month: number; day: number; type?: 'month' | 'week'}; type DestructuredRange = {start: DestructuredDate; end: DestructuredDate}; -type FullReturn = { year: number; month: number; day: number; data: T[] }; +type FullReturn = { year: number; month: number; day: number; data: ISession[] }; -const matchBasedOnDate = (start: DestructuredDate, end: DestructuredDate): FilterQuery => { +const matchBasedOnDate = (start: DestructuredDate, end: DestructuredDate): FilterQuery => { if (start.year === end.year && start.month === end.month) { return { year: start.year, @@ -112,16 +112,16 @@ const getProjectionByFullDate = (): { day: string; month: string; year: string } }); export const aggregates = { - dailySessionsOfYesterday(collection: Collection, { year, month, day }: DestructuredDate): AggregationCursor & { + dailySessionsOfYesterday(collection: Collection, { year, month, day }: DestructuredDate): AggregationCursor & { time: number; sessions: number; - devices: T['device'][]; + devices: ISession['device'][]; _computedAt: string; }> { - return collection.aggregate & { + return collection.aggregate & { time: number; sessions: number; - devices: T['device'][]; + devices: ISession['device'][]; _computedAt: string; }>([{ $match: { @@ -211,7 +211,7 @@ export const aggregates = { }], { allowDiskUse: true }); }, - async getUniqueUsersOfYesterday(collection: Collection, { year, month, day }: DestructuredDate): Promise { + async getUniqueUsersOfYesterday(collection: Collection, { year, month, day }: DestructuredDate): Promise { return collection.aggregate([{ $match: { year, @@ -273,7 +273,7 @@ export const aggregates = { }]).toArray(); }, - async getUniqueUsersOfLastMonthOrWeek(collection: Collection, { year, month, day, type = 'month' }: DestructuredDateWithType): Promise { + async getUniqueUsersOfLastMonthOrWeek(collection: Collection, { year, month, day, type = 'month' }: DestructuredDateWithType): Promise { return collection.aggregate([{ $match: { type: 'user_daily', @@ -343,7 +343,7 @@ export const aggregates = { }], { allowDiskUse: true }).toArray(); }, - getMatchOfLastMonthOrWeek({ year, month, day, type = 'month' }: DestructuredDateWithType): FilterQuery { + getMatchOfLastMonthOrWeek({ year, month, day, type = 'month' }: DestructuredDateWithType): FilterQuery { let startOfPeriod; if (type === 'month') { @@ -418,7 +418,7 @@ export const aggregates = { }; }, - async getUniqueDevicesOfLastMonthOrWeek(collection: Collection, { year, month, day, type = 'month' }: DestructuredDateWithType): Promise { + async getUniqueDevicesOfLastMonthOrWeek(collection: Collection, { year, month, day, type = 'month' }: DestructuredDateWithType): Promise { return collection.aggregate([{ $match: { type: 'user_daily', @@ -456,7 +456,7 @@ export const aggregates = { }], { allowDiskUse: true }).toArray(); }, - getUniqueDevicesOfYesterday(collection: Collection, { year, month, day }: DestructuredDate): Promise { + getUniqueDevicesOfYesterday(collection: Collection, { year, month, day }: DestructuredDate): Promise { return collection.aggregate([{ $match: { year, @@ -496,7 +496,7 @@ export const aggregates = { }]).toArray(); }, - getUniqueOSOfLastMonthOrWeek(collection: Collection, { year, month, day, type = 'month' }: DestructuredDateWithType): Promise { + getUniqueOSOfLastMonthOrWeek(collection: Collection, { year, month, day, type = 'month' }: DestructuredDateWithType): Promise { return collection.aggregate([{ $match: { type: 'user_daily', @@ -535,7 +535,7 @@ export const aggregates = { }], { allowDiskUse: true }).toArray(); }, - getUniqueOSOfYesterday(collection: Collection, { year, month, day }: DestructuredDate): Promise { + getUniqueOSOfYesterday(collection: Collection, { year, month, day }: DestructuredDate): Promise { return collection.aggregate([{ $match: { year, @@ -577,7 +577,7 @@ export const aggregates = { }, }; -export class SessionsRaw extends BaseRaw { +export class SessionsRaw extends BaseRaw { protected indexes: IndexSpecification[] = [ { key: { instanceId: 1, sessionId: 1, year: 1, month: 1, day: 1 } }, { key: { instanceId: 1, sessionId: 1, userId: 1 } }, @@ -590,19 +590,19 @@ export class SessionsRaw extends BaseRaw { { key: { _computedAt: 1 }, expireAfterSeconds: 60 * 60 * 24 * 45 }, ] - private secondaryCollection: Collection; + private secondaryCollection: Collection; constructor( - public readonly col: Collection, - public readonly colSecondary: Collection, - public readonly trash?: Collection, + public readonly col: Collection, + public readonly colSecondary: Collection, + public readonly trash?: Collection, ) { super(col, trash); this.secondaryCollection = colSecondary; } - async getActiveUsersBetweenDates({ start, end }: DestructuredRange): Promise { + async getActiveUsersBetweenDates({ start, end }: DestructuredRange): Promise { return this.col.aggregate([ { $match: { @@ -618,7 +618,7 @@ export class SessionsRaw extends BaseRaw { ]).toArray(); } - async findLastLoginByIp(ip: string): Promise { + async findLastLoginByIp(ip: string): Promise { return this.findOne({ ip, }, { @@ -627,7 +627,7 @@ export class SessionsRaw extends BaseRaw { }); } - async getActiveUsersOfPeriodByDayBetweenDates({ start, end }: DestructuredRange): Promise { + async getActiveUsersOfPeriodByDayBetweenDates({ start, end }: DestructuredRange): Promise { return this.col.aggregate([ { $match: { @@ -675,7 +675,7 @@ export class SessionsRaw extends BaseRaw { ]).toArray(); } - async getBusiestTimeWithinHoursPeriod({ start, end, groupSize }: DestructuredRange & {groupSize: number}): Promise { + async getBusiestTimeWithinHoursPeriod({ start, end, groupSize }: DestructuredRange & {groupSize: number}): Promise { const match = { $match: { type: 'computed-session', @@ -709,7 +709,7 @@ export class SessionsRaw extends BaseRaw { return this.col.aggregate([match, rangeProject, unwind, groups.listGroup, groups.countGroup, presentationProject, sort]).toArray(); } - async getTotalOfSessionsByDayBetweenDates({ start, end }: DestructuredRange): Promise { + async getTotalOfSessionsByDayBetweenDates({ start, end }: DestructuredRange): Promise { return this.col.aggregate([ { $match: { @@ -739,7 +739,7 @@ export class SessionsRaw extends BaseRaw { ]).toArray(); } - async getTotalOfSessionByHourAndDayBetweenDates({ start, end }: DestructuredRange): Promise { + async getTotalOfSessionByHourAndDayBetweenDates({ start, end }: DestructuredRange): Promise { const match = { $match: { type: 'computed-session', @@ -922,7 +922,7 @@ export class SessionsRaw extends BaseRaw { }; } - async createOrUpdate(data: T): Promise { + async createOrUpdate(data: ISession): Promise { const { year, month, day, sessionId, instanceId } = data; if (!year || !month || !day || !sessionId || !instanceId) { @@ -992,12 +992,12 @@ export class SessionsRaw extends BaseRaw { return this.updateMany(query, update); } - async createBatch(sessions: ModelOptionalId[]): Promise { + async createBatch(sessions: ModelOptionalId[]): Promise { if (!sessions || sessions.length === 0) { return; } - const ops: BulkWriteOperation[] = []; + const ops: BulkWriteOperation[] = []; sessions.forEach((doc) => { const { year, month, day, sessionId, instanceId } = doc; delete doc._id; diff --git a/app/utils/client/lib/RestApiClient.d.ts b/app/utils/client/lib/RestApiClient.d.ts new file mode 100644 index 0000000000000..452fb2a9c13d7 --- /dev/null +++ b/app/utils/client/lib/RestApiClient.d.ts @@ -0,0 +1,43 @@ +import { Serialized } from '../../../../definition/Serialized'; + +export declare const APIClient: { + delete(endpoint: string, params?: Serialized

): Promise>; + get(endpoint: string, params?: Serialized

): Promise>; + post(endpoint: string, params?: Serialized

, body?: B): Promise>; + upload( + endpoint: string, + params?: Serialized

, + formData?: B, + xhrOptions?: { + progress: (amount: number) => void; + error: (ev: ProgressEvent) => void; + } + ): { promise: Promise> }; + getCredentials(): { + 'X-User-Id': string; + 'X-Auth-Token': string; + }; + _jqueryCall( + method?: string, + endpoint?: string, + params?: any, + body?: any, + headers?: Record, + dataType?: string + ): any; + v1: { + delete(endpoint: string, params?: Serialized

): Promise>; + get(endpoint: string, params?: Serialized

): Promise>; + post(endpoint: string, params?: Serialized

, body?: B): Promise>; + upload( + endpoint: string, + params?: Serialized

, + formData?: B, + xhrOptions?: { + progress: (amount: number) => void; + error: (ev: ProgressEvent) => void; + } + ): { promise: Promise> }; + }; +}; diff --git a/app/utils/client/lib/RestApiClient.js b/app/utils/client/lib/RestApiClient.js index 9d857af0d580d..bcec20f9df76c 100644 --- a/app/utils/client/lib/RestApiClient.js +++ b/app/utils/client/lib/RestApiClient.js @@ -1,5 +1,6 @@ import { Meteor } from 'meteor/meteor'; import { Accounts } from 'meteor/accounts-base'; +import jQuery from 'jquery'; import { process2faReturn } from '../../../../client/lib/2fa/process2faReturn'; import { baseURI } from '../../../../client/lib/baseURI'; diff --git a/client/contexts/ServerContext/ServerContext.ts b/client/contexts/ServerContext/ServerContext.ts index 15b8c50f97f51..838500ef1864f 100644 --- a/client/contexts/ServerContext/ServerContext.ts +++ b/client/contexts/ServerContext/ServerContext.ts @@ -1,8 +1,15 @@ import { createContext, useCallback, useContext, useMemo } from 'react'; -import { IServerInfo } from '../../../definition/IServerInfo'; +import type { IServerInfo } from '../../../definition/IServerInfo'; import type { Serialized } from '../../../definition/Serialized'; -import type { PathFor, Params, Return, Method } from '../../../definition/rest'; +import type { + Method, + PathFor, + OperationParams, + MatchPathPattern, + OperationResult, + PathPattern, +} from '../../../definition/rest'; import { ServerMethodFunction, ServerMethodName, @@ -18,11 +25,11 @@ type ServerContextValue = { methodName: MethodName, ...args: ServerMethodParameters ) => Promise>; - callEndpoint: >( - method: M, - path: P, - params: Params[0], - ) => Promise>>; + callEndpoint: >( + method: TMethod, + path: TPath, + params: Serialized>>, + ) => Promise>>>; uploadToEndpoint: (endpoint: string, params: any, formData: any) => Promise; getStream: ( streamName: string, @@ -70,10 +77,16 @@ export const useMethod = ( ); }; -export const useEndpoint = >( - method: M, - path: P, -): ((params: Params[0]) => Promise>>) => { +type EndpointFunction = ( + params: void extends OperationParams + ? void + : Serialized>, +) => Promise>>; + +export const useEndpoint = >( + method: TMethod, + path: TPath, +): EndpointFunction> => { const { callEndpoint } = useContext(ServerContext); return useCallback((params) => callEndpoint(method, path, params), [callEndpoint, path, method]); diff --git a/client/hooks/useEndpointAction.ts b/client/hooks/useEndpointAction.ts index 0deb6cdd7d7c4..62990514185ca 100644 --- a/client/hooks/useEndpointAction.ts +++ b/client/hooks/useEndpointAction.ts @@ -1,16 +1,24 @@ import { useCallback } from 'react'; import { Serialized } from '../../definition/Serialized'; -import { Method, Params, PathFor, Return } from '../../definition/rest'; +import { + MatchPathPattern, + Method, + OperationParams, + OperationResult, + PathFor, +} from '../../definition/rest'; import { useEndpoint } from '../contexts/ServerContext'; import { useToastMessageDispatch } from '../contexts/ToastMessagesContext'; -export const useEndpointAction = >( - method: M, - path: P, - params: Params[0] = {}, +export const useEndpointAction = >( + method: TMethod, + path: TPath, + params: Serialized>> = {} as Serialized< + OperationParams> + >, successMessage?: string, -): ((extraParams?: Params[1]) => Promise>>) => { +): (() => Promise>>>) => { const sendData = useEndpoint(method, path); const dispatchToastMessage = useToastMessageDispatch(); diff --git a/client/hooks/useEndpointActionExperimental.ts b/client/hooks/useEndpointActionExperimental.ts index 0e82f6d428306..1b5420acae277 100644 --- a/client/hooks/useEndpointActionExperimental.ts +++ b/client/hooks/useEndpointActionExperimental.ts @@ -1,15 +1,26 @@ import { useCallback } from 'react'; import { Serialized } from '../../definition/Serialized'; -import { Method, Params, PathFor, Return } from '../../definition/rest'; +import { + MatchPathPattern, + Method, + OperationParams, + OperationResult, + PathFor, +} from '../../definition/rest'; import { useEndpoint } from '../contexts/ServerContext'; import { useToastMessageDispatch } from '../contexts/ToastMessagesContext'; -export const useEndpointActionExperimental = >( - method: M, - path: P, +export const useEndpointActionExperimental = < + TMethod extends Method, + TPath extends PathFor, +>( + method: TMethod, + path: TPath, successMessage?: string, -): ((params: Params[0]) => Promise>>) => { +): (( + params: Serialized>>, +) => Promise>>>) => { const sendData = useEndpoint(method, path); const dispatchToastMessage = useToastMessageDispatch(); diff --git a/client/hooks/useEndpointData.ts b/client/hooks/useEndpointData.ts index 1e8f5ebc880cd..38469217d1723 100644 --- a/client/hooks/useEndpointData.ts +++ b/client/hooks/useEndpointData.ts @@ -1,18 +1,26 @@ import { useCallback, useEffect } from 'react'; import { Serialized } from '../../definition/Serialized'; -import { Params, PathFor, Return } from '../../definition/rest'; +import { MatchPathPattern, OperationParams, OperationResult, PathFor } from '../../definition/rest'; import { useEndpoint } from '../contexts/ServerContext'; import { useToastMessageDispatch } from '../contexts/ToastMessagesContext'; import { AsyncState, useAsyncState } from './useAsyncState'; -const defaultParams = {}; - -export const useEndpointData =

>( - endpoint: P, - params: Params<'GET', P>[0] = defaultParams as Params<'GET', P>[0], - initialValue?: Serialized> | (() => Serialized>), -): AsyncState>> & { reload: () => void } => { +export const useEndpointData = >( + endpoint: TPath, + params: void extends OperationParams<'GET', MatchPathPattern> + ? void + : Serialized< + OperationParams<'GET', MatchPathPattern> + > = undefined as void extends OperationParams<'GET', MatchPathPattern> + ? void + : Serialized>>, + initialValue?: + | Serialized>> + | (() => Serialized>>), +): AsyncState>>> & { + reload: () => void; +} => { const { resolve, reject, reset, ...state } = useAsyncState(initialValue); const dispatchToastMessage = useToastMessageDispatch(); const getData = useEndpoint('GET', endpoint); diff --git a/client/lib/userData.ts b/client/lib/userData.ts index 2b37d1957f5a1..76488ead80137 100644 --- a/client/lib/userData.ts +++ b/client/lib/userData.ts @@ -5,14 +5,39 @@ import { Users } from '../../app/models/client'; import { Notifications } from '../../app/notifications/client'; import { APIClient } from '../../app/utils/client'; import type { IUser, IUserDataEvent } from '../../definition/IUser'; +import { Serialized } from '../../definition/Serialized'; export const isSyncReady = new ReactiveVar(false); -type RawUserData = Omit & { - _updatedAt: string; -}; +type RawUserData = Serialized< + Pick< + IUser, + | '_id' + | 'type' + | 'name' + | 'username' + | 'emails' + | 'status' + | 'statusDefault' + | 'statusText' + | 'statusConnection' + | 'avatarOrigin' + | 'utcOffset' + | 'language' + | 'settings' + | 'roles' + | 'active' + | 'defaultRoom' + | 'customFields' + | 'statusLivechat' + | 'oauth' + | 'createdAt' + | '_updatedAt' + | 'avatarETag' + > +>; -const updateUser = (userData: IUser & { _updatedAt: Date }): void => { +const updateUser = (userData: IUser): void => { const user: IUser = Users.findOne({ _id: userData._id }); if (!user || !user._updatedAt || user._updatedAt.getTime() < userData._updatedAt.getTime()) { @@ -57,6 +82,7 @@ export const synchronizeUserData = async (uid: Meteor.User['_id']): Promise( }); }); -const callEndpoint = >( - method: M, - path: P, - params: Params[0], -): Promise>> => { +const callEndpoint = >( + method: TMethod, + path: TPath, + params: Serialized>>, +): Promise>>> => { const api = path[0] === '/' ? APIClient : APIClient.v1; const endpointPath = path[0] === '/' ? path.slice(1) : path; diff --git a/client/startup/banners.ts b/client/startup/banners.ts index 57e0d9c7daa9e..64ba23041accc 100644 --- a/client/startup/banners.ts +++ b/client/startup/banners.ts @@ -4,14 +4,15 @@ import { Tracker } from 'meteor/tracker'; import { Notifications } from '../../app/notifications/client'; import { APIClient } from '../../app/utils/client'; import { IBanner, BannerPlatform } from '../../definition/IBanner'; +import { Serialized } from '../../definition/Serialized'; import * as banners from '../lib/banners'; const fetchInitialBanners = async (): Promise => { - const response = (await APIClient.get('v1/banners', { - platform: BannerPlatform.Web, - })) as { + const response: Serialized<{ banners: IBanner[]; - }; + }> = await APIClient.get('v1/banners', { + platform: BannerPlatform.Web, + }); for (const banner of response.banners) { banners.open({ @@ -22,11 +23,11 @@ const fetchInitialBanners = async (): Promise => { }; const handleBanner = async (event: { bannerId: string }): Promise => { - const response = (await APIClient.get(`v1/banners/${event.bannerId}`, { - platform: BannerPlatform.Web, - })) as { + const response: Serialized<{ banners: IBanner[]; - }; + }> = await APIClient.get(`v1/banners/${event.bannerId}`, { + platform: BannerPlatform.Web, + }); if (!response.banners.length) { return banners.closeById(event.bannerId); diff --git a/client/views/admin/settings/groups/LDAPGroupPage.tsx b/client/views/admin/settings/groups/LDAPGroupPage.tsx index 94a4b59273a15..f466c56d3d17c 100644 --- a/client/views/admin/settings/groups/LDAPGroupPage.tsx +++ b/client/views/admin/settings/groups/LDAPGroupPage.tsx @@ -39,7 +39,7 @@ function LDAPGroupPage({ _id, ...group }: ISetting): JSX.Element { const handleTestConnectionButtonClick = async (): Promise => { try { - const { message } = await testConnection(undefined); + const { message } = await testConnection(); dispatchToastMessage({ type: 'success', message: t(message) }); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); @@ -48,12 +48,12 @@ function LDAPGroupPage({ _id, ...group }: ISetting): JSX.Element { const handleSyncNowButtonClick = async (): Promise => { try { - await testConnection(undefined); + await testConnection(); const confirmSync = async (): Promise => { closeModal(); try { - const { message } = await syncNow(undefined); + const { message } = await syncNow(); dispatchToastMessage({ type: 'success', message: t(message) }); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); @@ -79,7 +79,7 @@ function LDAPGroupPage({ _id, ...group }: ISetting): JSX.Element { const handleSearchTestButtonClick = async (): Promise => { try { - await testConnection(undefined); + await testConnection(); let username = ''; const handleChangeUsername = (event: FormEvent): void => { username = event.currentTarget.value; diff --git a/client/views/hooks/useDepartmentsByUnitsList.ts b/client/views/hooks/useDepartmentsByUnitsList.ts index e2a1de9352a96..7211afe283fc9 100644 --- a/client/views/hooks/useDepartmentsByUnitsList.ts +++ b/client/views/hooks/useDepartmentsByUnitsList.ts @@ -21,9 +21,7 @@ export const useDepartmentsByUnitsList = ( } => { const [itemsList, setItemsList] = useState(() => new RecordList()); const reload = useCallback(() => setItemsList(new RecordList()), []); - const endpoint = `livechat/departments.available-by-unit/${ - options.unitId || 'none' - }` as 'livechat/departments.by-unit/'; + const endpoint = `livechat/departments.available-by-unit/${options.unitId || 'none'}` as const; const getDepartments = useEndpoint('GET', endpoint); diff --git a/definition/Serialized.ts b/definition/Serialized.ts index 2871f401689e3..a6626dcdb9b84 100644 --- a/definition/Serialized.ts +++ b/definition/Serialized.ts @@ -1,9 +1,10 @@ -export type Serialized = T extends Date - ? (Exclude | string) - : T extends boolean | number | string | null | undefined - ? T - : T extends {} - ? { - [K in keyof T]: Serialized; - } - : null; +export type Serialized = + T extends Date + ? (Exclude | string) + : T extends boolean | number | string | null | undefined + ? T + : T extends {} + ? { + [K in keyof T]: Serialized; + } + : null; diff --git a/definition/externals/meteor/check.d.ts b/definition/externals/meteor/check.d.ts new file mode 100644 index 0000000000000..401b56c831fd3 --- /dev/null +++ b/definition/externals/meteor/check.d.ts @@ -0,0 +1,7 @@ +import 'meteor/check'; + +declare module 'meteor/check' { + namespace Match { + function Where(condition: (val: T) => val is U): Matcher; + } +} diff --git a/definition/rest/helpers/ReplacePlaceholders.ts b/definition/rest/helpers/ReplacePlaceholders.ts new file mode 100644 index 0000000000000..d8503b4b8471f --- /dev/null +++ b/definition/rest/helpers/ReplacePlaceholders.ts @@ -0,0 +1,8 @@ +export type ReplacePlaceholders = + string extends TPath + ? TPath + : TPath extends `${ infer Start }:${ infer _Param }/${ infer Rest }` + ? `${ Start }${ string }/${ ReplacePlaceholders }` + : TPath extends `${ infer Start }:${ infer _Param }` + ? `${ Start }${ string }` + : TPath; diff --git a/definition/rest/index.ts b/definition/rest/index.ts index b7bad4b5ffdae..8a2f9e3105a70 100644 --- a/definition/rest/index.ts +++ b/definition/rest/index.ts @@ -1,7 +1,8 @@ import type { EnterpriseEndpoints } from '../../ee/definition/rest'; -import type { ExtractKeys, ValueOf } from '../utils'; +import type { KeyOfEach } from '../utils'; import type { AppsEndpoints } from './apps'; -import { BannersEndpoints } from './v1/banners'; +import type { ReplacePlaceholders } from './helpers/ReplacePlaceholders'; +import type { BannersEndpoints } from './v1/banners'; import type { ChannelsEndpoints } from './v1/channels'; import type { ChatEndpoints } from './v1/chat'; import type { CloudEndpoints } from './v1/cloud'; @@ -11,20 +12,21 @@ import type { DnsEndpoints } from './v1/dns'; import type { EmojiCustomEndpoints } from './v1/emojiCustom'; import type { GroupsEndpoints } from './v1/groups'; import type { ImEndpoints } from './v1/im'; -import { InstancesEndpoints } from './v1/instances'; +import type { InstancesEndpoints } from './v1/instances'; import type { LDAPEndpoints } from './v1/ldap'; import type { LicensesEndpoints } from './v1/licenses'; import type { MiscEndpoints } from './v1/misc'; import type { OmnichannelEndpoints } from './v1/omnichannel'; -import { PermissionsEndpoints } from './v1/permissions'; -import { RolesEndpoints } from './v1/roles'; +import type { PermissionsEndpoints } from './v1/permissions'; +import type { RolesEndpoints } from './v1/roles'; import type { RoomsEndpoints } from './v1/rooms'; -import { SettingsEndpoints } from './v1/settings'; +import type { SettingsEndpoints } from './v1/settings'; import type { StatisticsEndpoints } from './v1/statistics'; import type { TeamsEndpoints } from './v1/teams'; import type { UsersEndpoints } from './v1/users'; -type CommunityEndpoints = BannersEndpoints & ChatEndpoints & +type CommunityEndpoints = BannersEndpoints & +ChatEndpoints & ChannelsEndpoints & CloudEndpoints & CustomUserStatusEndpoints & @@ -47,51 +49,76 @@ MiscEndpoints & PermissionsEndpoints & InstancesEndpoints; -export type Endpoints = CommunityEndpoints & EnterpriseEndpoints; +type Endpoints = CommunityEndpoints & EnterpriseEndpoints; -type Endpoint = UnionizeEndpoints; +type OperationsByPathPattern = TPathPattern extends any + ? OperationsByPathPatternAndMethod + : never; -type UnionizeEndpoints = ValueOf< -{ - [P in keyof EE]: UnionizeMethods; -} ->; +type OperationsByPathPatternAndMethod< + TPathPattern extends keyof Endpoints, + TMethod extends KeyOfEach = KeyOfEach +> = TMethod extends any + ? { + pathPattern: TPathPattern; + method: TMethod; + path: ReplacePlaceholders; + params: GetParams; + result: GetResult; + } + : never; -type ExtractOperations = ExtractKeys any>; +type Operations = OperationsByPathPattern; -type UnionizeMethods = ValueOf< -{ - [M in keyof OO as ExtractOperations]: ( - method: M, - path: OO extends { path: string } ? OO['path'] : P, - ...params: Parameters any>> - ) => ReturnType any>>; -} ->; +export type PathPattern = Operations['pathPattern']; -export type Method = Parameters[0]; -export type Path = Parameters[1]; +export type Method = Operations['method']; -export type MethodFor

= P extends any - ? Parameters any>>[0] +export type Path = Operations['path']; + +export type MethodFor = TPath extends any + ? Extract['method'] : never; -export type PathFor = M extends any - ? Parameters any>>[1] + +export type PathFor = TMethod extends any + ? Extract['path'] : never; -type Operation> = M extends any - ? P extends any - ? Extract any> - : never +export type MatchPathPattern = TPath extends any + ? Extract['pathPattern'] : never; -type ExtractParams = Q extends [any, any] - ? [undefined?] - : Q extends [any, any, any, ...any[]] - ? [Q[2]] +export type JoinPathPattern = Extract< +PathPattern, +`${ TBasePath }/${ TSubPathPattern }` | TSubPathPattern +>; + +type GetParams = TOperation extends (...args: any) => any + ? Parameters[0] extends void ? void : Parameters[0] + : never + +type GetResult = TOperation extends (...args: any) => any + ? ReturnType + : never + +export type OperationParams = + TMethod extends keyof Endpoints[TPathPattern] + ? GetParams : never; -export type Params> = ExtractParams< -Parameters> ->; -export type Return> = ReturnType>; +export type OperationResult = + TMethod extends keyof Endpoints[TPathPattern] + ? GetResult + : never; + +export type UrlParams = string extends T + ? Record + : T extends `${ infer _Start }:${ infer Param }/${ infer Rest }` + ? { [k in Param | keyof UrlParams]: string } + : T extends `${ infer _Start }:${ infer Param }` + ? { [k in Param]: string } + : {}; + +export type MethodOf = TPathPattern extends any + ? keyof Endpoints[TPathPattern] + : never; diff --git a/definition/rest/v1/banners.ts b/definition/rest/v1/banners.ts index e448abb7b410f..7b667a4f26e9c 100644 --- a/definition/rest/v1/banners.ts +++ b/definition/rest/v1/banners.ts @@ -1,21 +1,21 @@ -import { IBanner } from '../../IBanner'; +import type { BannerPlatform, IBanner } from '../../IBanner'; export type BannersEndpoints = { /* @deprecated */ 'banners.getNew': { - GET: () => ({ + GET: (params: { platform: BannerPlatform; bid: IBanner['_id'] }) => ({ banners: IBanner[]; }); }; 'banners/:id': { - GET: (params: { platform: string }) => ({ + GET: (params: { platform: BannerPlatform }) => ({ banners: IBanner[]; }); }; 'banners': { - GET: () => ({ + GET: (params: { platform: BannerPlatform }) => ({ banners: IBanner[]; }); }; diff --git a/definition/rest/v1/omnichannel.ts b/definition/rest/v1/omnichannel.ts index bf098ad76b3b7..80d85f4ba4bf7 100644 --- a/definition/rest/v1/omnichannel.ts +++ b/definition/rest/v1/omnichannel.ts @@ -49,13 +49,17 @@ export type OmnichannelEndpoints = { }; }; 'livechat/department/:_id': { - path: `livechat/department/${ string }`; GET: () => { department: ILivechatDepartment; }; }; - 'livechat/departments.by-unit/': { - path: `livechat/departments.by-unit/${ string }`; + 'livechat/departments.by-unit/:id': { + GET: (params: { text: string; offset: number; count: number }) => { + departments: ILivechatDepartment[]; + total: number; + }; + }; + 'livechat/departments.available-by-unit/:id': { GET: (params: { text: string; offset: number; count: number }) => { departments: ILivechatDepartment[]; total: number; @@ -139,7 +143,6 @@ export type OmnichannelEndpoints = { DELETE: (params: { _id: IOmnichannelCannedResponse['_id'] }) => void; }; 'canned-responses/:_id': { - path: `canned-responses/${ string }`; GET: () => { cannedResponse: IOmnichannelCannedResponse; }; diff --git a/definition/rest/v1/roles.ts b/definition/rest/v1/roles.ts index 844d125083b3d..bccc9a37e1d10 100644 --- a/definition/rest/v1/roles.ts +++ b/definition/rest/v1/roles.ts @@ -109,6 +109,7 @@ type RoleRemoveUserFromRoleProps = { username: string; roleName: string; roomId?: string; + scope?: string; } const roleRemoveUserFromRolePropsSchema: JSONSchemaType = { @@ -124,6 +125,10 @@ const roleRemoveUserFromRolePropsSchema: JSONSchemaType PaginatedResult & { rooms: IRoom[] }; + GET: (params: PaginatedRequest & ({ teamId: string } | { teamName: string }) & { filter?: string; type?: string }) => PaginatedResult & { rooms: IRoom[] }; }; diff --git a/definition/utils.ts b/definition/utils.ts index 72339afa798a0..211bcd5234dea 100644 --- a/definition/utils.ts +++ b/definition/utils.ts @@ -9,3 +9,6 @@ export type UnionToIntersection = (T extends any ? (x: T) => void : never) ex : never; export type Awaited = T extends PromiseLike ? Awaited : T; + +// `T extends any` is a trick to apply a operator to each member of a union +export type KeyOfEach = T extends any ? keyof T : never; diff --git a/ee/definition/rest/v1/omnichannel/businessHours.ts b/ee/definition/rest/v1/omnichannel/businessHours.ts index 77b352c612599..1211ccdb9de05 100644 --- a/ee/definition/rest/v1/omnichannel/businessHours.ts +++ b/ee/definition/rest/v1/omnichannel/businessHours.ts @@ -2,6 +2,16 @@ import { ILivechatBusinessHour } from '../../../../../definition/ILivechatBusine export type OmnichannelBusinessHoursEndpoints = { 'livechat/business-hours.list': { - GET: () => ({ businessHours: ILivechatBusinessHour[] }); + GET: (params: { + name?: string; + offset: number; + count: number; + sort: Record; + }) => { + businessHours: ILivechatBusinessHour[]; + count: number; + offset: number; + total: number; + }; }; } diff --git a/server/sdk/types/ITeamService.ts b/server/sdk/types/ITeamService.ts index 697a67e406418..1d464759397ab 100644 --- a/server/sdk/types/ITeamService.ts +++ b/server/sdk/types/ITeamService.ts @@ -41,7 +41,7 @@ export interface ITeamInfo extends ITeam { } export interface IListRoomsFilter { - name: string; + name?: string; isDefault: boolean; getAllRooms: boolean; allowPrivateTeam: boolean; diff --git a/tests/end-to-end/api/21-banners.js b/tests/end-to-end/api/21-banners.js index 6d31095aced43..ede0cccc737a8 100644 --- a/tests/end-to-end/api/21-banners.js +++ b/tests/end-to-end/api/21-banners.js @@ -27,7 +27,6 @@ describe('banners', function() { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', 'Match error: Missing key \'platform\''); }) .end(done); }); @@ -41,8 +40,6 @@ describe('banners', function() { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', 'Platform is unknown. [error-unknown-platform]'); - expect(res.body).to.have.property('errorType', 'error-unknown-platform'); }) .end(done); }); @@ -56,8 +53,6 @@ describe('banners', function() { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', 'The required "platform" param is missing. [error-missing-param]'); - expect(res.body).to.have.property('errorType', 'error-missing-param'); }) .end(done); }); @@ -112,8 +107,6 @@ describe('banners', function() { .expect(400) .expect((res) => { expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', 'The required "bannerId" param is missing. [error-missing-param]'); - expect(res.body).to.have.property('errorType', 'error-missing-param'); }) .end(done); });