diff --git a/.changeset/silly-kings-approve.md b/.changeset/silly-kings-approve.md new file mode 100644 index 000000000000..cfaa02f3e7ec --- /dev/null +++ b/.changeset/silly-kings-approve.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/meteor': patch +'@rocket.chat/rest-typings': patch +--- + +Adds `groups.membersOrderedByRole` and `channels.membersOrderedByRole` endpoints to retrieve members of groups and channels sorted according to their respective role in the room. diff --git a/apps/meteor/app/api/server/v1/channels.ts b/apps/meteor/app/api/server/v1/channels.ts index 91c7b63c2098..0348bf5aed21 100644 --- a/apps/meteor/app/api/server/v1/channels.ts +++ b/apps/meteor/app/api/server/v1/channels.ts @@ -21,12 +21,14 @@ import { isChannelsListProps, isChannelsFilesListProps, isChannelsOnlineProps, + isChannelsMembersOrderedByRoleProps, } from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; import { isTruthy } from '../../../../lib/isTruthy'; import { eraseRoom } from '../../../../server/lib/eraseRoom'; import { findUsersOfRoom } from '../../../../server/lib/findUsersOfRoom'; +import { findUsersOfRoomOrderedByRole } from '../../../../server/lib/findUsersOfRoomOrderedByRole'; import { hideRoomMethod } from '../../../../server/methods/hideRoom'; import { removeUserFromRoomMethod } from '../../../../server/methods/removeUserFromRoom'; import { canAccessRoomAsync } from '../../../authorization/server'; @@ -1092,6 +1094,45 @@ API.v1.addRoute( }, ); +API.v1.addRoute( + 'channels.membersOrderedByRole', + { authRequired: true, validateParams: isChannelsMembersOrderedByRoleProps }, + { + async get() { + const findResult = await findChannelByIdOrName({ + params: this.queryParams, + checkedArchived: false, + }); + + if (findResult.broadcast && !(await hasPermissionAsync(this.userId, 'view-broadcast-member-list', findResult._id))) { + return API.v1.unauthorized(); + } + + const { offset: skip, count: limit } = await getPaginationItems(this.queryParams); + const { sort = {} } = await this.parseJsonQuery(); + + const { status, filter, rolesOrder = ['owner', 'moderator'] } = this.queryParams; + + const { members, total } = await findUsersOfRoomOrderedByRole({ + rid: findResult._id, + ...(status && { status: { $in: status } }), + skip, + limit, + filter, + ...(sort?.username && { sort: { username: sort.username } }), + rolesInOrder: rolesOrder, + }); + + return API.v1.success({ + members, + count: members.length, + offset: skip, + total, + }); + }, + }, +); + API.v1.addRoute( 'channels.online', { authRequired: true, validateParams: isChannelsOnlineProps }, diff --git a/apps/meteor/app/api/server/v1/groups.ts b/apps/meteor/app/api/server/v1/groups.ts index 1a8069fff205..208ff666c39e 100644 --- a/apps/meteor/app/api/server/v1/groups.ts +++ b/apps/meteor/app/api/server/v1/groups.ts @@ -1,13 +1,14 @@ import { Team, isMeteorError } from '@rocket.chat/core-services'; import type { IIntegration, IUser, IRoom, RoomType } from '@rocket.chat/core-typings'; import { Integrations, Messages, Rooms, Subscriptions, Uploads, Users } from '@rocket.chat/models'; -import { isGroupsOnlineProps, isGroupsMessagesProps } from '@rocket.chat/rest-typings'; +import { isGroupsOnlineProps, isGroupsMessagesProps, isGroupsMembersOrderedByRoleProps } from '@rocket.chat/rest-typings'; import { check, Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import type { Filter } from 'mongodb'; import { eraseRoom } from '../../../../server/lib/eraseRoom'; import { findUsersOfRoom } from '../../../../server/lib/findUsersOfRoom'; +import { findUsersOfRoomOrderedByRole } from '../../../../server/lib/findUsersOfRoomOrderedByRole'; import { hideRoomMethod } from '../../../../server/methods/hideRoom'; import { removeUserFromRoomMethod } from '../../../../server/methods/removeUserFromRoom'; import { canAccessRoomAsync, roomAccessAttributes } from '../../../authorization/server'; @@ -744,6 +745,45 @@ API.v1.addRoute( }, ); +API.v1.addRoute( + 'groups.membersOrderedByRole', + { authRequired: true, validateParams: isGroupsMembersOrderedByRoleProps }, + { + async get() { + const findResult = await findPrivateGroupByIdOrName({ + params: this.queryParams, + userId: this.userId, + }); + + if (findResult.broadcast && !(await hasPermissionAsync(this.userId, 'view-broadcast-member-list', findResult.rid))) { + return API.v1.unauthorized(); + } + + const { offset: skip, count: limit } = await getPaginationItems(this.queryParams); + const { sort = {} } = await this.parseJsonQuery(); + + const { status, filter, rolesOrder = ['owner', 'moderator'] } = this.queryParams; + + const { members, total } = await findUsersOfRoomOrderedByRole({ + rid: findResult.rid, + ...(status && { status: { $in: status } }), + skip, + limit, + filter, + ...(sort?.username && { sort: { username: sort.username } }), + rolesInOrder: rolesOrder, + }); + + return API.v1.success({ + members, + count: members.length, + offset: skip, + total, + }); + }, + }, +); + API.v1.addRoute( 'groups.messages', { authRequired: true, validateParams: isGroupsMessagesProps }, diff --git a/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts b/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts new file mode 100644 index 000000000000..abedd03d0aae --- /dev/null +++ b/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts @@ -0,0 +1,188 @@ +import type { IUser, IRole } from '@rocket.chat/core-typings'; +import { Subscriptions } from '@rocket.chat/models'; +import { escapeRegExp } from '@rocket.chat/string-helpers'; +import type { Document, FilterOperators } from 'mongodb'; + +import { settings } from '../../app/settings/server'; + +type FindUsersParam = { + rid: string; + status?: FilterOperators; + skip?: number; + limit?: number; + filter?: string; + sort?: Record; + rolesInOrder?: IRole['_id'][]; + exceptions?: string[]; + extraQuery?: Document[]; +}; + +type UserWithRoleData = IUser & { + roles: IRole['_id'][]; +}; + +export async function findUsersOfRoomOrderedByRole({ + rid, + status, + skip = 0, + limit = 0, + filter = '', + sort, + rolesInOrder = [], + exceptions = [], + extraQuery = [], +}: FindUsersParam): Promise<{ members: UserWithRoleData[]; total: number }> { + const searchFields = settings.get('Accounts_SearchFields').trim().split(','); + const termRegex = new RegExp(escapeRegExp(filter), 'i'); + const orStmt = filter && searchFields.length ? searchFields.map((field) => ({ [field.trim()]: termRegex })) : []; + + const useRealName = settings.get('UI_Use_Real_Name'); + const defaultSort = useRealName ? { name: 1 } : { username: 1 }; + + const sortCriteria = { + rolePriority: 1, + statusConnection: -1, + ...(sort || defaultSort), + }; + + const userLookupPipeline: Document[] = [{ $match: { $expr: { $eq: ['$_id', '$$userId'] } } }]; + + if (status) { + userLookupPipeline.push({ $match: { status } }); + } + + userLookupPipeline.push({ + $match: { + $and: [ + { + active: true, + username: { + $exists: true, + ...(exceptions.length > 0 && { $nin: exceptions }), + }, + ...(filter && orStmt.length > 0 && { $or: orStmt }), + }, + ...extraQuery, + ], + }, + }); + + userLookupPipeline.push({ + $project: { + _id: 1, + username: 1, + name: 1, + nickname: 1, + status: 1, + avatarETag: 1, + _updatedAt: 1, + federated: 1, + statusConnection: 1, + }, + }); + + const defaultPriority = rolesInOrder.length + 1; + + const branches = rolesInOrder.map((role, index) => ({ + case: { $eq: ['$$this', role] }, + then: index + 1, + })); + + const filteredPipeline: Document[] = [ + { + $lookup: { + from: 'users', + let: { userId: '$u._id' }, + pipeline: userLookupPipeline, + as: 'userDetails', + }, + }, + { $unwind: '$userDetails' }, + { + $addFields: { + primaryRole: { + $reduce: { + input: '$roles', + initialValue: { role: null, priority: defaultPriority }, + in: { + $let: { + vars: { + currentPriority: { + $switch: { + branches, + default: defaultPriority, + }, + }, + }, + in: { + $cond: [ + { + $and: [{ $in: ['$$this', rolesInOrder] }, { $lt: ['$$currentPriority', '$$value.priority'] }], + }, + { role: '$$this', priority: '$$currentPriority' }, + '$$value', + ], + }, + }, + }, + }, + }, + }, + }, + { + $addFields: { + rolePriority: { $ifNull: ['$primaryRole.priority', defaultPriority] }, + }, + }, + { + $project: { + _id: '$userDetails._id', + rid: 1, + roles: 1, + primaryRole: '$primaryRole.role', + rolePriority: 1, + username: '$userDetails.username', + name: '$userDetails.name', + nickname: '$userDetails.nickname', + status: '$userDetails.status', + avatarETag: '$userDetails.avatarETag', + _updatedAt: '$userDetails._updatedAt', + federated: '$userDetails.federated', + statusConnection: '$userDetails.statusConnection', + }, + }, + ]; + + const facetPipeline: Document[] = [ + { $match: { rid } }, + { + $facet: { + totalCount: [{ $match: { rid } }, ...filteredPipeline, { $count: 'total' }], + members: [ + { $match: { rid } }, + ...filteredPipeline, + { $sort: sortCriteria }, + ...(skip > 0 ? [{ $skip: skip }] : []), + ...(limit > 0 ? [{ $limit: limit }] : []), + ], + }, + }, + { + $project: { + members: 1, + totalCount: { $arrayElemAt: ['$totalCount.total', 0] }, + }, + }, + ]; + + const [result] = await Subscriptions.col.aggregate(facetPipeline, { allowDiskUse: true }).toArray(); + + return { + members: result.members.map((member: any) => { + delete member.primaryRole; + delete member.rolePriority; + return member; + }), + total: result.totalCount, + }; +} diff --git a/apps/meteor/tests/end-to-end/api/channels.ts b/apps/meteor/tests/end-to-end/api/channels.ts index 75aff5ad770a..e1ddac8e5fb5 100644 --- a/apps/meteor/tests/end-to-end/api/channels.ts +++ b/apps/meteor/tests/end-to-end/api/channels.ts @@ -1,5 +1,6 @@ import type { Credentials } from '@rocket.chat/api-client'; import type { IIntegration, IMessage, IRoom, ITeam, IUser } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; import { expect, assert } from 'chai'; import { after, before, describe, it } from 'mocha'; @@ -1479,6 +1480,196 @@ describe('[Channels]', () => { }); }); + describe('[/channels.membersOrderedByRole]', () => { + let testChannel: IRoom; + let ownerUser: IUser; + let moderatorUser: IUser; + let memberUser1: IUser; + let memberUser2: IUser; + + let ownerCredentials: { 'X-Auth-Token': string; 'X-User-Id': string }; + + before(async () => { + ownerUser = await createUser({ username: Random.id(), roles: ['admin'] }); + ownerCredentials = await login(ownerUser.username, password); + + moderatorUser = await createUser({ username: Random.id() }); + memberUser1 = await createUser({ username: Random.id() }); + memberUser2 = await createUser({ username: Random.id() }); + + // Create a public channel + const roomCreationResponse = await createRoom({ + type: 'c', + name: `channel.membersOrderedByRole.test.${Date.now()}`, + credentials: ownerCredentials, + }); + testChannel = roomCreationResponse.body.channel; + + await request + .post(api('channels.invite')) + .set(ownerCredentials) + .send({ + roomId: testChannel._id, + userId: moderatorUser._id, + }) + .expect(200); + + await request + .post(api('channels.invite')) + .set(ownerCredentials) + .send({ + roomId: testChannel._id, + userId: memberUser1._id, + }) + .expect(200); + + await request + .post(api('channels.invite')) + .set(ownerCredentials) + .send({ + roomId: testChannel._id, + userId: memberUser2._id, + }) + .expect(200); + + await request + .post(api('channels.addModerator')) + .set(ownerCredentials) + .send({ + roomId: testChannel._id, + userId: moderatorUser._id, + }) + .expect(200); + }); + + after(async () => { + await deleteRoom({ type: 'c', roomId: testChannel._id }); + await deleteUser(ownerUser); + await deleteUser(moderatorUser); + await deleteUser(memberUser1); + await deleteUser(memberUser2); + }); + + it('should return a list of members ordered by owner, moderator, then members by default', async () => { + const response = await request + .get(api('channels.membersOrderedByRole')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + expect(response.body.members).to.be.an('array'); + + // We expect Owner first, then Moderator, then Members + // The endpoint defaults to rolesInOrder = ["owner", "moderator"] + const [first, second, ...rest] = response.body.members; + expect(first.username).to.equal(ownerUser.username); + expect(second.username).to.equal(moderatorUser.username); + + const memberUsernames = rest.map((m: any) => m.username); + expect(memberUsernames).to.include(memberUser1.username); + expect(memberUsernames).to.include(memberUser2.username); + + expect(response.body).to.have.property('total'); + expect(response.body.total).to.be.gte(4); + }); + + it('should allow custom role order', async () => { + // Switch role order: moderator, owner + // This should display moderator first, then owner, then members + const response = await request + .get(api('channels.membersOrderedByRole')) + .set(credentials) + .query({ + roomId: testChannel._id, + rolesOrder: ['moderator', 'owner'], + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + const [first, second, ...rest] = response.body.members; + expect(first.username).to.equal(moderatorUser.username); // now moderator first + expect(second.username).to.equal(ownerUser.username); // owner second + expect(rest.map((m: any) => m.username)).to.include(memberUser1.username); + expect(rest.map((m: any) => m.username)).to.include(memberUser2.username); + }); + + it('should support pagination', async () => { + const response = await request + .get(api('channels.membersOrderedByRole')) + .set(credentials) + .query({ + roomId: testChannel._id, + count: 2, + offset: 0, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + expect(response.body.members).to.have.lengthOf(2); + expect(response.body.total).to.be.gte(4); + }); + + it('should return matched members when using filter param', async () => { + const response = await request + .get(api(`channels.membersOrderedByRole`)) + .set(credentials) + .query({ + roomId: testChannel._id, + filter: memberUser1.username, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + expect(response.body.members).to.have.lengthOf(1); + expect(response.body.members[0]).have.property('username', memberUser1.username); + }); + + it('should return empty list if no matches (e.g., filter by status that no one has)', async () => { + const response = await request + .get(api(`channels.membersOrderedByRole`)) + .set(credentials) + .query({ + 'roomId': testChannel._id, + 'status[]': 'SomeRandomStatus', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + expect(response.body.members).to.be.an.empty('array'); + }); + + it('should support custom sorting by username descending', async () => { + const response = await request + .get(api('channels.membersOrderedByRole')) + .set(credentials) + .query({ + roomId: testChannel._id, + sort: JSON.stringify({ username: -1 }), + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(response.body).to.have.property('success', true); + const usernames = response.body.members.map((m: any) => m.username); + const expected = [ + ownerUser.username, // since owner + moderatorUser.username, // since moderator + ...(memberUser1.username!.localeCompare(memberUser2.username || '') < 0 + ? [memberUser2.username, memberUser1.username] + : [memberUser1.username, memberUser2.username]), + ]; + expect(usernames).to.deep.equal(expected); + }); + }); + describe('/channels.getIntegrations', () => { let integrationCreatedByAnUser: IIntegration; let userCredentials: Credentials; diff --git a/packages/rest-typings/src/v1/channels/ChannelsMembersByOrderedRole.ts b/packages/rest-typings/src/v1/channels/ChannelsMembersByOrderedRole.ts new file mode 100644 index 000000000000..3af770067ee1 --- /dev/null +++ b/packages/rest-typings/src/v1/channels/ChannelsMembersByOrderedRole.ts @@ -0,0 +1,59 @@ +import type { IRole, IRoom } from '@rocket.chat/core-typings'; + +import type { PaginatedRequest } from '../../helpers/PaginatedRequest'; +import { ajv } from '../Ajv'; + +type MembersOrderedByRoleProps = { + roomId?: IRoom['_id']; + roomName?: IRoom['name']; + status?: string[]; + filter?: string; + rolesOrder?: IRole['_id'][]; +}; + +export type ChannelsMembersOrderedByRoleProps = PaginatedRequest; + +const membersOrderedByRoleRolePropsSchema = { + properties: { + roomId: { + type: 'string', + }, + roomName: { + type: 'string', + }, + rolesOrder: { + type: 'array', + items: { + type: 'string', + }, + nullable: true, + }, + status: { + type: 'array', + items: { + type: 'string', + }, + nullable: true, + }, + filter: { + type: 'string', + nullable: true, + }, + count: { + type: 'integer', + nullable: true, + }, + offset: { + type: 'integer', + nullable: true, + }, + sort: { + type: 'string', + nullable: true, + }, + }, + oneOf: [{ required: ['roomId'] }, { required: ['roomName'] }], + additionalProperties: false, +}; + +export const isChannelsMembersOrderedByRoleProps = ajv.compile(membersOrderedByRoleRolePropsSchema); diff --git a/packages/rest-typings/src/v1/channels/channels.ts b/packages/rest-typings/src/v1/channels/channels.ts index 51611dfa783b..8178ecf17878 100644 --- a/packages/rest-typings/src/v1/channels/channels.ts +++ b/packages/rest-typings/src/v1/channels/channels.ts @@ -1,4 +1,4 @@ -import type { IUploadWithUser, IMessage, IRoom, ITeam, IGetRoomRoles, IUser, IIntegration } from '@rocket.chat/core-typings'; +import type { IUploadWithUser, IMessage, IRoom, ITeam, IGetRoomRoles, IUser, IIntegration, IRole } from '@rocket.chat/core-typings'; import type { ChannelsAddAllProps } from './ChannelsAddAllProps'; import type { ChannelsArchiveProps } from './ChannelsArchiveProps'; @@ -14,6 +14,7 @@ import type { ChannelsJoinProps } from './ChannelsJoinProps'; import type { ChannelsKickProps } from './ChannelsKickProps'; import type { ChannelsLeaveProps } from './ChannelsLeaveProps'; import type { ChannelsListProps } from './ChannelsListProps'; +import type { ChannelsMembersOrderedByRoleProps } from './ChannelsMembersByOrderedRole'; import type { ChannelsMessagesProps } from './ChannelsMessagesProps'; import type { ChannelsModeratorsProps } from './ChannelsModeratorsProps'; import type { ChannelsOnlineProps } from './ChannelsOnlineProps'; @@ -52,6 +53,11 @@ export type ChannelsEndpoints = { members: IUser[]; }>; }; + '/v1/channels.membersOrderedByRole': { + GET: (params: ChannelsMembersOrderedByRoleProps) => PaginatedResult<{ + members: IUser & { roles: IRole['_id'] }[]; + }>; + }; '/v1/channels.history': { GET: (params: ChannelsHistoryProps) => PaginatedResult<{ messages: IMessage[]; diff --git a/packages/rest-typings/src/v1/channels/index.ts b/packages/rest-typings/src/v1/channels/index.ts index 981296e244fe..76e8fd796c3e 100644 --- a/packages/rest-typings/src/v1/channels/index.ts +++ b/packages/rest-typings/src/v1/channels/index.ts @@ -17,3 +17,4 @@ export * from './ChannelsRolesProps'; export * from './ChannelsSetAnnouncementProps'; export * from './ChannelsSetReadOnlyProps'; export * from './ChannelsUnarchiveProps'; +export * from './ChannelsMembersByOrderedRole'; diff --git a/packages/rest-typings/src/v1/groups/GroupsMembersByOrderedRole.ts b/packages/rest-typings/src/v1/groups/GroupsMembersByOrderedRole.ts new file mode 100644 index 000000000000..e4da6b2df4bb --- /dev/null +++ b/packages/rest-typings/src/v1/groups/GroupsMembersByOrderedRole.ts @@ -0,0 +1,59 @@ +import type { IRole, IRoom } from '@rocket.chat/core-typings'; + +import type { PaginatedRequest } from '../../helpers/PaginatedRequest'; +import { ajv } from '../Ajv'; + +type MembersOrderedByRoleProps = { + roomId?: IRoom['_id']; + roomName?: IRoom['name']; + status?: string[]; + filter?: string; + rolesOrder?: IRole['_id'][]; +}; + +export type GroupsMembersOrderedByRoleProps = PaginatedRequest; + +const membersOrderedByRoleRolePropsSchema = { + properties: { + roomId: { + type: 'string', + }, + roomName: { + type: 'string', + }, + rolesOrder: { + type: 'array', + items: { + type: 'string', + }, + nullable: true, + }, + status: { + type: 'array', + items: { + type: 'string', + }, + nullable: true, + }, + filter: { + type: 'string', + nullable: true, + }, + count: { + type: 'integer', + nullable: true, + }, + offset: { + type: 'integer', + nullable: true, + }, + sort: { + type: 'string', + nullable: true, + }, + }, + oneOf: [{ required: ['roomId'] }, { required: ['roomName'] }], + additionalProperties: false, +}; + +export const isGroupsMembersOrderedByRoleProps = ajv.compile(membersOrderedByRoleRolePropsSchema); diff --git a/packages/rest-typings/src/v1/groups/groups.ts b/packages/rest-typings/src/v1/groups/groups.ts index 973f5d98a357..a2644865bd73 100644 --- a/packages/rest-typings/src/v1/groups/groups.ts +++ b/packages/rest-typings/src/v1/groups/groups.ts @@ -1,4 +1,14 @@ -import type { IMessage, IRoom, ITeam, IGetRoomRoles, IUser, IUploadWithUser, IIntegration, ISubscription } from '@rocket.chat/core-typings'; +import type { + IMessage, + IRoom, + ITeam, + IGetRoomRoles, + IUser, + IUploadWithUser, + IIntegration, + ISubscription, + IRole, +} from '@rocket.chat/core-typings'; import type { GroupsAddAllProps } from './GroupsAddAllProps'; import type { GroupsAddLeaderProps } from './GroupsAddLeaderProps'; @@ -18,6 +28,7 @@ import type { GroupsInviteProps } from './GroupsInviteProps'; import type { GroupsKickProps } from './GroupsKickProps'; import type { GroupsLeaveProps } from './GroupsLeaveProps'; import type { GroupsListProps } from './GroupsListProps'; +import type { GroupsMembersOrderedByRoleProps } from './GroupsMembersByOrderedRole'; import type { GroupsMembersProps } from './GroupsMembersProps'; import type { GroupsMessagesProps } from './GroupsMessagesProps'; import type { GroupsModeratorsProps } from './GroupsModeratorsProps'; @@ -53,6 +64,11 @@ export type GroupsEndpoints = { total: number; }; }; + '/v1/groups.membersOrderedByRole': { + GET: (params: GroupsMembersOrderedByRoleProps) => PaginatedResult<{ + members: IUser & { roles: IRole['_id'][] }[]; + }>; + }; '/v1/groups.history': { GET: (params: GroupsHistoryProps) => PaginatedResult<{ messages: IMessage[]; diff --git a/packages/rest-typings/src/v1/groups/index.ts b/packages/rest-typings/src/v1/groups/index.ts index 12d08774cd28..12e27f540b13 100644 --- a/packages/rest-typings/src/v1/groups/index.ts +++ b/packages/rest-typings/src/v1/groups/index.ts @@ -37,3 +37,4 @@ export * from './GroupsSetTopicProps'; export * from './GroupsSetTypeProps'; export * from './GroupsModeratorsProps'; export * from './GroupsHistoryProps'; +export * from './GroupsMembersByOrderedRole';