diff --git a/.changeset/fifty-cups-sort.md b/.changeset/fifty-cups-sort.md new file mode 100644 index 0000000000000000000000000000000000000000..389391ef8cc9a48ee4e655824d81f6d16b9425e8 --- /dev/null +++ b/.changeset/fifty-cups-sort.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/rest-typings": minor +--- + +Created a new endpoint to get a filtered and paginated list of users. diff --git a/.changeset/pink-parrots-end.md b/.changeset/pink-parrots-end.md new file mode 100644 index 0000000000000000000000000000000000000000..9f1863f6915c094fd69cf13ffee87031445823e8 --- /dev/null +++ b/.changeset/pink-parrots-end.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/rest-typings": minor +--- + +Created a new endpoint to resend the welcome email to a given user diff --git a/apps/meteor/app/api/server/lib/users.ts b/apps/meteor/app/api/server/lib/users.ts index 990fda0a0209fb15a657b83aab33fa1e77e1e757..f80d662771dfd64e3a210938a3c5bde6b232f24e 100644 --- a/apps/meteor/app/api/server/lib/users.ts +++ b/apps/meteor/app/api/server/lib/users.ts @@ -2,7 +2,7 @@ import type { IUser } from '@rocket.chat/core-typings'; import { Users, Subscriptions } from '@rocket.chat/models'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { Mongo } from 'meteor/mongo'; -import type { Filter } from 'mongodb'; +import type { Filter, RootFilterOperators } from 'mongodb'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { settings } from '../../../settings/server'; @@ -119,3 +119,98 @@ export function getNonEmptyQuery<T extends IUser>(query: Mongo.Query<T> | undefi return { ...defaultQuery, ...query }; } + +type FindPaginatedUsersByStatusProps = { + uid: string; + offset: number; + count: number; + sort: Record<string, 1 | -1>; + status: 'active' | 'deactivated'; + roles: string[] | null; + searchTerm: string; + hasLoggedIn: boolean; + type: string; +}; + +export async function findPaginatedUsersByStatus({ + uid, + offset, + count, + sort, + status, + roles, + searchTerm, + hasLoggedIn, + type, +}: FindPaginatedUsersByStatusProps) { + const projection = { + name: 1, + username: 1, + emails: 1, + roles: 1, + status: 1, + active: 1, + avatarETag: 1, + lastLogin: 1, + type: 1, + reason: 1, + }; + + const actualSort: Record<string, 1 | -1> = sort || { username: 1 }; + if (sort?.status) { + actualSort.active = sort.status; + } + if (sort?.name) { + actualSort.nameInsensitive = sort.name; + } + const match: Filter<IUser & RootFilterOperators<IUser>> = {}; + switch (status) { + case 'active': + match.active = true; + break; + case 'deactivated': + match.active = false; + break; + } + + if (hasLoggedIn !== undefined) { + match.lastLogin = { $exists: hasLoggedIn }; + } + + if (type) { + match.type = type; + } + + const canSeeAllUserInfo = await hasPermissionAsync(uid, 'view-full-other-user-info'); + + match.$or = [ + ...(canSeeAllUserInfo ? [{ 'emails.address': { $regex: escapeRegExp(searchTerm || ''), $options: 'i' } }] : []), + { + username: { $regex: escapeRegExp(searchTerm || ''), $options: 'i' }, + }, + { + name: { $regex: escapeRegExp(searchTerm || ''), $options: 'i' }, + }, + ]; + if (roles?.length && !roles.includes('all')) { + match.roles = { $in: roles }; + } + const { cursor, totalCount } = await Users.findPaginated( + { + ...match, + }, + { + sort: actualSort, + skip: offset, + limit: count, + projection, + }, + ); + const [users, total] = await Promise.all([cursor.toArray(), totalCount]); + return { + users, + count: users.length, + offset, + total, + }; +} diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index 2d57b6d56ab4af76004f3c290d102b0a3e55839e..bdf6fa2dd1c6184ad7bf7684b2df34c6f354ae50 100644 --- a/apps/meteor/app/api/server/v1/misc.ts +++ b/apps/meteor/app/api/server/v1/misc.ts @@ -26,6 +26,7 @@ import { hasPermissionAsync } from '../../../authorization/server/functions/hasP import { passwordPolicy } from '../../../lib/server'; import { settings } from '../../../settings/server'; import { getDefaultUserFields } from '../../../utils/server/functions/getDefaultUserFields'; +import { isSMTPConfigured } from '../../../utils/server/functions/isSMTPConfigured'; import { getURL } from '../../../utils/server/getURL'; import { API } from '../api'; import { getLoggedInUser } from '../helpers/getLoggedInUser'; @@ -636,9 +637,7 @@ API.v1.addRoute( { authRequired: true }, { async get() { - const isMailURLSet = !(process.env.MAIL_URL === 'undefined' || process.env.MAIL_URL === undefined); - const isSMTPConfigured = Boolean(settings.get('SMTP_Host')) || isMailURLSet; - return API.v1.success({ isSMTPConfigured }); + return API.v1.success({ isSMTPConfigured: isSMTPConfigured() }); }, }, ); diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index ea82ad8e5a34ee560687f1906ea992b0d83f2749..ccca23f8ea823e6a8691247d6b67f5e74a88b11c 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -6,6 +6,8 @@ import { isUserSetActiveStatusParamsPOST, isUserDeactivateIdleParamsPOST, isUsersInfoParamsGetProps, + isUsersListStatusProps, + isUsersSendWelcomeEmailProps, isUserRegisterParamsPOST, isUserLogoutParamsPOST, isUsersListTeamsProps, @@ -24,6 +26,7 @@ import type { Filter } from 'mongodb'; import { i18n } from '../../../../server/lib/i18n'; import { resetUserE2EEncriptionKey } from '../../../../server/lib/resetUserE2EKey'; +import { sendWelcomeEmail } from '../../../../server/lib/sendWelcomeEmail'; import { saveUserPreferences } from '../../../../server/methods/saveUserPreferences'; import { getUserForCheck, emailCheck } from '../../../2fa/server/code'; import { resetTOTP } from '../../../2fa/server/functions/resetTOTP'; @@ -49,7 +52,7 @@ import { getUserFromParams } from '../helpers/getUserFromParams'; import { isUserFromParams } from '../helpers/isUserFromParams'; import { getUploadFormData } from '../lib/getUploadFormData'; import { isValidQuery } from '../lib/isValidQuery'; -import { findUsersToAutocomplete, getInclusiveFields, getNonEmptyFields, getNonEmptyQuery } from '../lib/users'; +import { findPaginatedUsersByStatus, findUsersToAutocomplete, getInclusiveFields, getNonEmptyFields, getNonEmptyQuery } from '../lib/users'; API.v1.addRoute( 'users.getAvatar', @@ -556,6 +559,60 @@ API.v1.addRoute( }, ); +API.v1.addRoute( + 'users.listByStatus', + { + authRequired: true, + validateParams: isUsersListStatusProps, + permissionsRequired: ['view-d-room'], + }, + { + async get() { + if ( + settings.get('API_Apply_permission_view-outside-room_on_users-list') && + !(await hasPermissionAsync(this.userId, 'view-outside-room')) + ) { + return API.v1.unauthorized(); + } + + const { offset, count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + const { status, hasLoggedIn, type, roles, searchTerm } = this.queryParams; + + return API.v1.success( + await findPaginatedUsersByStatus({ + uid: this.userId, + offset, + count, + sort, + status, + roles, + searchTerm, + hasLoggedIn, + type, + }), + ); + }, + }, +); + +API.v1.addRoute( + 'users.sendWelcomeEmail', + { + authRequired: true, + validateParams: isUsersSendWelcomeEmailProps, + permissionsRequired: ['send-mail'], + }, + { + async post() { + const { email } = this.bodyParams; + await sendWelcomeEmail(email); + + return API.v1.success(); + }, + }, +); + API.v1.addRoute( 'users.register', { diff --git a/apps/meteor/app/utils/server/functions/isSMTPConfigured.ts b/apps/meteor/app/utils/server/functions/isSMTPConfigured.ts new file mode 100644 index 0000000000000000000000000000000000000000..fa300cb37ee206a357c85886eabdb9648dd3f21a --- /dev/null +++ b/apps/meteor/app/utils/server/functions/isSMTPConfigured.ts @@ -0,0 +1,6 @@ +import { settings } from '../../../settings/server'; + +export const isSMTPConfigured = (): boolean => { + const isMailURLSet = !(process.env.MAIL_URL === 'undefined' || process.env.MAIL_URL === undefined); + return Boolean(settings.get('SMTP_Host')) || isMailURLSet; +}; diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx index 4010b118e57d6e41947e6503013c963dd1602768..e0d46c2ba6fb38f9ca02824c70162b57a33eee92 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx @@ -1,5 +1,6 @@ -import type { IRole, IUser } from '@rocket.chat/core-typings'; +import type { IRole, IUser, Serialized } from '@rocket.chat/core-typings'; import { Box } from '@rocket.chat/fuselage'; +import type { DefaultUserInfo } from '@rocket.chat/rest-typings'; import { capitalize } from '@rocket.chat/string-helpers'; import { UserAvatar } from '@rocket.chat/ui-avatar'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; @@ -11,7 +12,7 @@ import { Roles } from '../../../../../app/models/client'; import { GenericTableRow, GenericTableCell } from '../../../../components/GenericTable'; type UsersTableRowProps = { - user: Pick<IUser, '_id' | 'username' | 'name' | 'status' | 'roles' | 'emails' | 'active' | 'avatarETag'>; + user: Serialized<DefaultUserInfo>; onClick: (id: IUser['_id']) => void; mediaQuery: boolean; }; diff --git a/apps/meteor/server/lib/sendWelcomeEmail.ts b/apps/meteor/server/lib/sendWelcomeEmail.ts new file mode 100644 index 0000000000000000000000000000000000000000..3daa8df94555c59c3ccd07b2e42ee023c08e3ffb --- /dev/null +++ b/apps/meteor/server/lib/sendWelcomeEmail.ts @@ -0,0 +1,43 @@ +import { Users } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import * as Mailer from '../../app/mailer/server/api'; +import { settings } from '../../app/settings/server'; +import { isSMTPConfigured } from '../../app/utils/server/functions/isSMTPConfigured'; + +export async function sendWelcomeEmail(to: string): Promise<void> { + if (!isSMTPConfigured()) { + throw new Meteor.Error('error-email-send-failed', 'SMTP is not configured', { + method: 'sendWelcomeEmail', + }); + } + + const email = to.trim(); + + const user = await Users.findOneByEmailAddress(email, { projection: { _id: 1 } }); + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'sendWelcomeEmail', + }); + } + + try { + let html = ''; + Mailer.getTemplate('Accounts_UserAddedEmail_Email', (template) => { + html = template; + }); + + await Mailer.send({ + to: email, + from: settings.get('From_Email'), + subject: settings.get('Accounts_UserAddedEmail_Subject'), + html, + }); + } catch (error: any) { + throw new Meteor.Error('error-email-send-failed', `Error trying to send email: ${error.message}`, { + method: 'sendWelcomeEmail', + message: error.message, + }); + } +} diff --git a/apps/meteor/tests/end-to-end/api/01-users.js b/apps/meteor/tests/end-to-end/api/01-users.js index 405eea05ee7e7a080a21134edb6ce2693409f880..d973f1bd3ff01c02ad5c1e55a5209355f9ce7982 100644 --- a/apps/meteor/tests/end-to-end/api/01-users.js +++ b/apps/meteor/tests/end-to-end/api/01-users.js @@ -3918,4 +3918,243 @@ describe('[Users]', function () { }); }); }); + + describe('[/users.listByStatus]', () => { + let user; + let otherUser; + let otherUserCredentials; + + before(async () => { + user = await createUser(); + otherUser = await createUser(); + otherUserCredentials = await login(otherUser.username, password); + }); + + after(async () => { + await deleteUser(user); + await deleteUser(otherUser); + await updatePermission('view-outside-room', ['admin', 'owner', 'moderator', 'user']); + await updatePermission('view-d-room', ['admin', 'owner', 'moderator', 'user']); + }); + + it('should list pending users', async () => { + await request + .get(api('users.listByStatus')) + .set(credentials) + .query({ hasLoggedIn: false, type: 'user', count: 50 }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users'); + const { users } = res.body; + const ids = users.map((user) => user._id); + expect(ids).to.include(user._id); + }); + }); + + it('should list all users', async () => { + await request + .get(api('users.listByStatus')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users'); + const { users } = res.body; + const ids = users.map((user) => user._id); + expect(ids).to.include(user._id); + }); + }); + + it('should list active users', async () => { + await login(user.username, password); + await request + .get(api('users.listByStatus')) + .set(credentials) + .query({ hasLoggedIn: true, status: 'active' }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users'); + const { users } = res.body; + const ids = users.map((user) => user._id); + expect(ids).to.include(user._id); + }); + }); + + it('should filter users by role', async () => { + await login(user.username, password); + await request + .get(api('users.listByStatus')) + .set(credentials) + .query({ 'roles[]': 'admin' }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users'); + const { users } = res.body; + const ids = users.map((user) => user._id); + expect(ids).to.not.include(user._id); + }); + }); + + it('should list deactivated users', async () => { + await request.post(api('users.setActiveStatus')).set(credentials).send({ + userId: user._id, + activeStatus: false, + confirmRelinquish: false, + }); + await request + .get(api('users.listByStatus')) + .set(credentials) + .query({ hasLoggedIn: true, status: 'deactivated' }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users'); + const { users } = res.body; + const ids = users.map((user) => user._id); + expect(ids).to.include(user._id); + }); + }); + + it('should filter users by username', async () => { + await request + .get(api('users.listByStatus')) + .set(credentials) + .query({ searchTerm: user.username }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users'); + const { users } = res.body; + const ids = users.map((user) => user._id); + expect(ids).to.include(user._id); + }); + }); + + it('should return error for invalid status params', async () => { + await request + .get(api('users.listByStatus')) + .set(credentials) + .query({ status: 'abcd' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('invalid-params'); + expect(res.body.error).to.be.equal('must be equal to one of the allowed values [invalid-params]'); + }); + }); + + it('should throw unauthorized error to user without "view-d-room" permission', async () => { + await updatePermission('view-d-room', ['admin']); + await request + .get(api('users.listByStatus')) + .set(otherUserCredentials) + .query({ status: 'active' }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + + it('should throw unauthorized error to user without "view-outside-room" permission', async () => { + await updatePermission('view-outside-room', ['admin']); + await request + .get(api('users.listByStatus')) + .set(otherUserCredentials) + .query({ status: 'active' }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + }); + + describe('[/users.sendWelcomeEmail]', async () => { + let user; + let otherUser; + + before(async () => { + user = await createUser(); + otherUser = await createUser(); + }); + + after(async () => { + await deleteUser(user); + await deleteUser(otherUser); + }); + + it('should send Welcome Email to user', async () => { + await updateSetting('SMTP_Host', 'localhost'); + + await request + .post(api('users.sendWelcomeEmail')) + .set(credentials) + .send({ email: user.emails[0].address }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should fail to send Welcome Email due to SMTP settings missing', async () => { + await updateSetting('SMTP_Host', ''); + + await request + .post(api('users.sendWelcomeEmail')) + .set(credentials) + .send({ email: user.emails[0].address }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('SMTP is not configured [error-email-send-failed]'); + }); + }); + + it('should fail to send Welcome Email due to missing param', async () => { + await updateSetting('SMTP_Host', ''); + + await request + .post(api('users.sendWelcomeEmail')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + expect(res.body).to.have.property('error', "must have required property 'email' [invalid-params]"); + }); + }); + + it('should fail to send Welcome Email due missing user', async () => { + await updateSetting('SMTP_Host', 'localhost'); + + await request + .post(api('users.sendWelcomeEmail')) + .set(credentials) + .send({ email: 'fake_user32132131231@rocket.chat' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-user'); + expect(res.body).to.have.property('error', 'Invalid user [error-invalid-user]'); + }); + }); + }); }); diff --git a/packages/rest-typings/src/v1/users.ts b/packages/rest-typings/src/v1/users.ts index 15f9ad840d42a9d8aab3ec6498d1dc526d86f347..bcf99f951664c1762fe4a15ea5a6492b72fe39f0 100644 --- a/packages/rest-typings/src/v1/users.ts +++ b/packages/rest-typings/src/v1/users.ts @@ -10,8 +10,10 @@ import type { UserRegisterParamsPOST } from './users/UserRegisterParamsPOST'; import type { UserSetActiveStatusParamsPOST } from './users/UserSetActiveStatusParamsPOST'; import type { UsersAutocompleteParamsGET } from './users/UsersAutocompleteParamsGET'; import type { UsersInfoParamsGet } from './users/UsersInfoParamsGet'; +import type { UsersListStatusParamsGET } from './users/UsersListStatusParamsGET'; import type { UsersListTeamsParamsGET } from './users/UsersListTeamsParamsGET'; import type { UsersSendConfirmationEmailParamsPOST } from './users/UsersSendConfirmationEmailParamsPOST'; +import type { UsersSendWelcomeEmailParamsPOST } from './users/UsersSendWelcomeEmailParamsPOST'; import type { UsersSetPreferencesParamsPOST } from './users/UsersSetPreferenceParamsPOST'; import type { UsersUpdateOwnBasicInfoParamsPOST } from './users/UsersUpdateOwnBasicInfoParamsPOST'; import type { UsersUpdateParamsPOST } from './users/UsersUpdateParamsPOST'; @@ -110,6 +112,11 @@ export type UserPresence = Readonly< export type UserPersonalTokens = Pick<IPersonalAccessToken, 'name' | 'lastTokenPart' | 'bypassTwoFactor'> & { createdAt: string }; +export type DefaultUserInfo = Pick< + IUser, + '_id' | 'username' | 'name' | 'status' | 'roles' | 'emails' | 'active' | 'avatarETag' | 'lastLogin' | 'type' +>; + export type UsersEndpoints = { '/v1/users.2fa.enableEmail': { POST: () => void; @@ -139,10 +146,20 @@ export type UsersEndpoints = { '/v1/users.list': { GET: (params: PaginatedRequest<{ fields: string }>) => PaginatedResult<{ - users: Pick<IUser, '_id' | 'username' | 'name' | 'status' | 'roles' | 'emails' | 'active' | 'avatarETag'>[]; + users: DefaultUserInfo[]; }>; }; + '/v1/users.listByStatus': { + GET: (params: UsersListStatusParamsGET) => PaginatedResult<{ + users: DefaultUserInfo[]; + }>; + }; + + '/v1/users.sendWelcomeEmail': { + POST: (params: UsersSendWelcomeEmailParamsPOST) => void; + }; + '/v1/users.setAvatar': { POST: (params: UsersSetAvatar) => void; }; @@ -373,6 +390,8 @@ export * from './users/UserCreateParamsPOST'; export * from './users/UserSetActiveStatusParamsPOST'; export * from './users/UserDeactivateIdleParamsPOST'; export * from './users/UsersInfoParamsGet'; +export * from './users/UsersListStatusParamsGET'; +export * from './users/UsersSendWelcomeEmailParamsPOST'; export * from './users/UserRegisterParamsPOST'; export * from './users/UserLogoutParamsPOST'; export * from './users/UsersListTeamsParamsGET'; diff --git a/packages/rest-typings/src/v1/users/UsersListStatusParamsGET.ts b/packages/rest-typings/src/v1/users/UsersListStatusParamsGET.ts new file mode 100644 index 0000000000000000000000000000000000000000..25024aee12cb14377f14e30405dfa0c55d126ee8 --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersListStatusParamsGET.ts @@ -0,0 +1,58 @@ +import Ajv from 'ajv'; + +import type { PaginatedRequest } from '../../helpers/PaginatedRequest'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +export type UsersListStatusParamsGET = PaginatedRequest<{ + status?: 'active' | 'deactivated'; + hasLoggedIn?: boolean; + type?: string; + roles?: string[]; + searchTerm?: string; +}>; +const UsersListStatusParamsGetSchema = { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['active', 'deactivated'], + }, + hasLoggedIn: { + type: 'boolean', + nullable: true, + }, + type: { + type: 'string', + nullable: true, + }, + roles: { + type: 'array', + items: { + type: 'string', + }, + nullable: true, + }, + searchTerm: { + type: 'string', + nullable: true, + }, + sort: { + type: 'string', + nullable: true, + }, + count: { + type: 'number', + nullable: true, + }, + offset: { + type: 'number', + nullable: true, + }, + }, + additionalProperties: false, +}; + +export const isUsersListStatusProps = ajv.compile<UsersListStatusParamsGET>(UsersListStatusParamsGetSchema); diff --git a/packages/rest-typings/src/v1/users/UsersSendWelcomeEmailParamsPOST.ts b/packages/rest-typings/src/v1/users/UsersSendWelcomeEmailParamsPOST.ts new file mode 100644 index 0000000000000000000000000000000000000000..59e85db8a5cb2af801a86d7f4eae41fd764afbec --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersSendWelcomeEmailParamsPOST.ts @@ -0,0 +1,17 @@ +import { ajv } from '../Ajv'; + +export type UsersSendWelcomeEmailParamsPOST = { email: string }; + +const UsersSendWelcomeEmailParamsPostSchema = { + type: 'object', + properties: { + email: { + type: 'string', + format: 'email', + }, + }, + required: ['email'], + additionalProperties: false, +}; + +export const isUsersSendWelcomeEmailProps = ajv.compile<UsersSendWelcomeEmailParamsPOST>(UsersSendWelcomeEmailParamsPostSchema);