Добавлена совместимость с Firebase Cloud Messaging.

Сервис и контроллер модуля schedule-replacer были перенесены в модуль schedule.
This commit is contained in:
2024-10-05 00:36:50 +04:00
parent 32e06350ad
commit 6ffe39a4a9
24 changed files with 1377 additions and 79 deletions

1113
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "schedule-parser-next", "name": "schedule-parser-next",
"version": "1.2.0", "version": "1.3.0",
"description": "", "description": "",
"author": "N08I40K", "author": "N08I40K",
"private": true, "private": true,
@@ -33,6 +33,7 @@
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"firebase-admin": "^12.6.0",
"jsdom": "^25.0.0", "jsdom": "^25.0.0",
"mongoose": "^8.6.1", "mongoose": "^8.6.1",
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.0",

View File

@@ -25,6 +25,11 @@ enum UserRole {
ADMIN ADMIN
} }
type FCM {
token String
topics Json
}
model User { model User {
id String @id @map("_id") @db.ObjectId id String @id @map("_id") @db.ObjectId
// //
@@ -37,4 +42,6 @@ model User {
// //
group String group String
role UserRole role UserRole
//
fcm FCM?
} }

View File

@@ -3,7 +3,7 @@ import { AuthModule } from "./auth/auth.module";
import { UsersModule } from "./users/users.module"; import { UsersModule } from "./users/users.module";
import { ScheduleModule } from "./schedule/schedule.module"; import { ScheduleModule } from "./schedule/schedule.module";
import { CacheModule } from "@nestjs/cache-manager"; import { CacheModule } from "@nestjs/cache-manager";
import { ScheduleReplacerModule } from "./schedule-replacer/schedule-replacer.module"; import { FirebaseAdminModule } from "./firebase-admin/firebase-admin.module";
@Module({ @Module({
imports: [ imports: [
@@ -11,7 +11,7 @@ import { ScheduleReplacerModule } from "./schedule-replacer/schedule-replacer.mo
UsersModule, UsersModule,
ScheduleModule, ScheduleModule,
CacheModule.register({ ttl: 5 * 60 * 1000, isGlobal: true }), CacheModule.register({ ttl: 5 * 60 * 1000, isGlobal: true }),
ScheduleReplacerModule, FirebaseAdminModule,
], ],
controllers: [], controllers: [],
providers: [], providers: [],

View File

@@ -34,8 +34,8 @@ import { UserToken } from "./auth.decorator";
@Controller("api/v1/auth") @Controller("api/v1/auth")
export class AuthController { export class AuthController {
constructor( constructor(
private readonly scheduleService: ScheduleService,
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly scheduleService: ScheduleService,
) {} ) {}
@ApiExtraModels(SignInReqDto) @ApiExtraModels(SignInReqDto)

View File

@@ -1,4 +1,4 @@
import { Module } from "@nestjs/common"; import { forwardRef, Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt"; import { JwtModule } from "@nestjs/jwt";
import { jwtConstants } from "../contants"; import { jwtConstants } from "../contants";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
@@ -9,8 +9,8 @@ import { ScheduleModule } from "../schedule/schedule.module";
@Module({ @Module({
imports: [ imports: [
UsersModule, forwardRef(() => UsersModule),
ScheduleModule, forwardRef(() => ScheduleModule),
JwtModule.register({ JwtModule.register({
global: true, global: true,
secret: jwtConstants.secret, secret: jwtConstants.secret,

View File

@@ -14,3 +14,7 @@ export const httpsConstants = {
export const apiConstants = { export const apiConstants = {
port: process.env.API_PORT ?? 5050, port: process.env.API_PORT ?? 5050,
}; };
export const firebaseConstants = {
serviceAccountPath: process.env.FIREBASE_ACCOUNT_PATH!,
};

View File

@@ -1,13 +1,17 @@
import { ApiProperty, OmitType, PickType } from "@nestjs/swagger"; import { ApiProperty, OmitType, PickType } from "@nestjs/swagger";
import { import {
IsArray,
IsEnum, IsEnum,
IsJWT, IsJWT,
IsMongoId, IsMongoId,
IsObject,
IsOptional,
IsString, IsString,
MaxLength, MaxLength,
MinLength, MinLength,
ValidateNested,
} from "class-validator"; } from "class-validator";
import { Expose, plainToClass } from "class-transformer"; import { Expose, plainToClass, Type } from "class-transformer";
export enum UserRoleDto { export enum UserRoleDto {
STUDENT = "STUDENT", STUDENT = "STUDENT",
@@ -15,6 +19,25 @@ export enum UserRoleDto {
ADMIN = "ADMIN", ADMIN = "ADMIN",
} }
export class UserFcmDto {
@ApiProperty({
description: "Токен Firebase Cloud Messaging",
})
@IsString()
@Expose()
token: string;
@ApiProperty({
example: ["schedule-update"],
description: "Топики на которые подписан пользователь",
})
@IsArray()
@ValidateNested({ each: true })
@IsString()
@Expose()
topics: Array<string>;
}
export class UserDto { export class UserDto {
@ApiProperty({ @ApiProperty({
example: "66e1b7e255c5d5f1268cce90", example: "66e1b7e255c5d5f1268cce90",
@@ -67,12 +90,20 @@ export class UserDto {
@IsEnum(UserRoleDto) @IsEnum(UserRoleDto)
@Expose() @Expose()
role: UserRoleDto; role: UserRoleDto;
@ApiProperty({ description: "Данные Firebase Cloud Messaging" })
@IsObject()
@Type(() => UserFcmDto)
@IsOptional()
@Expose()
fcm: UserFcmDto | null;
} }
export class ClientUserResDto extends OmitType(UserDto, [ export class ClientUserResDto extends OmitType(UserDto, [
"password", "password",
"salt", "salt",
"accessToken", "accessToken",
"fcm",
]) { ]) {
static fromUserDto(userDto: UserDto): ClientUserResDto { static fromUserDto(userDto: UserDto): ClientUserResDto {
return plainToClass(ClientUserResDto, userDto, { return plainToClass(ClientUserResDto, userDto, {

View File

@@ -0,0 +1,20 @@
import { Test, TestingModule } from "@nestjs/testing";
import { FirebaseAdminController } from "./firebase-admin.controller";
describe("FirebaseAdminController", () => {
let controller: FirebaseAdminController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [FirebaseAdminController],
}).compile();
controller = module.get<FirebaseAdminController>(
FirebaseAdminController,
);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
});

View File

@@ -0,0 +1,41 @@
import {
Controller,
HttpCode,
HttpStatus,
Param,
Post,
UseGuards,
} from "@nestjs/common";
import { AuthGuard } from "../auth/auth.guard";
import { UserToken } from "../auth/auth.decorator";
import { UserFromTokenPipe } from "../auth/auth.pipe";
import { UserDto } from "../dto/user.dto";
import { ResultDto } from "../utility/validation/class-validator.interceptor";
import { FirebaseAdminService } from "./firebase-admin.service";
@Controller("api/v1/fcm")
@UseGuards(AuthGuard)
export class FirebaseAdminController {
private readonly defaultTopics = new Set(["schedule-update"]);
constructor(private readonly firebaseAdminService: FirebaseAdminService) {}
@Post("set-token/:token")
@HttpCode(HttpStatus.OK)
@ResultDto(null)
async setToken(
@Param("token") token: string,
@UserToken(UserFromTokenPipe) user: UserDto,
): Promise<void> {
if (user.fcm?.token === token) return;
const updatedUser = (
await this.firebaseAdminService.updateToken(user, token)
).userDto;
await this.firebaseAdminService.subscribe(
updatedUser,
this.defaultTopics,
);
}
}

View File

@@ -0,0 +1,12 @@
import { forwardRef, Module } from "@nestjs/common";
import { FirebaseAdminService } from "./firebase-admin.service";
import { UsersModule } from "../users/users.module";
import { FirebaseAdminController } from "./firebase-admin.controller";
@Module({
imports: [forwardRef(() => UsersModule)],
providers: [FirebaseAdminService],
exports: [FirebaseAdminService],
controllers: [FirebaseAdminController],
})
export class FirebaseAdminModule {}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from "@nestjs/testing";
import { FirebaseAdminService } from "./firebase-admin.service";
describe("FirebaseAdminService", () => {
let service: FirebaseAdminService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [FirebaseAdminService],
}).compile();
service = module.get<FirebaseAdminService>(FirebaseAdminService);
});
it("should be defined", () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,99 @@
import { forwardRef, Inject, Injectable, OnModuleInit } from "@nestjs/common";
import { initializeApp, App } from "firebase-admin/app";
import { credential } from "firebase-admin";
import {
BaseMessage,
getMessaging,
Messaging,
TopicMessage,
} from "firebase-admin/messaging";
import { firebaseConstants } from "../contants";
import { UsersService } from "../users/users.service";
import { UserDto } from "../dto/user.dto";
@Injectable()
export class FirebaseAdminService implements OnModuleInit {
constructor(
@Inject(forwardRef(() => UsersService))
private readonly usersService: UsersService,
) {}
private app: App;
private messaging: Messaging;
onModuleInit() {
this.app = initializeApp({
credential: credential.cert(firebaseConstants.serviceAccountPath),
});
this.messaging = getMessaging(this.app);
}
async sendByTopic(topic: string, message: BaseMessage): Promise<void> {
const topicMessage = message as TopicMessage;
topicMessage.topic = topic;
await this.messaging.send(topicMessage);
}
async updateToken(
user: UserDto,
token: string,
): Promise<{ userDto: UserDto; isNew: boolean }> {
const isNew = user.fcm === null;
const fcm = !isNew ? user.fcm : { token: token, topics: [] };
if (!isNew) {
if (fcm.token === token) return { userDto: user, isNew: false };
for (const topic in fcm.topics)
await this.messaging.subscribeToTopic(token, topic);
fcm.token = token;
}
return {
userDto: await this.usersService.update({
where: { id: user.id },
data: { fcm: fcm },
}),
isNew: isNew,
};
}
async unsubscribe(user: UserDto, topics: Set<string>): Promise<UserDto> {
const fcm = user.fcm;
const currentTopics = new Set(fcm.topics);
for (const topic of topics) {
await this.messaging.unsubscribeFromTopic(fcm.token, topic);
currentTopics.delete(topic);
}
if (currentTopics.size === fcm.topics.length) return user;
fcm.topics = Array.from(currentTopics);
return await this.usersService.update({
where: { id: user.id },
data: { fcm: fcm },
});
}
async subscribe(user: UserDto, topics: Set<string>): Promise<UserDto> {
const fcm = user.fcm;
const currentTopics = new Set(fcm.topics);
for (const topic of topics) {
await this.messaging.subscribeToTopic(fcm.token, topic);
currentTopics.add(topic);
}
if (currentTopics.size === fcm.topics.length) return user;
fcm.topics = Array.from(currentTopics);
return await this.usersService.update({
where: { id: user.id },
data: { fcm: fcm },
});
}
}

View File

@@ -1,15 +0,0 @@
import { Module } from "@nestjs/common";
import { ScheduleReplacerService } from "./schedule-replacer.service";
import { PrismaService } from "../prisma/prisma.service";
import { ScheduleReplacerController } from "./schedule-replacer.controller";
import { ScheduleModule } from "../schedule/schedule.module";
import { AuthService } from "../auth/auth.service";
import { UsersModule } from "../users/users.module";
@Module({
imports: [ScheduleModule, UsersModule],
providers: [AuthService, PrismaService, ScheduleReplacerService],
exports: [ScheduleReplacerService],
controllers: [ScheduleReplacerController],
})
export class ScheduleReplacerModule {}

View File

@@ -149,14 +149,8 @@ export class ScheduleParser {
!downloadData.new && !downloadData.new &&
this.lastResult && this.lastResult &&
this.xlsDownloader.getCacheMode() !== XlsDownloaderCacheMode.NONE this.xlsDownloader.getCacheMode() !== XlsDownloaderCacheMode.NONE
) { )
console.debug(
"Так как скачанный XLS не новый, присутствует уже готовый результат и кеширование не отключено...",
);
console.debug("будет возвращён предыдущий результат.");
return this.lastResult; return this.lastResult;
}
const workBook = XLSX.read(downloadData.fileData); const workBook = XLSX.read(downloadData.fileData);
const workSheet = workBook.Sheets[workBook.SheetNames[0]]; const workSheet = workBook.Sheets[workBook.SheetNames[0]];

View File

@@ -9,7 +9,7 @@ import {
NotAcceptableException, NotAcceptableException,
ServiceUnavailableException, ServiceUnavailableException,
} from "@nestjs/common"; } from "@nestjs/common";
import { ScheduleReplacerService } from "../../../schedule-replacer/schedule-replacer.service"; import { ScheduleReplacerService } from "../../schedule-replacer.service";
import { Error } from "mongoose"; import { Error } from "mongoose";
import * as crypto from "crypto"; import * as crypto from "crypto";

View File

@@ -17,7 +17,7 @@ import {
import { AuthRoles } from "../auth-role/auth-role.decorator"; import { AuthRoles } from "../auth-role/auth-role.decorator";
import { UserRoleDto } from "../dto/user.dto"; import { UserRoleDto } from "../dto/user.dto";
import { ScheduleReplacerService } from "./schedule-replacer.service"; import { ScheduleReplacerService } from "./schedule-replacer.service";
import { ScheduleService } from "../schedule/schedule.service"; import { ScheduleService } from "./schedule.service";
import { FileInterceptor } from "@nestjs/platform-express"; import { FileInterceptor } from "@nestjs/platform-express";
import { import {
ApiExtraModels, ApiExtraModels,

View File

@@ -7,6 +7,14 @@ import { plainToClass } from "class-transformer";
export class ScheduleReplacerService { export class ScheduleReplacerService {
constructor(private readonly prismaService: PrismaService) {} constructor(private readonly prismaService: PrismaService) {}
async hasByEtag(etag: string): Promise<boolean> {
return (
(await this.prismaService.scheduleReplace.count({
where: { etag: etag },
})) > 0
);
}
async getByEtag(etag: string): Promise<ScheduleReplacerDto | null> { async getByEtag(etag: string): Promise<ScheduleReplacerDto | null> {
const response = await this.prismaService.scheduleReplace.findUnique({ const response = await this.prismaService.scheduleReplace.findUnique({
where: { etag: etag }, where: { etag: etag },

View File

@@ -1,18 +1,16 @@
import { Module } from "@nestjs/common"; import { forwardRef, Module } from "@nestjs/common";
import { ScheduleService } from "./schedule.service"; import { ScheduleService } from "./schedule.service";
import { ScheduleController } from "./schedule.controller"; import { ScheduleController } from "./schedule.controller";
import { UsersService } from "../users/users.service";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { ScheduleReplacerService } from "../schedule-replacer/schedule-replacer.service"; import { FirebaseAdminModule } from "../firebase-admin/firebase-admin.module";
import { UsersModule } from "src/users/users.module";
import { ScheduleReplacerService } from "./schedule-replacer.service";
import { ScheduleReplacerController } from "./schedule-replacer.controller";
@Module({ @Module({
providers: [ imports: [forwardRef(() => UsersModule), FirebaseAdminModule],
ScheduleService, providers: [PrismaService, ScheduleService, ScheduleReplacerService],
ScheduleReplacerService, controllers: [ScheduleController, ScheduleReplacerController],
UsersService,
PrismaService,
],
controllers: [ScheduleController],
exports: [ScheduleService], exports: [ScheduleService],
}) })
export class ScheduleModule {} export class ScheduleModule {}

View File

@@ -1,4 +1,8 @@
import { Inject, Injectable, NotFoundException } from "@nestjs/common"; import {
Inject,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { import {
ScheduleParser, ScheduleParser,
ScheduleParseResult, ScheduleParseResult,
@@ -17,7 +21,8 @@ import { Cache, CACHE_MANAGER } from "@nestjs/cache-manager";
import { instanceToPlain } from "class-transformer"; import { instanceToPlain } from "class-transformer";
import { cacheGetOrFill } from "../utility/cache.util"; import { cacheGetOrFill } from "../utility/cache.util";
import * as crypto from "crypto"; import * as crypto from "crypto";
import { ScheduleReplacerService } from "../schedule-replacer/schedule-replacer.service"; import { ScheduleReplacerService } from "./schedule-replacer.service";
import { FirebaseAdminService } from "../firebase-admin/firebase-admin.service";
@Injectable() @Injectable()
export class ScheduleService { export class ScheduleService {
@@ -37,6 +42,7 @@ export class ScheduleService {
constructor( constructor(
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache, @Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
private readonly scheduleReplacerService: ScheduleReplacerService, private readonly scheduleReplacerService: ScheduleReplacerService,
private readonly firebaseAdminService: FirebaseAdminService,
) { ) {
const xlsDownloader = this.scheduleParser.getXlsDownloader(); const xlsDownloader = this.scheduleParser.getXlsDownloader();
@@ -77,8 +83,26 @@ export class ScheduleService {
if ( if (
this.scheduleUpdatedAt.valueOf() === 0 || this.scheduleUpdatedAt.valueOf() === 0 ||
this.cacheHash !== oldHash this.cacheHash !== oldHash
) ) {
if (this.scheduleUpdatedAt.valueOf() !== 0) {
const isReplaced =
await this.scheduleReplacerService.hasByEtag(
schedule.etag,
);
await this.firebaseAdminService.sendByTopic(
"schedule-update",
{
data: {
type: "schedule-update",
replaced: isReplaced.toString(),
etag: schedule.etag,
},
},
);
}
this.scheduleUpdatedAt = new Date(); this.scheduleUpdatedAt = new Date();
}
return schedule; return schedule;
}); });

View File

@@ -1,13 +1,13 @@
import { Module } from "@nestjs/common"; import { forwardRef, Module } from "@nestjs/common";
import { UsersService } from "./users.service"; import { UsersService } from "./users.service";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { UsersController } from "./users.controller"; import { UsersController } from "./users.controller";
import { AuthService } from "../auth/auth.service";
import { ScheduleModule } from "../schedule/schedule.module"; import { ScheduleModule } from "../schedule/schedule.module";
import { AuthModule } from "../auth/auth.module";
@Module({ @Module({
imports: [ScheduleModule], imports: [forwardRef(() => ScheduleModule), forwardRef(() => AuthModule)],
providers: [PrismaService, UsersService, AuthService], providers: [PrismaService, UsersService],
exports: [UsersService], exports: [UsersService],
controllers: [UsersController], controllers: [UsersController],
}) })

View File

@@ -1,5 +1,7 @@
import { import {
ConflictException, ConflictException,
forwardRef,
Inject,
Injectable, Injectable,
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
@@ -16,6 +18,7 @@ import { ScheduleService } from "../schedule/schedule.service";
export class UsersService { export class UsersService {
constructor( constructor(
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
@Inject(forwardRef(() => ScheduleService))
private readonly scheduleService: ScheduleService, private readonly scheduleService: ScheduleService,
) {} ) {}