- Updated package version to 3.0.0
- Improved FCM topic handling logic
- Enhanced schedule parser accuracy
- Removed HTTPS options for dev simplicity
- Added detailed API documentation
- Removed support for older api versions
This commit is contained in:
2025-01-25 22:51:33 +04:00
parent 1174f61487
commit 50325c3862
10 changed files with 160 additions and 53 deletions

View File

@@ -27,7 +27,7 @@ enum UserRole {
type FCM { type FCM {
token String token String
topics Json topics String[]
} }
model User { model User {

View File

@@ -25,12 +25,12 @@ export class AuthService {
) {} ) {}
/** /**
* Получение пользователя по его токену * Получает пользователя по его JWT токену
* @param token - jwt токен * @param {string} token - JWT токен для аутентификации
* @returns {User} - пользователь * @returns {Promise<User>} - Объект пользователя, если токен валиден
* @throws {UnauthorizedException} - некорректный или недействительный токен * @throws {UnauthorizedException} - Если токен некорректен или недействителен
* @throws {UnauthorizedException} - токен указывает на несуществующего пользователя * @throws {UnauthorizedException} - Если пользователь, указанный в токене, не существует
* @throws {UnauthorizedException} - текущий токен устарел и был обновлён на новый * @throws {UnauthorizedException} - Если токен устарел и был заменён на новый
* @async * @async
*/ */
async decodeUserToken(token: string): Promise<User> { async decodeUserToken(token: string): Promise<User> {
@@ -52,6 +52,13 @@ export class AuthService {
return user; return user;
} }
/**
* Регистрирует нового пользователя в системе.
*
* @param signUpDto - Объект с данными для регистрации, включая имя пользователя, пароль, роль и группу.
* @returns Возвращает объект UserDto с данными зарегистрированного пользователя или объект SignUpErrorDto в случае ошибки.
* @throws SignUpErrorDto - Если роль пользователя недопустима или имя пользователя уже существует.
*/
async signUp(signUpDto: SignUpDto): Promise<UserDto | SignUpErrorDto> { async signUp(signUpDto: SignUpDto): Promise<UserDto | SignUpErrorDto> {
if (![UserRole.STUDENT, UserRole.TEACHER].includes(signUpDto.role)) if (![UserRole.STUDENT, UserRole.TEACHER].includes(signUpDto.role))
return new SignUpErrorDto(SignUpErrorCode.DISALLOWED_ROLE); return new SignUpErrorDto(SignUpErrorCode.DISALLOWED_ROLE);
@@ -75,6 +82,23 @@ export class AuthService {
); );
} }
/**
* Асинхронная функция для входа пользователя в систему.
*
* @param {SignInDto} signIn - Объект, содержащий данные для входа (имя пользователя и пароль).
* @returns {Promise<UserDto | SignInErrorDto>} - Возвращает объект UserDto в случае успешного входа или SignInErrorDto в случае ошибки.
*
* @throws {SignInErrorDto} - Если пользователь не найден или пароль неверный, возвращается объект SignInErrorDto с кодом ошибки INCORRECT_CREDENTIALS.
*
* @example
* const signInData = { username: 'user123', password: 'password123' };
* const result = await signIn(signInData);
* if (result instanceof UserDto) {
* console.log('Вход выполнен успешно:', result);
* } else {
* console.log('Ошибка входа:', result);
* }
*/
async signIn(signIn: SignInDto): Promise<UserDto | SignInErrorDto> { async signIn(signIn: SignInDto): Promise<UserDto | SignInErrorDto> {
const user = await this.usersService.findUnique({ const user = await this.usersService.findUnique({
username: signIn.username, username: signIn.username,
@@ -96,6 +120,20 @@ export class AuthService {
); );
} }
/**
* Парсит VK ID пользователя по access token
*
* @param accessToken - Access token пользователя VK
* @returns Promise, который разрешается в VK ID пользователя или null в случае ошибки
*
* @example
* const vkId = await parseVKID('access_token_here');
* if (vkId) {
* console.log(`VK ID пользователя: ${vkId}`);
* } else {
* console.error('Ошибка при получении VK ID');
* }
*/
private static async parseVKID(accessToken: string): Promise<number> { private static async parseVKID(accessToken: string): Promise<number> {
const form = new FormData(); const form = new FormData();
form.append("access_token", accessToken); form.append("access_token", accessToken);
@@ -115,6 +153,12 @@ export class AuthService {
return data.response.id; return data.response.id;
} }
/**
* Регистрация пользователя через VK
* @param signUpDto - DTO с данными для регистрации через VK
* @returns Promise<UserDto | SignUpErrorDto> - возвращает DTO пользователя в случае успешной регистрации
* или DTO ошибки в случае возникновения проблем
*/
async signUpVK(signUpDto: SignUpVKDto): Promise<UserDto | SignUpErrorDto> { async signUpVK(signUpDto: SignUpVKDto): Promise<UserDto | SignUpErrorDto> {
if (![UserRole.STUDENT, UserRole.TEACHER].includes(signUpDto.role)) if (![UserRole.STUDENT, UserRole.TEACHER].includes(signUpDto.role))
return new SignUpErrorDto(SignUpErrorCode.DISALLOWED_ROLE); return new SignUpErrorDto(SignUpErrorCode.DISALLOWED_ROLE);
@@ -148,6 +192,11 @@ export class AuthService {
); );
} }
/**
* Авторизация пользователя через VK
* @param signInVKDto - DTO с данными для авторизации через VK
* @returns Promise<UserDto | SignInErrorDto> - возвращает DTO пользователя в случае успешной авторизации или DTO ошибки в случае неудачи
*/
async signInVK( async signInVK(
signInVKDto: SignInVKDto, signInVKDto: SignInVKDto,
): Promise<UserDto | SignInErrorDto> { ): Promise<UserDto | SignInErrorDto> {
@@ -172,11 +221,12 @@ export class AuthService {
/** /**
* Смена пароля пользователя * Смена пароля пользователя
* @param user - пользователь * @param user - пользователь, для которого меняется пароль
* @param changePassword - старый и новый пароли * @param changePassword - объект, содержащий старый и новый пароли
* @throws {ConflictException} - пароли идентичны * @throws {ConflictException} - выбрасывается, если старый и новый пароли идентичны
* @throws {UnauthorizedException} - неверный исходный пароль * @throws {UnauthorizedException} - выбрасывается, если передан неверный исходный пароль
* @async * @async
* @returns {Promise<void>} - возвращает Promise, который разрешается, когда пароль успешно изменен
*/ */
async changePassword( async changePassword(
user: User, user: User,
@@ -187,13 +237,13 @@ export class AuthService {
if (oldPassword == newPassword) if (oldPassword == newPassword)
throw new ConflictException("Пароли идентичны"); throw new ConflictException("Пароли идентичны");
if (user.password !== (await hash(oldPassword, user.salt))) if (!(await compare(oldPassword, user.password)))
throw new UnauthorizedException("Передан неверный исходный пароль"); throw new UnauthorizedException("Передан неверный исходный пароль");
await this.usersService.update({ await this.usersService.update({
where: { id: user.id }, where: { id: user.id },
data: { data: {
password: await hash(newPassword, user.salt), password: await hash(newPassword, await genSalt(8)),
}, },
}); });
} }

View File

@@ -4,8 +4,8 @@ import {
Controller, Controller,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Param, Param, Patch,
Post, Post, Query,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { AuthGuard } from "../auth/auth.guard"; import { AuthGuard } from "../auth/auth.guard";
@@ -54,11 +54,11 @@ export class FirebaseAdminController {
status: HttpStatus.OK, status: HttpStatus.OK,
description: "Установка токена удалась", description: "Установка токена удалась",
}) })
@Post("set-token/:token") @Patch("set-token")
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ResultDto(null) @ResultDto(null)
async setToken( async setToken(
@Param("token") token: string, @Query("token") token: string,
@UserToken(UserPipe) user: User, @UserToken(UserPipe) user: User,
): Promise<void> { ): Promise<void> {
if (user.fcm?.token === token) return; if (user.fcm?.token === token) return;

View File

@@ -86,17 +86,17 @@ export class FirebaseAdminService implements OnModuleInit {
if (!user.fcm) throw new Error("User does not have fcm data!"); if (!user.fcm) throw new Error("User does not have fcm data!");
const fcm = user.fcm; const fcm = user.fcm;
const newTopics = new Set<string>(); const userTopics = new Set<string>([...fcm.topics]);
for (const topic of topics) { for (const topic of topics) {
if (!fcm.topics.includes(topic)) continue; if (!fcm.topics.includes(topic)) continue;
await this.messaging.unsubscribeFromTopic(fcm.token, topic); await this.messaging.unsubscribeFromTopic(fcm.token, topic);
newTopics.add(topic); userTopics.delete(topic);
} }
if (newTopics.size === fcm.topics.length) return user; if (userTopics.size === fcm.topics.length) return user;
fcm.topics = Array.from(newTopics); fcm.topics = Array.from(userTopics);
return await this.usersService.update({ return await this.usersService.update({
where: { id: user.id }, where: { id: user.id },
@@ -118,10 +118,12 @@ export class FirebaseAdminService implements OnModuleInit {
if (force) if (force)
await this.messaging.unsubscribeFromTopic(fcm.token, topic); await this.messaging.unsubscribeFromTopic(fcm.token, topic);
else if (fcm.topics.includes(topic)) continue; else if (fcm.topics.includes(topic)) continue;
else newTopics.add(topic);
newTopics.add(topic);
await this.messaging.subscribeToTopic(fcm.token, topic); await this.messaging.subscribeToTopic(fcm.token, topic);
} }
if (newTopics.size === fcm.topics.length) return user; if (newTopics.size === fcm.topics.length) return user;
fcm.topics = Array.from(newTopics); fcm.topics = Array.from(newTopics);

View File

@@ -3,8 +3,7 @@ import { AppModule } from "./app.module";
import { ValidatorOptions } from "class-validator"; import { ValidatorOptions } from "class-validator";
import { PartialValidationPipe } from "./utility/validation/partial-validation.pipe"; import { PartialValidationPipe } from "./utility/validation/partial-validation.pipe";
import { ClassValidatorInterceptor } from "./utility/validation/class-validator.interceptor"; import { ClassValidatorInterceptor } from "./utility/validation/class-validator.interceptor";
import { apiConstants, httpsConstants } from "./contants"; import { apiConstants } from "./contants";
import * as fs from "node:fs";
import { VersioningType } from "@nestjs/common"; import { VersioningType } from "@nestjs/common";
import { import {
FastifyAdapter, FastifyAdapter,
@@ -16,12 +15,6 @@ async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>( const app = await NestFactory.create<NestFastifyApplication>(
AppModule, AppModule,
new FastifyAdapter(), new FastifyAdapter(),
{
httpsOptions: {
cert: fs.readFileSync(httpsConstants.certPath),
key: fs.readFileSync(httpsConstants.keyPath),
},
},
); );
const validatorOptions: ValidatorOptions = { const validatorOptions: ValidatorOptions = {
enableDebugMessages: true, enableDebugMessages: true,
@@ -33,13 +26,13 @@ async function bootstrap() {
app.enableCors(); app.enableCors();
app.setGlobalPrefix("api"); app.setGlobalPrefix("api");
app.enableVersioning({ app.enableVersioning({ type: VersioningType.URI });
type: VersioningType.URI,
});
const swaggerConfig = new DocumentBuilder() const swaggerConfig = new DocumentBuilder()
.setTitle("Schedule Parser") .setTitle("Schedule Parser")
.setDescription("Парсер расписания") .setDescription(
"API для парсинга и управления расписанием учебных занятий",
)
.setVersion(apiConstants.version) .setVersion(apiConstants.version)
.build(); .build();
@@ -49,8 +42,12 @@ async function bootstrap() {
swaggerDocument.servers = [ swaggerDocument.servers = [
{ {
url: `https://localhost:${apiConstants.port}`, url: "https://polytechnic-dev.n08i40k.ru",
description: "Локальный сервер для разработки", description: "Сервер для разработки и тестирования",
},
{
url: "https://polytechnic.n08i40k.ru",
description: "Сервер для продакшн окружения",
}, },
]; ];

View File

@@ -16,14 +16,14 @@ export default class CacheStatusDto {
cacheUpdateRequired: boolean; cacheUpdateRequired: boolean;
/** /**
* Дата обновления кеша * Время последнего обновления кеша в формате timestamp
* @example 1729288173002 * @example 1729288173002
*/ */
@IsNumber() @IsNumber()
lastCacheUpdate: number; lastCacheUpdate: number;
/** /**
* Дата обновления расписания * Время последнего обновления расписания в формате timestamp
* @example 1729288173002 * @example 1729288173002
*/ */
@IsNumber() @IsNumber()

View File

@@ -69,13 +69,14 @@ describe("ScheduleParser", () => {
); );
expect(schedule).toBeDefined(); expect(schedule).toBeDefined();
const group: Group | undefined = schedule.groups.get("ИС-214/23"); const group: Group | undefined = schedule.groups.get("ИС-114/23");
expect(group).toBeDefined(); expect(group).toBeDefined();
const day = group.days[0]; const day = group.days[0];
expect(day).toBeDefined(); expect(day).toBeDefined();
expect(day.lessons.length).toBeGreaterThan(0); expect(day.lessons.length).toBeGreaterThan(0);
expect(day.lessons[0].name).toBe("Линейка");
}); });
}); });
}); });

View File

@@ -3,7 +3,7 @@ import { XlsDownloaderInterface } from "../xls-downloader/xls-downloader.interfa
import * as XLSX from "xlsx"; import * as XLSX from "xlsx";
import { Range, WorkSheet } from "xlsx"; import { Range, WorkSheet } from "xlsx";
import { toNormalString, trimAll } from "../../../utility/string.util"; import { toNormalString, trimAll } from "../../../utility/string.util";
import { plainToClass, plainToInstance, Type } from "class-transformer"; import { plainToInstance, Type } from "class-transformer";
import * as objectHash from "object-hash"; import * as objectHash from "object-hash";
import LessonTime from "../../entities/lesson-time.entity"; import LessonTime from "../../entities/lesson-time.entity";
import { LessonType } from "../../enum/lesson-type.enum"; import { LessonType } from "../../enum/lesson-type.enum";
@@ -607,7 +607,7 @@ export class ScheduleParser {
} }
private static readonly consultationRegExp = /\(?[кК]онсультация\)?/; private static readonly consultationRegExp = /\(?[кК]онсультация\)?/;
private static readonly otherStreetRegExp = /^[А-Я][а-я]+,\s?[0-9]+$/; private static readonly otherStreetRegExp = /^[А-Я][а-я]+,?\s?[0-9]+$/;
private static parseLesson( private static parseLesson(
workSheet: XLSX.Sheet, workSheet: XLSX.Sheet,
@@ -689,17 +689,25 @@ export class ScheduleParser {
column + 1, column + 1,
); );
// Если количество кабинетов равно 1, назначаем этот кабинет всем подгруппам
if (cabinets.length === 1) { if (cabinets.length === 1) {
// eslint-disable-next-line @typescript-eslint/no-for-in-array // eslint-disable-next-line @typescript-eslint/no-for-in-array
for (const index in lessonData.subGroups) for (const index in lessonData.subGroups)
lessonData.subGroups[index].cabinet = cabinets[0]; lessonData.subGroups[index].cabinet = cabinets[0] ?? "";
} else if (cabinets.length === lessonData.subGroups.length) { }
// Если количество кабинетов совпадает с количеством подгрупп, назначаем кабинеты по порядку
else if (cabinets.length === lessonData.subGroups.length) {
// eslint-disable-next-line @typescript-eslint/no-for-in-array // eslint-disable-next-line @typescript-eslint/no-for-in-array
for (const index in lessonData.subGroups) { for (const index in lessonData.subGroups) {
lessonData.subGroups[index].cabinet = lessonData.subGroups[index].cabinet =
cabinets[lessonData.subGroups[index].number - 1]; cabinets[lessonData.subGroups[index].number - 1] ??
cabinets[0] ??
"";
} }
} else if (cabinets.length !== 0) { }
// Если количество кабинетов не равно нулю, но не совпадает с количеством подгрупп
else if (cabinets.length !== 0) {
// Если кабинетов больше, чем подгрупп, добавляем новые подгруппы с ошибкой
if (cabinets.length > lessonData.subGroups.length) { if (cabinets.length > lessonData.subGroups.length) {
// eslint-disable-next-line @typescript-eslint/no-for-in-array // eslint-disable-next-line @typescript-eslint/no-for-in-array
for (const index in cabinets) { for (const index in cabinets) {
@@ -717,7 +725,14 @@ export class ScheduleParser {
lessonData.subGroups[index].cabinet = cabinets[index]; lessonData.subGroups[index].cabinet = cabinets[index];
} }
} else throw new Error("Разное кол-во кабинетов и подгрупп!"); }
// Если кабинетов меньше, чем подгрупп, выбрасываем ошибку
else throw new Error("Разное кол-во кабинетов и подгрупп!");
}
// Если кабинетов нет, но есть подгруппы, назначаем им значение "??"
else if (lessonData.subGroups.length !== 0) {
for (const subGroup of lessonData.subGroups)
subGroup.cabinet = "??";
} }
} }

View File

@@ -17,6 +17,9 @@ import TeacherSchedule from "./entities/teacher-schedule.entity";
import GetGroupNamesDto from "./dto/get-group-names.dto"; import GetGroupNamesDto from "./dto/get-group-names.dto";
import TeacherNamesDto from "./dto/teacher-names.dto"; import TeacherNamesDto from "./dto/teacher-names.dto";
/**
* Сервис для работы с расписанием
*/
@Injectable() @Injectable()
export class ScheduleService { export class ScheduleService {
readonly scheduleParser: ScheduleParser; readonly scheduleParser: ScheduleParser;
@@ -26,6 +29,12 @@ export class ScheduleService {
private scheduleUpdatedAt: Date = new Date(0); private scheduleUpdatedAt: Date = new Date(0);
/**
* Конструктор сервиса
* @param cacheManager Менеджер кэша
* @param scheduleReplacerService Сервис замены расписания
* @param firebaseAdminService Сервис работы с Firebase
*/
constructor( constructor(
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache, @Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
private readonly scheduleReplacerService: ScheduleReplacerService, private readonly scheduleReplacerService: ScheduleReplacerService,
@@ -54,6 +63,10 @@ export class ScheduleService {
); );
} }
/**
* Получение статуса кэша
* @returns Объект с информацией о состоянии кэша
*/
getCacheStatus(): CacheStatusDto { getCacheStatus(): CacheStatusDto {
return plainToInstance(CacheStatusDto, { return plainToInstance(CacheStatusDto, {
cacheHash: this.cacheHash, cacheHash: this.cacheHash,
@@ -65,6 +78,10 @@ export class ScheduleService {
}); });
} }
/**
* Получение исходного расписания
* @returns Результат парсинга расписания
*/
async getSourceSchedule(): Promise<ScheduleParseResult> { async getSourceSchedule(): Promise<ScheduleParseResult> {
const schedule = await this.scheduleParser.getSchedule(); const schedule = await this.scheduleParser.getSchedule();
@@ -93,6 +110,10 @@ export class ScheduleService {
return schedule; return schedule;
} }
/**
* Получение расписания
* @returns Объект расписания
*/
async getSchedule(): Promise<Schedule> { async getSchedule(): Promise<Schedule> {
const sourceSchedule = await this.getSourceSchedule(); const sourceSchedule = await this.getSourceSchedule();
@@ -103,6 +124,12 @@ export class ScheduleService {
}; };
} }
/**
* Получение расписания для группы
* @param name Название группы
* @returns Расписание группы
* @throws NotFoundException Если группа не найдена
*/
async getGroup(name: string): Promise<GroupSchedule> { async getGroup(name: string): Promise<GroupSchedule> {
const schedule = await this.getSourceSchedule(); const schedule = await this.getSourceSchedule();
@@ -120,6 +147,10 @@ export class ScheduleService {
}; };
} }
/**
* Получение списка названий групп
* @returns Объект с массивом названий групп
*/
async getGroupNames(): Promise<GetGroupNamesDto> { async getGroupNames(): Promise<GetGroupNamesDto> {
const schedule = await this.getSourceSchedule(); const schedule = await this.getSourceSchedule();
const names: Array<string> = []; const names: Array<string> = [];
@@ -131,6 +162,12 @@ export class ScheduleService {
}); });
} }
/**
* Получение расписания для преподавателя
* @param name ФИО преподавателя
* @returns Расписание преподавателя
* @throws NotFoundException Если преподаватель не найден
*/
async getTeacher(name: string): Promise<TeacherSchedule> { async getTeacher(name: string): Promise<TeacherSchedule> {
const schedule = await this.getSourceSchedule(); const schedule = await this.getSourceSchedule();
@@ -148,6 +185,10 @@ export class ScheduleService {
}; };
} }
/**
* Получение списка ФИО преподавателей
* @returns Объект с массивом ФИО преподавателей
*/
async getTeacherNames(): Promise<TeacherNamesDto> { async getTeacherNames(): Promise<TeacherNamesDto> {
const schedule = await this.getSourceSchedule(); const schedule = await this.getSourceSchedule();
const names: Array<string> = []; const names: Array<string> = [];
@@ -162,6 +203,11 @@ export class ScheduleService {
}); });
} }
/**
* Обновление URL для загрузки расписания
* @param url Новый URL
* @returns Объект с информацией о состоянии кэша
*/
async updateDownloadUrl(url: string): Promise<CacheStatusDto> { async updateDownloadUrl(url: string): Promise<CacheStatusDto> {
await this.scheduleParser.getXlsDownloader().setDownloadUrl(url); await this.scheduleParser.getXlsDownloader().setDownloadUrl(url);
@@ -170,6 +216,9 @@ export class ScheduleService {
return this.getCacheStatus(); return this.getCacheStatus();
} }
/**
* Обновление кэша
*/
async refreshCache() { async refreshCache() {
await this.cacheManager.clear(); await this.cacheManager.clear();

View File

@@ -33,13 +33,6 @@ export default class User {
@MaxLength(20) @MaxLength(20)
username: string; username: string;
/**
* Соль пароля
* @example "$2b$08$34xwFv1WVJpvpVi3tZZuv."
*/
@IsString()
salt: string;
/** /**
* Хеш пароля * Хеш пароля
* @example "$2b$08$34xwFv1WVJpvpVi3tZZuv." * @example "$2b$08$34xwFv1WVJpvpVi3tZZuv."