- 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

@@ -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<User>} - Объект пользователя, если токен валиден
* @throws {UnauthorizedException} - Если токен некорректен или недействителен
* @throws {UnauthorizedException} - Если пользователь, указанный в токене, не существует
* @throws {UnauthorizedException} - Если токен устарел и был заменён на новый
* @async
*/
async decodeUserToken(token: string): Promise<User> {
@@ -52,6 +52,13 @@ export class AuthService {
return user;
}
/**
* Регистрирует нового пользователя в системе.
*
* @param signUpDto - Объект с данными для регистрации, включая имя пользователя, пароль, роль и группу.
* @returns Возвращает объект UserDto с данными зарегистрированного пользователя или объект SignUpErrorDto в случае ошибки.
* @throws SignUpErrorDto - Если роль пользователя недопустима или имя пользователя уже существует.
*/
async signUp(signUpDto: SignUpDto): Promise<UserDto | SignUpErrorDto> {
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>} - Возвращает объект 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> {
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<number> {
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<UserDto | SignUpErrorDto> - возвращает DTO пользователя в случае успешной регистрации
* или DTO ошибки в случае возникновения проблем
*/
async signUpVK(signUpDto: SignUpVKDto): Promise<UserDto | SignUpErrorDto> {
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<UserDto | SignInErrorDto> - возвращает DTO пользователя в случае успешной авторизации или DTO ошибки в случае неудачи
*/
async signInVK(
signInVKDto: SignInVKDto,
): Promise<UserDto | SignInErrorDto> {
@@ -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<void>} - возвращает 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)),
},
});
}

View File

@@ -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<void> {
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!");
const fcm = user.fcm;
const newTopics = new Set<string>();
const userTopics = new Set<string>([...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);

View File

@@ -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<NestFastifyApplication>(
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: "Сервер для продакшн окружения",
},
];

View File

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

View File

@@ -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("Линейка");
});
});
});

View File

@@ -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 = "??";
}
}

View File

@@ -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<ScheduleParseResult> {
const schedule = await this.scheduleParser.getSchedule();
@@ -93,6 +110,10 @@ export class ScheduleService {
return schedule;
}
/**
* Получение расписания
* @returns Объект расписания
*/
async getSchedule(): Promise<Schedule> {
const sourceSchedule = await this.getSourceSchedule();
@@ -103,6 +124,12 @@ export class ScheduleService {
};
}
/**
* Получение расписания для группы
* @param name Название группы
* @returns Расписание группы
* @throws NotFoundException Если группа не найдена
*/
async getGroup(name: string): Promise<GroupSchedule> {
const schedule = await this.getSourceSchedule();
@@ -120,6 +147,10 @@ export class ScheduleService {
};
}
/**
* Получение списка названий групп
* @returns Объект с массивом названий групп
*/
async getGroupNames(): Promise<GetGroupNamesDto> {
const schedule = await this.getSourceSchedule();
const names: Array<string> = [];
@@ -131,6 +162,12 @@ export class ScheduleService {
});
}
/**
* Получение расписания для преподавателя
* @param name ФИО преподавателя
* @returns Расписание преподавателя
* @throws NotFoundException Если преподаватель не найден
*/
async getTeacher(name: string): Promise<TeacherSchedule> {
const schedule = await this.getSourceSchedule();
@@ -148,6 +185,10 @@ export class ScheduleService {
};
}
/**
* Получение списка ФИО преподавателей
* @returns Объект с массивом ФИО преподавателей
*/
async getTeacherNames(): Promise<TeacherNamesDto> {
const schedule = await this.getSourceSchedule();
const names: Array<string> = [];
@@ -162,6 +203,11 @@ export class ScheduleService {
});
}
/**
* Обновление URL для загрузки расписания
* @param url Новый URL
* @returns Объект с информацией о состоянии кэша
*/
async updateDownloadUrl(url: string): Promise<CacheStatusDto> {
await this.scheduleParser.getXlsDownloader().setDownloadUrl(url);
@@ -170,6 +216,9 @@ export class ScheduleService {
return this.getCacheStatus();
}
/**
* Обновление кэша
*/
async refreshCache() {
await this.cacheManager.clear();

View File

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