Skip to content

Commit

Permalink
Invite mentors (#2481)
Browse files Browse the repository at this point in the history
* feat(notifications): add batch invite for mentors

* fix: refactor

* fix: update copyright
  • Loading branch information
aaliakseyenka authored Jul 14, 2024
1 parent 677142b commit e275ebf
Show file tree
Hide file tree
Showing 14 changed files with 521 additions and 21 deletions.
1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"react-dom": "18.2.0",
"react-markdown": "8.0.7",
"react-masonry-css": "1.0.16",
"react-quill": "2.0.0",
"react-use": "17.4.0",
"remark-gfm": "3.0.1",
"serverless-http": "3.2.0",
Expand Down
96 changes: 96 additions & 0 deletions client/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3385,6 +3385,37 @@ export interface InterviewFeedbackDto {
*/
'maxScore': number;
}
/**
*
* @export
* @interface InviteMentorsDto
*/
export interface InviteMentorsDto {
/**
*
* @type {Array<string>}
* @memberof InviteMentorsDto
*/
'preselectedCourses': Array<string>;
/**
*
* @type {boolean}
* @memberof InviteMentorsDto
*/
'certificate': boolean;
/**
*
* @type {boolean}
* @memberof InviteMentorsDto
*/
'mentor': boolean;
/**
*
* @type {string}
* @memberof InviteMentorsDto
*/
'text': string;
}
/**
*
* @export
Expand Down Expand Up @@ -15735,6 +15766,41 @@ export const RegistryApiAxiosParamCreator = function (configuration?: Configurat
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};

return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {InviteMentorsDto} inviteMentorsDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
inviteMentors: async (inviteMentorsDto: InviteMentorsDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'inviteMentorsDto' is not null or undefined
assertParamExists('inviteMentors', 'inviteMentorsDto', inviteMentorsDto)
const localVarPath = `/registry/mentors/invite`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}

const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;



localVarHeaderParameter['Content-Type'] = 'application/json';

setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(inviteMentorsDto, localVarRequestOptions, configuration)

return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
Expand Down Expand Up @@ -15798,6 +15864,16 @@ export const RegistryApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.getMentorRegistries(pageSize, currentPage, githubId, cityName, preferedCourses, preselectedCourses, technicalMentoring, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {InviteMentorsDto} inviteMentorsDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async inviteMentors(inviteMentorsDto: InviteMentorsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.inviteMentors(inviteMentorsDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
};

Expand Down Expand Up @@ -15852,6 +15928,15 @@ export const RegistryApiFactory = function (configuration?: Configuration, baseP
getMentorRegistries(pageSize?: number, currentPage?: number, githubId?: string, cityName?: string, preferedCourses?: Array<number>, preselectedCourses?: Array<number>, technicalMentoring?: Array<string>, options?: any): AxiosPromise<FilterMentorRegistryResponse> {
return localVarFp.getMentorRegistries(pageSize, currentPage, githubId, cityName, preferedCourses, preselectedCourses, technicalMentoring, options).then((request) => request(axios, basePath));
},
/**
*
* @param {InviteMentorsDto} inviteMentorsDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
inviteMentors(inviteMentorsDto: InviteMentorsDto, options?: any): AxiosPromise<void> {
return localVarFp.inviteMentors(inviteMentorsDto, options).then((request) => request(axios, basePath));
},
};
};

Expand Down Expand Up @@ -15913,6 +15998,17 @@ export class RegistryApi extends BaseAPI {
public getMentorRegistries(pageSize?: number, currentPage?: number, githubId?: string, cityName?: string, preferedCourses?: Array<number>, preselectedCourses?: Array<number>, technicalMentoring?: Array<string>, options?: AxiosRequestConfig) {
return RegistryApiFp(this.configuration).getMentorRegistries(pageSize, currentPage, githubId, cityName, preferedCourses, preselectedCourses, technicalMentoring, options).then((request) => request(this.axios, this.basePath));
}

/**
*
* @param {InviteMentorsDto} inviteMentorsDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof RegistryApi
*/
public inviteMentors(inviteMentorsDto: InviteMentorsDto, options?: AxiosRequestConfig) {
return RegistryApiFp(this.configuration).inviteMentors(inviteMentorsDto, options).then((request) => request(this.axios, this.basePath));
}
}


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Alert, Checkbox, Form, message, Select, Space, Spin } from 'antd';
import { useAsync } from 'react-use';
import { InviteMentorsDto } from 'api';
import { ModalForm } from 'components/Forms';
import { useLoading } from 'components/useLoading';
import ReactQuill from 'react-quill';
import { MentorRegistryService } from 'services/mentorRegistry';
import { DisciplinesApi } from 'api';

import 'react-quill/dist/quill.snow.css';

