From 50325c3862ad7236fb83586fd560b61752b6ea50 Mon Sep 17 00:00:00 2001 From: N08I40K Date: Sat, 25 Jan 2025 22:51:33 +0400 Subject: [PATCH] 3.0.0. - 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 --- prisma/schema.prisma | 2 +- src/auth/auth.service.ts | 74 ++++++++++++++++--- .../firebase-admin.controller.ts | 8 +- src/firebase-admin/firebase-admin.service.ts | 12 +-- src/main.ts | 25 +++---- src/schedule/dto/cache-status.dto.ts | 4 +- .../schedule-parser/schedule-parser.spec.ts | 3 +- .../schedule-parser/schedule-parser.ts | 29 ++++++-- src/schedule/schedule.service.ts | 49 ++++++++++++ src/users/entity/user.entity.ts | 7 -- 10 files changed, 160 insertions(+), 53 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8d719db..db49829 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -27,7 +27,7 @@ enum UserRole { type FCM { token String - topics Json + topics String[] } model User { diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index ba8fdb3..932797c 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -25,12 +25,12 @@ export class AuthService { ) {} /** - * Получение пользователя по его токену - * @param token - jwt токен - * @returns {User} - пользователь - * @throws {UnauthorizedException} - некорректный или недействительный токен - * @throws {UnauthorizedException} - токен указывает на несуществующего пользователя - * @throws {UnauthorizedException} - текущий токен устарел и был обновлён на новый + * Получает пользователя по его JWT токену + * @param {string} token - JWT токен для аутентификации + * @returns {Promise} - Объект пользователя, если токен валиден + * @throws {UnauthorizedException} - Если токен некорректен или недействителен + * @throws {UnauthorizedException} - Если пользователь, указанный в токене, не существует + * @throws {UnauthorizedException} - Если токен устарел и был заменён на новый * @async */ async decodeUserToken(token: string): Promise { @@ -52,6 +52,13 @@ export class AuthService { return user; } + /** + * Регистрирует нового пользователя в системе. + * + * @param signUpDto - Объект с данными для регистрации, включая имя пользователя, пароль, роль и группу. + * @returns Возвращает объект UserDto с данными зарегистрированного пользователя или объект SignUpErrorDto в случае ошибки. + * @throws SignUpErrorDto - Если роль пользователя недопустима или имя пользователя уже существует. + */ async signUp(signUpDto: SignUpDto): Promise { if (![UserRole.STUDENT, UserRole.TEACHER].includes(signUpDto.role)) return new SignUpErrorDto(SignUpErrorCode.DISALLOWED_ROLE); @@ -75,6 +82,23 @@ export class AuthService { ); } + /** + * Асинхронная функция для входа пользователя в систему. + * + * @param {SignInDto} signIn - Объект, содержащий данные для входа (имя пользователя и пароль). + * @returns {Promise} - Возвращает объект 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 { const user = await this.usersService.findUnique({ 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 { const form = new FormData(); form.append("access_token", accessToken); @@ -115,6 +153,12 @@ export class AuthService { return data.response.id; } + /** + * Регистрация пользователя через VK + * @param signUpDto - DTO с данными для регистрации через VK + * @returns Promise - возвращает DTO пользователя в случае успешной регистрации + * или DTO ошибки в случае возникновения проблем + */ async signUpVK(signUpDto: SignUpVKDto): Promise { if (![UserRole.STUDENT, UserRole.TEACHER].includes(signUpDto.role)) return new SignUpErrorDto(SignUpErrorCode.DISALLOWED_ROLE); @@ -148,6 +192,11 @@ export class AuthService { ); } + /** + * Авторизация пользователя через VK + * @param signInVKDto - DTO с данными для авторизации через VK + * @returns Promise - возвращает DTO пользователя в случае успешной авторизации или DTO ошибки в случае неудачи + */ async signInVK( signInVKDto: SignInVKDto, ): Promise { @@ -172,11 +221,12 @@ export class AuthService { /** * Смена пароля пользователя - * @param user - пользователь - * @param changePassword - старый и новый пароли - * @throws {ConflictException} - пароли идентичны - * @throws {UnauthorizedException} - неверный исходный пароль + * @param user - пользователь, для которого меняется пароль + * @param changePassword - объект, содержащий старый и новый пароли + * @throws {ConflictException} - выбрасывается, если старый и новый пароли идентичны + * @throws {UnauthorizedException} - выбрасывается, если передан неверный исходный пароль * @async + * @returns {Promise} - возвращает Promise, который разрешается, когда пароль успешно изменен */ async changePassword( user: User, @@ -187,13 +237,13 @@ export class AuthService { if (oldPassword == newPassword) throw new ConflictException("Пароли идентичны"); - if (user.password !== (await hash(oldPassword, user.salt))) + if (!(await compare(oldPassword, user.password))) throw new UnauthorizedException("Передан неверный исходный пароль"); await this.usersService.update({ where: { id: user.id }, data: { - password: await hash(newPassword, user.salt), + password: await hash(newPassword, await genSalt(8)), }, }); } diff --git a/src/firebase-admin/firebase-admin.controller.ts b/src/firebase-admin/firebase-admin.controller.ts index 330e2de..9a4649c 100644 --- a/src/firebase-admin/firebase-admin.controller.ts +++ b/src/firebase-admin/firebase-admin.controller.ts @@ -4,8 +4,8 @@ import { Controller, HttpCode, HttpStatus, - Param, - Post, + Param, Patch, + Post, Query, UseGuards, } from "@nestjs/common"; import { AuthGuard } from "../auth/auth.guard"; @@ -54,11 +54,11 @@ export class FirebaseAdminController { status: HttpStatus.OK, description: "Установка токена удалась", }) - @Post("set-token/:token") + @Patch("set-token") @HttpCode(HttpStatus.OK) @ResultDto(null) async setToken( - @Param("token") token: string, + @Query("token") token: string, @UserToken(UserPipe) user: User, ): Promise { if (user.fcm?.token === token) return; diff --git a/src/firebase-admin/firebase-admin.service.ts b/src/firebase-admin/firebase-admin.service.ts index 9a41ff5..3a5c60f 100644 --- a/src/firebase-admin/firebase-admin.service.ts +++ b/src/firebase-admin/firebase-admin.service.ts @@ -86,17 +86,17 @@ export class FirebaseAdminService implements OnModuleInit { if (!user.fcm) throw new Error("User does not have fcm data!"); const fcm = user.fcm; - const newTopics = new Set(); + const userTopics = new Set([...fcm.topics]); for (const topic of topics) { if (!fcm.topics.includes(topic)) continue; 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({ where: { id: user.id }, @@ -118,10 +118,12 @@ export class FirebaseAdminService implements OnModuleInit { if (force) await this.messaging.unsubscribeFromTopic(fcm.token, topic); else if (fcm.topics.includes(topic)) continue; - else newTopics.add(topic); + + newTopics.add(topic); await this.messaging.subscribeToTopic(fcm.token, topic); } + if (newTopics.size === fcm.topics.length) return user; fcm.topics = Array.from(newTopics); diff --git a/src/main.ts b/src/main.ts index cc31950..fb954e8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,8 +3,7 @@ import { AppModule } from "./app.module"; import { ValidatorOptions } from "class-validator"; import { PartialValidationPipe } from "./utility/validation/partial-validation.pipe"; import { ClassValidatorInterceptor } from "./utility/validation/class-validator.interceptor"; -import { apiConstants, httpsConstants } from "./contants"; -import * as fs from "node:fs"; +import { apiConstants } from "./contants"; import { VersioningType } from "@nestjs/common"; import { FastifyAdapter, @@ -16,12 +15,6 @@ async function bootstrap() { const app = await NestFactory.create( AppModule, new FastifyAdapter(), - { - httpsOptions: { - cert: fs.readFileSync(httpsConstants.certPath), - key: fs.readFileSync(httpsConstants.keyPath), - }, - }, ); const validatorOptions: ValidatorOptions = { enableDebugMessages: true, @@ -33,13 +26,13 @@ async function bootstrap() { app.enableCors(); app.setGlobalPrefix("api"); - app.enableVersioning({ - type: VersioningType.URI, - }); + app.enableVersioning({ type: VersioningType.URI }); const swaggerConfig = new DocumentBuilder() .setTitle("Schedule Parser") - .setDescription("Парсер расписания") + .setDescription( + "API для парсинга и управления расписанием учебных занятий", + ) .setVersion(apiConstants.version) .build(); @@ -49,8 +42,12 @@ async function bootstrap() { swaggerDocument.servers = [ { - url: `https://localhost:${apiConstants.port}`, - description: "Локальный сервер для разработки", + url: "https://polytechnic-dev.n08i40k.ru", + description: "Сервер для разработки и тестирования", + }, + { + url: "https://polytechnic.n08i40k.ru", + description: "Сервер для продакшн окружения", }, ]; diff --git a/src/schedule/dto/cache-status.dto.ts b/src/schedule/dto/cache-status.dto.ts index d81cd23..d51c20b 100644 --- a/src/schedule/dto/cache-status.dto.ts +++ b/src/schedule/dto/cache-status.dto.ts @@ -16,14 +16,14 @@ export default class CacheStatusDto { cacheUpdateRequired: boolean; /** - * Дата обновления кеша + * Время последнего обновления кеша в формате timestamp * @example 1729288173002 */ @IsNumber() lastCacheUpdate: number; /** - * Дата обновления расписания + * Время последнего обновления расписания в формате timestamp * @example 1729288173002 */ @IsNumber() diff --git a/src/schedule/internal/schedule-parser/schedule-parser.spec.ts b/src/schedule/internal/schedule-parser/schedule-parser.spec.ts index 604d309..ec76951 100644 --- a/src/schedule/internal/schedule-parser/schedule-parser.spec.ts +++ b/src/schedule/internal/schedule-parser/schedule-parser.spec.ts @@ -69,13 +69,14 @@ describe("ScheduleParser", () => { ); expect(schedule).toBeDefined(); - const group: Group | undefined = schedule.groups.get("ИС-214/23"); + const group: Group | undefined = schedule.groups.get("ИС-114/23"); expect(group).toBeDefined(); const day = group.days[0]; expect(day).toBeDefined(); expect(day.lessons.length).toBeGreaterThan(0); + expect(day.lessons[0].name).toBe("Линейка"); }); }); }); diff --git a/src/schedule/internal/schedule-parser/schedule-parser.ts b/src/schedule/internal/schedule-parser/schedule-parser.ts index c999539..ed1ec69 100644 --- a/src/schedule/internal/schedule-parser/schedule-parser.ts +++ b/src/schedule/internal/schedule-parser/schedule-parser.ts @@ -3,7 +3,7 @@ import { XlsDownloaderInterface } from "../xls-downloader/xls-downloader.interfa import * as XLSX from "xlsx"; import { Range, WorkSheet } from "xlsx"; 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 LessonTime from "../../entities/lesson-time.entity"; import { LessonType } from "../../enum/lesson-type.enum"; @@ -607,7 +607,7 @@ export class ScheduleParser { } private static readonly consultationRegExp = /\(?[кК]онсультация\)?/; - private static readonly otherStreetRegExp = /^[А-Я][а-я]+,\s?[0-9]+$/; + private static readonly otherStreetRegExp = /^[А-Я][а-я]+,?\s?[0-9]+$/; private static parseLesson( workSheet: XLSX.Sheet, @@ -689,17 +689,25 @@ export class ScheduleParser { column + 1, ); + // Если количество кабинетов равно 1, назначаем этот кабинет всем подгруппам if (cabinets.length === 1) { // eslint-disable-next-line @typescript-eslint/no-for-in-array for (const index in lessonData.subGroups) - lessonData.subGroups[index].cabinet = cabinets[0]; - } else if (cabinets.length === lessonData.subGroups.length) { + lessonData.subGroups[index].cabinet = cabinets[0] ?? ""; + } + // Если количество кабинетов совпадает с количеством подгрупп, назначаем кабинеты по порядку + else if (cabinets.length === lessonData.subGroups.length) { // eslint-disable-next-line @typescript-eslint/no-for-in-array for (const index in lessonData.subGroups) { 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) { // eslint-disable-next-line @typescript-eslint/no-for-in-array for (const index in cabinets) { @@ -717,7 +725,14 @@ export class ScheduleParser { 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 = "??"; } } diff --git a/src/schedule/schedule.service.ts b/src/schedule/schedule.service.ts index f3b3547..564e6ec 100644 --- a/src/schedule/schedule.service.ts +++ b/src/schedule/schedule.service.ts @@ -17,6 +17,9 @@ import TeacherSchedule from "./entities/teacher-schedule.entity"; import GetGroupNamesDto from "./dto/get-group-names.dto"; import TeacherNamesDto from "./dto/teacher-names.dto"; +/** + * Сервис для работы с расписанием + */ @Injectable() export class ScheduleService { readonly scheduleParser: ScheduleParser; @@ -26,6 +29,12 @@ export class ScheduleService { private scheduleUpdatedAt: Date = new Date(0); + /** + * Конструктор сервиса + * @param cacheManager Менеджер кэша + * @param scheduleReplacerService Сервис замены расписания + * @param firebaseAdminService Сервис работы с Firebase + */ constructor( @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, private readonly scheduleReplacerService: ScheduleReplacerService, @@ -54,6 +63,10 @@ export class ScheduleService { ); } + /** + * Получение статуса кэша + * @returns Объект с информацией о состоянии кэша + */ getCacheStatus(): CacheStatusDto { return plainToInstance(CacheStatusDto, { cacheHash: this.cacheHash, @@ -65,6 +78,10 @@ export class ScheduleService { }); } + /** + * Получение исходного расписания + * @returns Результат парсинга расписания + */ async getSourceSchedule(): Promise { const schedule = await this.scheduleParser.getSchedule(); @@ -93,6 +110,10 @@ export class ScheduleService { return schedule; } + /** + * Получение расписания + * @returns Объект расписания + */ async getSchedule(): Promise { const sourceSchedule = await this.getSourceSchedule(); @@ -103,6 +124,12 @@ export class ScheduleService { }; } + /** + * Получение расписания для группы + * @param name Название группы + * @returns Расписание группы + * @throws NotFoundException Если группа не найдена + */ async getGroup(name: string): Promise { const schedule = await this.getSourceSchedule(); @@ -120,6 +147,10 @@ export class ScheduleService { }; } + /** + * Получение списка названий групп + * @returns Объект с массивом названий групп + */ async getGroupNames(): Promise { const schedule = await this.getSourceSchedule(); const names: Array = []; @@ -131,6 +162,12 @@ export class ScheduleService { }); } + /** + * Получение расписания для преподавателя + * @param name ФИО преподавателя + * @returns Расписание преподавателя + * @throws NotFoundException Если преподаватель не найден + */ async getTeacher(name: string): Promise { const schedule = await this.getSourceSchedule(); @@ -148,6 +185,10 @@ export class ScheduleService { }; } + /** + * Получение списка ФИО преподавателей + * @returns Объект с массивом ФИО преподавателей + */ async getTeacherNames(): Promise { const schedule = await this.getSourceSchedule(); const names: Array = []; @@ -162,6 +203,11 @@ export class ScheduleService { }); } + /** + * Обновление URL для загрузки расписания + * @param url Новый URL + * @returns Объект с информацией о состоянии кэша + */ async updateDownloadUrl(url: string): Promise { await this.scheduleParser.getXlsDownloader().setDownloadUrl(url); @@ -170,6 +216,9 @@ export class ScheduleService { return this.getCacheStatus(); } + /** + * Обновление кэша + */ async refreshCache() { await this.cacheManager.clear(); diff --git a/src/users/entity/user.entity.ts b/src/users/entity/user.entity.ts index 359de46..565e332 100644 --- a/src/users/entity/user.entity.ts +++ b/src/users/entity/user.entity.ts @@ -33,13 +33,6 @@ export default class User { @MaxLength(20) username: string; - /** - * Соль пароля - * @example "$2b$08$34xwFv1WVJpvpVi3tZZuv." - */ - @IsString() - salt: string; - /** * Хеш пароля * @example "$2b$08$34xwFv1WVJpvpVi3tZZuv."