Merge branch 'updatePackages' into 'main'
Update packages See merge request v3/service/matcher!13
This commit is contained in:
commit
0fed45c8b0
|
@ -10,6 +10,7 @@ DATABASE_URL="postgresql://mobicoop:mobicoop@v3-db:5432/mobicoop?schema=matcher"
|
|||
# MESSAGE BROKER
|
||||
MESSAGE_BROKER_URI=amqp://v3-broker:5672
|
||||
MESSAGE_BROKER_EXCHANGE=mobicoop
|
||||
MESSAGE_BROKER_EXCHANGE_DURABILITY=true
|
||||
|
||||
# REDIS
|
||||
REDIS_HOST=v3-redis
|
||||
|
|
File diff suppressed because it is too large
Load Diff
96
package.json
96
package.json
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@mobicoop/matcher",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"description": "Mobicoop V3 Matcher",
|
||||
"author": "sbriat",
|
||||
"private": true,
|
||||
|
@ -30,63 +30,63 @@
|
|||
"migrate:deploy": "npx prisma migrate deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "^1.8.14",
|
||||
"@grpc/proto-loader": "^0.7.6",
|
||||
"@liaoliaots/nestjs-redis": "^9.0.5",
|
||||
"@mobicoop/configuration-module": "^1.2.0",
|
||||
"@mobicoop/ddd-library": "^1.5.0",
|
||||
"@mobicoop/health-module": "^2.0.0",
|
||||
"@mobicoop/message-broker-module": "^1.2.0",
|
||||
"@nestjs/axios": "^2.0.0",
|
||||
"@nestjs/cache-manager": "^1.0.0",
|
||||
"@nestjs/common": "^9.0.0",
|
||||
"@nestjs/config": "^2.3.1",
|
||||
"@nestjs/core": "^9.0.0",
|
||||
"@nestjs/cqrs": "^9.0.3",
|
||||
"@nestjs/event-emitter": "^1.4.2",
|
||||
"@nestjs/microservices": "^9.4.0",
|
||||
"@nestjs/platform-express": "^9.0.0",
|
||||
"@nestjs/terminus": "^9.2.2",
|
||||
"@prisma/client": "^4.13.0",
|
||||
"axios": "^1.3.5",
|
||||
"cache-manager": "^5.2.3",
|
||||
"cache-manager-ioredis-yet": "^1.1.0",
|
||||
"@grpc/grpc-js": "^1.9.6",
|
||||
"@grpc/proto-loader": "^0.7.10",
|
||||
"@songkeys/nestjs-redis": "^10.0.0",
|
||||
"@mobicoop/configuration-module": "^3.0.0",
|
||||
"@mobicoop/ddd-library": "^2.0.0",
|
||||
"@mobicoop/health-module": "^2.3.1",
|
||||
"@mobicoop/message-broker-module": "^2.1.1",
|
||||
"@nestjs/axios": "^3.0.0",
|
||||
"@nestjs/cache-manager": "^2.1.0",
|
||||
"@nestjs/common": "^10.2.7",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
"@nestjs/core": "^10.2.7",
|
||||
"@nestjs/cqrs": "^10.2.6",
|
||||
"@nestjs/event-emitter": "^2.0.2",
|
||||
"@nestjs/microservices": "^10.2.7",
|
||||
"@nestjs/platform-express": "^10.2.7",
|
||||
"@nestjs/terminus": "^10.1.1",
|
||||
"@prisma/client": "^5.4.2",
|
||||
"axios": "^1.5.1",
|
||||
"cache-manager": "^5.2.4",
|
||||
"cache-manager-ioredis-yet": "^1.2.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"geo-tz": "^7.0.7",
|
||||
"geographiclib-geodesic": "^2.0.0",
|
||||
"got": "^11.8.6",
|
||||
"got": "^13.0.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"nestjs-request-context": "^2.1.0",
|
||||
"nestjs-request-context": "^3.0.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.2.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"timezonecomplete": "^5.12.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^9.0.0",
|
||||
"@nestjs/schematics": "^9.0.0",
|
||||
"@nestjs/testing": "^9.0.0",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/jest": "29.5.0",
|
||||
"@types/node": "18.15.11",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@types/uuid": "^9.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||
"@typescript-eslint/parser": "^5.0.0",
|
||||
"dotenv-cli": "^7.2.1",
|
||||
"eslint": "^8.0.1",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"jest": "29.5.0",
|
||||
"prettier": "^2.3.2",
|
||||
"prisma": "^4.13.0",
|
||||
"source-map-support": "^0.5.20",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-jest": "29.0.5",
|
||||
"ts-loader": "^9.2.3",
|
||||
"ts-node": "^10.0.0",
|
||||
"@nestjs/cli": "^10.1.18",
|
||||
"@nestjs/schematics": "^10.0.2",
|
||||
"@nestjs/testing": "^10.2.7",
|
||||
"@types/express": "^4.17.20",
|
||||
"@types/jest": "29.5.6",
|
||||
"@types/node": "20.8.6",
|
||||
"@types/supertest": "^2.0.14",
|
||||
"@types/uuid": "^9.0.5",
|
||||
"@typescript-eslint/eslint-plugin": "^6.8.0",
|
||||
"@typescript-eslint/parser": "^6.8.0",
|
||||
"dotenv-cli": "^7.3.0",
|
||||
"eslint": "^8.51.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.1",
|
||||
"jest": "29.7.0",
|
||||
"prettier": "^3.0.3",
|
||||
"prisma": "^5.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^6.3.3",
|
||||
"ts-jest": "29.1.1",
|
||||
"ts-loader": "^9.5.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "^4.7.4"
|
||||
"typescript": "^5.2.2"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
// service
|
||||
export const SERVICE_NAME = 'matcher';
|
||||
|
||||
// grpc
|
||||
export const GRPC_PACKAGE_NAME = 'matcher';
|
||||
|
||||
// messaging
|
||||
export const AD_CREATED_MESSAGE_HANDLER = 'adCreated';
|
||||
export const AD_CREATED_ROUTING_KEY = 'ad.created';
|
||||
export const AD_CREATED_QUEUE = 'matcher-ad-created';
|
||||
export const AD_UPDATED_MESSAGE_HANDLER = 'adUpdated';
|
||||
export const AD_UPDATED_ROUTING_KEY = 'ad.updated';
|
||||
export const AD_UPDATED_QUEUE = 'matcher-ad-updated';
|
||||
export const AD_DELETED_MESSAGE_HANDLER = 'adDeleted';
|
||||
export const AD_DELETED_ROUTING_KEY = 'ad.deleted';
|
||||
export const AD_DELETED_QUEUE = 'matcher-ad-deleted';
|
||||
|
||||
// configuration
|
||||
export const SERVICE_CONFIGURATION_SET_QUEUE = 'matcher-configuration-set';
|
||||
export const SERVICE_CONFIGURATION_DELETE_QUEUE =
|
||||
'matcher-configuration-delete';
|
||||
export const SERVICE_CONFIGURATION_PROPAGATE_QUEUE =
|
||||
'matcher-configuration-propagate';
|
||||
|
||||
// health
|
||||
export const GRPC_HEALTH_PACKAGE_NAME = 'health';
|
||||
export const HEALTH_AD_REPOSITORY = 'AdRepository';
|
||||
export const HEALTH_CRITICAL_LOGGING_KEY = 'logging.matcher.health.crit';
|
|
@ -14,6 +14,14 @@ import { MESSAGE_PUBLISHER } from '@modules/messager/messager.di-tokens';
|
|||
import { HealthModuleOptions } from '@mobicoop/health-module/dist/core/domain/types/health.types';
|
||||
import { MessagePublisherPort } from '@mobicoop/ddd-library';
|
||||
import { GeographyModule } from '@modules/geography/geography.module';
|
||||
import {
|
||||
HEALTH_AD_REPOSITORY,
|
||||
HEALTH_CRITICAL_LOGGING_KEY,
|
||||
SERVICE_CONFIGURATION_DELETE_QUEUE,
|
||||
SERVICE_CONFIGURATION_PROPAGATE_QUEUE,
|
||||
SERVICE_CONFIGURATION_SET_QUEUE,
|
||||
SERVICE_NAME,
|
||||
} from './app.constants';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
@ -31,18 +39,23 @@ import { GeographyModule } from '@modules/geography/geography.module';
|
|||
) as string,
|
||||
messageBroker: {
|
||||
uri: configService.get<string>('MESSAGE_BROKER_URI') as string,
|
||||
exchange: configService.get<string>(
|
||||
'MESSAGE_BROKER_EXCHANGE',
|
||||
) as string,
|
||||
exchange: {
|
||||
name: configService.get<string>(
|
||||
'MESSAGE_BROKER_EXCHANGE',
|
||||
) as string,
|
||||
durable: configService.get<boolean>(
|
||||
'MESSAGE_BROKER_EXCHANGE_DURABILITY',
|
||||
) as boolean,
|
||||
},
|
||||
},
|
||||
redis: {
|
||||
host: configService.get<string>('REDIS_HOST') as string,
|
||||
password: configService.get<string>('REDIS_PASSWORD'),
|
||||
port: configService.get<number>('REDIS_PORT') as number,
|
||||
},
|
||||
setConfigurationBrokerQueue: 'matcher-configuration-create-update',
|
||||
deleteConfigurationQueue: 'matcher-configuration-delete',
|
||||
propagateConfigurationQueue: 'matcher-configuration-propagate',
|
||||
setConfigurationQueue: SERVICE_CONFIGURATION_SET_QUEUE,
|
||||
deleteConfigurationQueue: SERVICE_CONFIGURATION_DELETE_QUEUE,
|
||||
propagateConfigurationQueue: SERVICE_CONFIGURATION_PROPAGATE_QUEUE,
|
||||
}),
|
||||
}),
|
||||
HealthModule.forRootAsync({
|
||||
|
@ -52,11 +65,11 @@ import { GeographyModule } from '@modules/geography/geography.module';
|
|||
adRepository: HealthRepositoryPort,
|
||||
messagePublisher: MessagePublisherPort,
|
||||
): Promise<HealthModuleOptions> => ({
|
||||
serviceName: 'matcher',
|
||||
criticalLoggingKey: 'logging.matcher.health.crit',
|
||||
serviceName: SERVICE_NAME,
|
||||
criticalLoggingKey: HEALTH_CRITICAL_LOGGING_KEY,
|
||||
checkRepositories: [
|
||||
{
|
||||
name: 'AdRepository',
|
||||
name: HEALTH_AD_REPOSITORY,
|
||||
repository: adRepository,
|
||||
},
|
||||
],
|
||||
|
|
|
@ -2,6 +2,7 @@ import { NestFactory } from '@nestjs/core';
|
|||
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
|
||||
import { join } from 'path';
|
||||
import { AppModule } from './app.module';
|
||||
import { GRPC_HEALTH_PACKAGE_NAME, GRPC_PACKAGE_NAME } from './app.constants';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
@ -11,12 +12,12 @@ async function bootstrap() {
|
|||
app.connectMicroservice<MicroserviceOptions>({
|
||||
transport: Transport.GRPC,
|
||||
options: {
|
||||
package: ['matcher', 'health'],
|
||||
package: [GRPC_PACKAGE_NAME, GRPC_HEALTH_PACKAGE_NAME],
|
||||
protoPath: [
|
||||
join(__dirname, 'modules/ad/interface/grpc-controllers/matcher.proto'),
|
||||
join(__dirname, 'health.proto'),
|
||||
],
|
||||
url: process.env.SERVICE_URL + ':' + process.env.SERVICE_PORT,
|
||||
url: `${process.env.SERVICE_URL}:${process.env.SERVICE_PORT}`,
|
||||
loader: { keepCase: true, enums: String },
|
||||
},
|
||||
});
|
||||
|
|
|
@ -36,9 +36,9 @@ import { OutputDateTimeTransformer } from './infrastructure/output-datetime-tran
|
|||
import { MatchingRepository } from './infrastructure/matching.repository';
|
||||
import { MatchingMapper } from './matching.mapper';
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { RedisClientOptions } from '@liaoliaots/nestjs-redis';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { redisStore } from 'cache-manager-ioredis-yet';
|
||||
import { RedisClientOptions } from '@songkeys/nestjs-redis';
|
||||
|
||||
const imports = [
|
||||
CqrsModule,
|
||||
|
|
|
@ -8,6 +8,7 @@ import { AdEntity } from '../core/domain/ad.entity';
|
|||
import { AdMapper } from '../ad.mapper';
|
||||
import { ExtendedPrismaRepositoryBase } from '@mobicoop/ddd-library/dist/db/prisma-repository.base';
|
||||
import { Frequency } from '../core/domain/ad.types';
|
||||
import { SERVICE_NAME } from '@src/app.constants';
|
||||
|
||||
export type AdModel = {
|
||||
uuid: string;
|
||||
|
@ -38,7 +39,7 @@ export type AdReadModel = AdModel & {
|
|||
};
|
||||
|
||||
/**
|
||||
* The record ready to be sent to the peristence system
|
||||
* The record ready to be sent to the persistence system
|
||||
*/
|
||||
export type AdWriteModel = AdModel & {
|
||||
schedule: {
|
||||
|
@ -103,7 +104,7 @@ export class AdRepository
|
|||
eventEmitter,
|
||||
new LoggerBase({
|
||||
logger: new Logger(AdRepository.name),
|
||||
domain: 'matcher',
|
||||
domain: SERVICE_NAME,
|
||||
messagePublisher,
|
||||
}),
|
||||
);
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { InjectRedis } from '@liaoliaots/nestjs-redis';
|
||||
import { MatchingRepositoryPort } from '../core/application/ports/matching.repository.port';
|
||||
import { MatchingEntity } from '../core/domain/matching.entity';
|
||||
import { Redis } from 'ioredis';
|
||||
import { MatchingMapper } from '../matching.mapper';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MatchingNotFoundException } from '../core/domain/matching.errors';
|
||||
import { InjectRedis } from '@songkeys/nestjs-redis';
|
||||
|
||||
const REDIS_MATCHING_TTL = 900;
|
||||
const REDIS_MATCHING_KEY = 'MATCHER:MATCHING';
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
|
@ -6,10 +6,4 @@ export class PrismaService extends PrismaClient implements OnModuleInit {
|
|||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async enableShutdownHooks(app: INestApplication) {
|
||||
this.$on('beforeExit', async () => {
|
||||
await app.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,13 +3,14 @@ import { RabbitSubscribe } from '@mobicoop/message-broker-module';
|
|||
import { CommandBus } from '@nestjs/cqrs';
|
||||
import { CreateAdCommand } from '@modules/ad/core/application/commands/create-ad/create-ad.command';
|
||||
import { Ad } from './ad.types';
|
||||
import { AD_CREATED_MESSAGE_HANDLER } from '@src/app.constants';
|
||||
|
||||
@Injectable()
|
||||
export class AdCreatedMessageHandler {
|
||||
constructor(private readonly commandBus: CommandBus) {}
|
||||
|
||||
@RabbitSubscribe({
|
||||
name: 'adCreated',
|
||||
name: AD_CREATED_MESSAGE_HANDLER,
|
||||
})
|
||||
public async adCreated(message: string) {
|
||||
try {
|
||||
|
|
|
@ -137,9 +137,8 @@ describe('create-ad.service', () => {
|
|||
AdEntity.create = jest.fn().mockReturnValue({
|
||||
id: '047a6ecf-23d4-4d3e-877c-3225d560a8da',
|
||||
});
|
||||
const result: AggregateID = await createAdService.execute(
|
||||
createAdCommand,
|
||||
);
|
||||
const result: AggregateID =
|
||||
await createAdService.execute(createAdCommand);
|
||||
expect(result).toBe('047a6ecf-23d4-4d3e-877c-3225d560a8da');
|
||||
});
|
||||
it('should create a new ad as passenger', async () => {
|
||||
|
|
|
@ -332,9 +332,8 @@ describe('Match Query Handler', () => {
|
|||
},
|
||||
mockRouteProvider,
|
||||
);
|
||||
const matching: MatchingResult = await matchQueryHandler.execute(
|
||||
matchQuery,
|
||||
);
|
||||
const matching: MatchingResult =
|
||||
await matchQueryHandler.execute(matchQuery);
|
||||
expect(matching.id).toHaveLength(36);
|
||||
expect(MatchingEntity.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
@ -362,9 +361,8 @@ describe('Match Query Handler', () => {
|
|||
},
|
||||
mockRouteProvider,
|
||||
);
|
||||
const matching: MatchingResult = await matchQueryHandler.execute(
|
||||
matchQuery,
|
||||
);
|
||||
const matching: MatchingResult =
|
||||
await matchQueryHandler.execute(matchQuery);
|
||||
expect(matching.id).toBe('a3b10efb-121e-4d08-9198-9f57afdb5e2d');
|
||||
expect(MatchingEntity.create).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
@ -392,9 +390,8 @@ describe('Match Query Handler', () => {
|
|||
},
|
||||
mockRouteProvider,
|
||||
);
|
||||
const matching: MatchingResult = await matchQueryHandler.execute(
|
||||
matchQuery,
|
||||
);
|
||||
const matching: MatchingResult =
|
||||
await matchQueryHandler.execute(matchQuery);
|
||||
expect(matching.id).toHaveLength(36);
|
||||
expect(MatchingEntity.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
|
|
@ -274,9 +274,8 @@ describe('Ad repository', () => {
|
|||
});
|
||||
|
||||
it('should return an empty array of candidates if query does not return Ads', async () => {
|
||||
const candidates: AdEntity[] = await adRepository.getCandidateAds(
|
||||
'someQueryString',
|
||||
);
|
||||
const candidates: AdEntity[] =
|
||||
await adRepository.getCandidateAds('someQueryString');
|
||||
expect(candidates.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { getRedisToken } from '@liaoliaots/nestjs-redis';
|
||||
import { Frequency, Role } from '@modules/ad/core/domain/ad.types';
|
||||
import { Target } from '@modules/ad/core/domain/candidate.types';
|
||||
import { MatchEntity } from '@modules/ad/core/domain/match.entity';
|
||||
|
@ -8,6 +7,7 @@ import { MatchingRepository } from '@modules/ad/infrastructure/matching.reposito
|
|||
import { MatchingMapper } from '@modules/ad/matching.mapper';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRedisToken } from '@songkeys/nestjs-redis';
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn().mockImplementation((value: string) => {
|
||||
|
|
|
@ -6,6 +6,18 @@ import {
|
|||
MessageBrokerPublisher,
|
||||
} from '@mobicoop/message-broker-module';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
AD_CREATED_MESSAGE_HANDLER,
|
||||
AD_CREATED_QUEUE,
|
||||
AD_CREATED_ROUTING_KEY,
|
||||
AD_DELETED_MESSAGE_HANDLER,
|
||||
AD_DELETED_QUEUE,
|
||||
AD_DELETED_ROUTING_KEY,
|
||||
AD_UPDATED_MESSAGE_HANDLER,
|
||||
AD_UPDATED_QUEUE,
|
||||
AD_UPDATED_ROUTING_KEY,
|
||||
SERVICE_NAME,
|
||||
} from '@src/app.constants';
|
||||
|
||||
const imports = [
|
||||
MessageBrokerModule.forRootAsync({
|
||||
|
@ -15,20 +27,25 @@ const imports = [
|
|||
configService: ConfigService,
|
||||
): Promise<MessageBrokerModuleOptions> => ({
|
||||
uri: configService.get<string>('MESSAGE_BROKER_URI') as string,
|
||||
exchange: configService.get<string>('MESSAGE_BROKER_EXCHANGE') as string,
|
||||
name: 'matcher',
|
||||
exchange: {
|
||||
name: configService.get<string>('MESSAGE_BROKER_EXCHANGE') as string,
|
||||
durable: configService.get<boolean>(
|
||||
'MESSAGE_BROKER_EXCHANGE_DURABILITY',
|
||||
) as boolean,
|
||||
},
|
||||
name: SERVICE_NAME,
|
||||
handlers: {
|
||||
adCreated: {
|
||||
routingKey: 'ad.created',
|
||||
queue: 'matcher-ad-created',
|
||||
[AD_CREATED_MESSAGE_HANDLER]: {
|
||||
routingKey: AD_CREATED_ROUTING_KEY,
|
||||
queue: AD_CREATED_QUEUE,
|
||||
},
|
||||
adUpdated: {
|
||||
routingKey: 'ad.updated',
|
||||
queue: 'matcher-ad-updated',
|
||||
[AD_UPDATED_MESSAGE_HANDLER]: {
|
||||
routingKey: AD_UPDATED_ROUTING_KEY,
|
||||
queue: AD_UPDATED_QUEUE,
|
||||
},
|
||||
adDeleted: {
|
||||
routingKey: 'ad.deleted',
|
||||
queue: 'matcher-ad-deleted',
|
||||
[AD_DELETED_MESSAGE_HANDLER]: {
|
||||
routingKey: AD_DELETED_ROUTING_KEY,
|
||||
queue: AD_DELETED_QUEUE,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"paths": {
|
||||
"@libs/*": ["src/libs/*"],
|
||||
|
|
Loading…
Reference in New Issue