type Props = {
onCancel: () => void;
};
const mentorRegistryService = new MentorRegistryService();
const disciplinesApi = new DisciplinesApi();

function InviteMentorsModal({ onCancel }: Props) {
const [loading, withLoading] = useLoading(false);
const submit = withLoading(async (data: InviteMentorsDto) => {
await mentorRegistryService.inviteMentors(data);
message.success('Invitation successfully send.');
onCancel();
});

const { loading: disciplinesLoading, value: disciplines = [] } = useAsync(async () => {
const { data } = await disciplinesApi.getDisciplines();
return data;
}, []);

return (
<ModalForm data={{}} title="Invite as a Mentor" submit={submit} cancel={onCancel} loading={loading}>
<Space direction="vertical" style={{ width: '100%' }}>
<Alert showIcon message="Invitation will be send to all mentors meeting the criteria below." type="info" />
<Form.Item
name="disciplines"
label="Disciplines"
style={formItemStyle}
rules={[{ required: true, message: 'Please select disciplines.' }]}
>
<Select
mode="multiple"
optionFilterProp="children"
notFoundContent={disciplinesLoading ? <Spin size="small" /> : null}
>
{disciplines.map(discipline => (
<Select.Option key={discipline.id} value={discipline.id}>
{discipline.name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item name="isMentor" style={formItemStyle} valuePropName="checked">
<Checkbox>Mentor in the Past</Checkbox>
</Form.Item>
<Form.Item
name="text"
label="Invitation Text"
style={formItemStyle}
rules={[{ required: true, message: 'Please add invitation text.' }]}
>
<ReactQuill theme="snow" placeholder="Write an invitation message" />
</Form.Item>
</Space>
</ModalForm>
);
}

const formItemStyle = { marginBottom: 0 };

export default InviteMentorsModal;
31 changes: 21 additions & 10 deletions client/src/pages/admin/mentor-registry.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useCallback, useState, useMemo, useEffect } from 'react';
import { useCallback, useState, useMemo, useEffect, useContext } from 'react';
import { useAsync } from 'react-use';
import FileExcelOutlined from '@ant-design/icons/FileExcelOutlined';
import { Alert, Button, Col, Form, message, notification, Row, Select, Tabs, Typography } from 'antd';
import { Alert, Button, Col, Form, message, notification, Row, Select, Space, Tabs, Typography } from 'antd';

import { DisciplineDto, DisciplinesApi, MentorRegistryDto } from 'api';

Expand All @@ -18,8 +18,13 @@ import { AdminPageLayout } from 'components/PageLayout';
import { tabRenderer } from 'components/TabsWithCounter/renderers';
import css from 'styled-jsx/css';
import { CommentModal } from 'components/CommentModal';
import { ActiveCourseProvider, SessionProvider } from 'modules/Course/contexts';
import { ActiveCourseProvider, SessionContext, SessionProvider } from 'modules/Course/contexts';
import { CoursesService } from 'services/courses';
import dynamic from 'next/dynamic';

const InviteMentorsModal = dynamic(() => import('modules/MentorRegistry/components/InviteMentorsModal'), {
ssr: false,
});

type NotificationType = 'success' | 'info' | 'warning' | 'error';

Expand All @@ -28,6 +33,7 @@ export enum ModalDataMode {
Resend = 'resend',
Delete = 'delete',
Comment = 'comment',
BatchInvite = 'batchInvite',
}

type ModalData = Partial<{
Expand All @@ -41,6 +47,7 @@ const disciplinesApi = new DisciplinesApi();

function Page() {
const [loading, withLoading] = useLoading(false);
const session = useContext(SessionContext);

const [api, contextHolder] = notification.useNotification();

Expand Down Expand Up @@ -195,13 +202,16 @@ function Page() {
<AdminPageLayout title="Mentor Registry" loading={loading} courses={courses} styles={{ margin: 0, padding: 0 }}>
<Row justify="space-between" style={{ padding: '0 24px', minHeight: 64 }} align="bottom" className="tabs">
<Tabs tabBarStyle={{ margin: '0' }} activeKey={activeTab} items={tabs} onChange={handleTabChange} />
<Button
icon={<FileExcelOutlined />}
style={{ alignSelf: 'center' }}
onClick={() => (window.location.href = `/api/registry/mentors/csv`)}
>
Export CSV
</Button>
<Space style={{ alignSelf: 'center' }}>
<Button icon={<FileExcelOutlined />} onClick={() => (window.location.href = `/api/registry/mentors/csv`)}>
Export CSV
</Button>
{session.isAdmin && (
<Button type="primary" onClick={() => setModalData({ mode: ModalDataMode.BatchInvite })}>
Invite mentors
</Button>
)}
</Space>
<style jsx>{styles}</style>
</Row>
<Col style={{ background: '#f0f2f5', padding: 24 }}>
Expand Down Expand Up @@ -254,6 +264,7 @@ function Page() {
}}
/>
)}
{modalData?.mode === ModalDataMode.BatchInvite && <InviteMentorsModal onCancel={onCancelModal} />}
{contextHolder}
</AdminPageLayout>
);
Expand Down
6 changes: 5 additions & 1 deletion client/src/services/mentorRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import axios, { AxiosInstance, AxiosResponse } from 'axios';
import { MentorRegistryDto, RegistryApi } from 'api';
import { MentorRegistryDto, RegistryApi, InviteMentorsDto } from 'api';
import { PreferredStudentsLocation } from 'common/enums/mentor';

export type MentorResponse = {
Expand Down Expand Up @@ -88,4 +88,8 @@ export class MentorRegistryService {
const response = await this.axios.get<AxiosResponse<MentorResponse>>(`/mentor`);
return response.data.data;
}

public async inviteMentors(data: InviteMentorsDto) {
await this.registryApi.inviteMentors(data);
}
}
2 changes: 1 addition & 1 deletion nestjs/src/notifications/email-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ export const emailTemplate = `<!DOCTYPE html>
valign="top"
align="center"
>
<p>© The Rolling Scopes 2022</p>
<p>© The Rolling Scopes 2024</p>
<a
href="https://app.rs.school/profile/notifications"
style="text-decoration: underline; color: #999999; font-size: 12px; text-align: center"
Expand Down
16 changes: 10 additions & 6 deletions nestjs/src/notifications/notifications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,20 +66,21 @@ export class NotificationsService {
}

/**
* Messages to users regarless on user subscription status to specific channel
* Messages to users regardless on user subscription status to specific channel
*/
public async sendMessage(notification: {
notificationId: NotificationId;
userId: number;
data: object;
channelId: NotificationChannelId;
channelValue: string;
noEscape?: boolean;
}) {
const { userId, data, notificationId, channelId, channelValue } = notification;
const { userId, data, notificationId, channelId, channelValue, noEscape } = notification;
const channelSettings = await this.getChannelSettings(channelId, notificationId);

const message = channelSettings
? this.buildChannelMessage({ ...channelSettings, externalId: channelValue }, data)
? this.buildChannelMessage({ ...channelSettings, externalId: channelValue, noEscape }, data)
: null;

if (!message) {
Expand Down Expand Up @@ -111,11 +112,14 @@ export class NotificationsService {
});
}

public buildChannelMessage(channel: NotificationChannelSettings & { externalId?: string }, data: object) {
const { channelId, externalId, template } = channel;
public buildChannelMessage(
channel: NotificationChannelSettings & { externalId?: string; noEscape?: boolean },
data: object,
) {
const { channelId, externalId, template, noEscape } = channel;
if (!externalId || !template) return;

const body = compile(channel.template.body)(data);
const body = compile(channel.template.body, { noEscape })(data);
const channelMessage = {
channelId,
to: externalId,
Expand Down
17 changes: 17 additions & 0 deletions nestjs/src/registry/dto/invite-mentors.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator';

export class InviteMentorsDto {
@ApiProperty()
@IsArray()
disciplines: string[];

@ApiProperty()
@IsBoolean()
@IsOptional()
isMentor?: boolean;

@ApiProperty()
@IsString()
text: string;
}
10 changes: 9 additions & 1 deletion nestjs/src/registry/registry.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, Put, Req, UseGuards, Query, ParseArrayPipe } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Put, Req, UseGuards, Query, ParseArrayPipe, Post } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { uniq } from 'lodash';
import { CourseRole, CurrentRequest, DefaultGuard, RequiredRoles, Role, RoleGuard } from 'src/auth';
Expand All @@ -12,6 +12,7 @@ import { CommentMentorRegistryDto } from './dto/comment-mentor-registry.dto';
import { FilterMentorRegistryResponse } from './dto/mentor-registry.dto';
import { DEFAULT_PAGE_NUMBER, DEFAULT_PAGE_SIZE } from './constants';
import { CourseInfo } from '@entities/session';
import { InviteMentorsDto } from './dto/invite-mentors.dto';

@Controller('registry')
@ApiTags('registry')
Expand Down Expand Up @@ -121,4 +122,11 @@ export class RegistryController {
const disciplines = await this.disciplinesService.getByIds(disciplineIds);
return disciplines.map(discipline => discipline.name);
}

@Post('mentors/invite')
@ApiOperation({ operationId: 'inviteMentors' })
@RequiredRoles([Role.Admin])
public async inviteMentors(@Body() body: InviteMentorsDto) {
await this.registryService.sendInvitationsToMentors(body);
}
}
Loading

0 comments on commit e275ebf

Please sign in to comment.