From ea85cbb4913b209c90ee179cdf0ecb8fff8bbee9 Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 24 Nov 2023 13:49:29 +0900 Subject: [PATCH 1/5] fix: project, channel select order (#85) --- apps/api/src/domains/channel/channel/channel.mysql.service.ts | 2 +- apps/api/src/domains/project/project/project.service.spec.ts | 4 ++-- apps/api/src/domains/project/project/project.service.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/api/src/domains/channel/channel/channel.mysql.service.ts b/apps/api/src/domains/channel/channel/channel.mysql.service.ts index b35310b7a..06916f7ae 100644 --- a/apps/api/src/domains/channel/channel/channel.mysql.service.ts +++ b/apps/api/src/domains/channel/channel/channel.mysql.service.ts @@ -69,7 +69,7 @@ export class ChannelMySQLService { return await paginate( this.repository.createQueryBuilder().setFindOptions({ where: { project: { id: projectId }, name: Like(`%${searchText}%`) }, - order: { createdAt: 'DESC' }, + order: { createdAt: 'ASC' }, }), options, ); diff --git a/apps/api/src/domains/project/project/project.service.spec.ts b/apps/api/src/domains/project/project/project.service.spec.ts index 5c7cf145f..9dd3d8b35 100644 --- a/apps/api/src/domains/project/project/project.service.spec.ts +++ b/apps/api/src/domains/project/project/project.service.spec.ts @@ -316,7 +316,7 @@ describe('ProjectService Test suite', () => { expect(createQueryBuilder.setFindOptions).toBeCalledTimes(1); expect(createQueryBuilder.setFindOptions).toBeCalledWith({ where: { name: Like(`%${dto.searchText}%`) }, - order: { createdAt: 'DESC' }, + order: { createdAt: 'ASC' }, }); }); it('finding all projects succeds as a GENERAL user', async () => { @@ -338,7 +338,7 @@ describe('ProjectService Test suite', () => { name: Like(`%${dto.searchText}%`), roles: { members: { user: { id: userId } } }, }, - order: { createdAt: 'DESC' }, + order: { createdAt: 'ASC' }, }); }); }); diff --git a/apps/api/src/domains/project/project/project.service.ts b/apps/api/src/domains/project/project/project.service.ts index 1780cb7ce..45534584d 100644 --- a/apps/api/src/domains/project/project/project.service.ts +++ b/apps/api/src/domains/project/project/project.service.ts @@ -149,7 +149,7 @@ export class ProjectService { return await paginate( this.projectRepo.createQueryBuilder().setFindOptions({ where: { name: Like(`%${searchText}%`) }, - order: { createdAt: 'DESC' }, + order: { createdAt: 'ASC' }, }), options, ); @@ -161,7 +161,7 @@ export class ProjectService { name: Like(`%${searchText}%`), roles: { members: { user: { id: user.id } } }, }, - order: { createdAt: 'DESC' }, + order: { createdAt: 'ASC' }, }), options, ); From c5f9ab8d5453b08fe614d4528a16f7425f51f180 Mon Sep 17 00:00:00 2001 From: Carson Date: Mon, 27 Nov 2023 12:05:19 +0900 Subject: [PATCH 2/5] feat: feedback statistics api (#86) * feat: feedback statistics api * feat: feedback statistics issued ratio * fix: feedback-statistics migration * feat: timezone offset in project * feat: feedback statistics api - cron job to create stats - register cron job on project creation - migration api for stats --- apps/api/package.json | 1 + apps/api/src/app.module.ts | 4 + .../migrations/1692159572819-init.ts | 15 + .../1692690482919-issue-name-unique.ts | 15 + .../1700795163534-feedback-statistics.ts | 39 +++ .../1700795948817-project-timezone-offset.ts | 32 ++ apps/api/src/configs/mysql.config.ts | 3 + .../domains/channel/channel/channel.entity.ts | 10 + .../domains/migration/migration.controller.ts | 22 +- .../src/domains/migration/migration.module.ts | 2 + .../project/dtos/create-project.dto.ts | 3 + .../requests/create-project-request.dto.ts | 6 + .../find-project-by-id-response.dto.ts | 6 + .../domains/project/project/project.entity.ts | 8 + .../domains/project/project/project.module.ts | 2 + .../project/project/project.service.spec.ts | 23 +- .../project/project/project.service.ts | 4 + .../dtos/get-count-by-date-by-channel.dto.ts | 21 ++ .../statistics/feedback/dtos/get-count.dto.ts | 20 ++ .../feedback/dtos/get-issued-rate.dto.ts | 20 ++ .../domains/statistics/feedback/dtos/index.ts | 18 + ...d-count-by-date-by-channel-response.dto.ts | 33 ++ .../dtos/responses/find-count-response.dto.ts | 29 ++ .../dtos/responses/find-issued-rate.dto.ts | 29 ++ .../feedback/dtos/responses/index.ts | 19 + .../feedback-statistics.controller.spec.ts | 86 +++++ .../feedback-statistics.controller.ts | 82 +++++ .../feedback/feedback-statistics.entity.ts | 62 ++++ .../feedback/feedback-statistics.module.ts | 56 +++ .../feedback-statistics.service.spec.ts | 325 ++++++++++++++++++ .../feedback/feedback-statistics.service.ts | 220 ++++++++++++ .../feedback-statistics.service.providers.ts | 50 +++ .../providers/project.service.providers.ts | 2 + apps/api/src/test-utils/util-functions.ts | 16 +- packages/ufb-shared/src/index.ts | 1 + packages/ufb-shared/src/timezone.ts | 54 +++ packages/ufb-ui/tsconfig.tsbuildinfo | 2 +- turbo.json | 54 ++- yarn.lock | 36 +- 39 files changed, 1399 insertions(+), 31 deletions(-) create mode 100644 apps/api/src/configs/modules/typeorm-config/migrations/1700795163534-feedback-statistics.ts create mode 100644 apps/api/src/configs/modules/typeorm-config/migrations/1700795948817-project-timezone-offset.ts create mode 100644 apps/api/src/domains/statistics/feedback/dtos/get-count-by-date-by-channel.dto.ts create mode 100644 apps/api/src/domains/statistics/feedback/dtos/get-count.dto.ts create mode 100644 apps/api/src/domains/statistics/feedback/dtos/get-issued-rate.dto.ts create mode 100644 apps/api/src/domains/statistics/feedback/dtos/index.ts create mode 100644 apps/api/src/domains/statistics/feedback/dtos/responses/find-count-by-date-by-channel-response.dto.ts create mode 100644 apps/api/src/domains/statistics/feedback/dtos/responses/find-count-response.dto.ts create mode 100644 apps/api/src/domains/statistics/feedback/dtos/responses/find-issued-rate.dto.ts create mode 100644 apps/api/src/domains/statistics/feedback/dtos/responses/index.ts create mode 100644 apps/api/src/domains/statistics/feedback/feedback-statistics.controller.spec.ts create mode 100644 apps/api/src/domains/statistics/feedback/feedback-statistics.controller.ts create mode 100644 apps/api/src/domains/statistics/feedback/feedback-statistics.entity.ts create mode 100644 apps/api/src/domains/statistics/feedback/feedback-statistics.module.ts create mode 100644 apps/api/src/domains/statistics/feedback/feedback-statistics.service.spec.ts create mode 100644 apps/api/src/domains/statistics/feedback/feedback-statistics.service.ts create mode 100644 apps/api/src/test-utils/providers/feedback-statistics.service.providers.ts create mode 100644 packages/ufb-shared/src/timezone.ts diff --git a/apps/api/package.json b/apps/api/package.json index 29e677a4f..21ef9b46e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -35,6 +35,7 @@ "@nestjs/passport": "^10.0.2", "@nestjs/platform-express": "^10.2.7", "@nestjs/platform-fastify": "^10.2.7", + "@nestjs/schedule": "^4.0.0", "@nestjs/swagger": "^7.1.14", "@nestjs/terminus": "^10.1.1", "@nestjs/typeorm": "^10.0.0", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 1e782f826..f6efbd53e 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -15,6 +15,7 @@ */ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { ScheduleModule } from '@nestjs/schedule'; import { PrometheusModule } from '@willsoto/nestjs-prometheus'; import { ClsModule } from 'nestjs-cls'; import { LoggerModule } from 'nestjs-pino'; @@ -46,6 +47,7 @@ import { IssueModule } from './domains/project/issue/issue.module'; import { MemberModule } from './domains/project/member/member.module'; import { ProjectModule } from './domains/project/project/project.module'; import { RoleModule } from './domains/project/role/role.module'; +import { FeedbackStatisticsModule } from './domains/statistics/feedback/feedback-statistics.module'; import { TenantModule } from './domains/tenant/tenant.module'; import { UserModule } from './domains/user/user.module'; @@ -66,6 +68,7 @@ const domainModules = [ UserModule, MemberModule, HistoryModule, + FeedbackStatisticsModule, ]; @Module({ @@ -107,6 +110,7 @@ const domainModules = [ global: true, middleware: { mount: true }, }), + ScheduleModule.forRoot(), ...domainModules, ], }) diff --git a/apps/api/src/configs/modules/typeorm-config/migrations/1692159572819-init.ts b/apps/api/src/configs/modules/typeorm-config/migrations/1692159572819-init.ts index a1e9615b0..4747af798 100644 --- a/apps/api/src/configs/modules/typeorm-config/migrations/1692159572819-init.ts +++ b/apps/api/src/configs/modules/typeorm-config/migrations/1692159572819-init.ts @@ -1,3 +1,18 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ import { MigrationInterface, QueryRunner } from 'typeorm'; export class Init1692159572819 implements MigrationInterface { diff --git a/apps/api/src/configs/modules/typeorm-config/migrations/1692690482919-issue-name-unique.ts b/apps/api/src/configs/modules/typeorm-config/migrations/1692690482919-issue-name-unique.ts index 5cad8c4b4..e4b4afe5b 100644 --- a/apps/api/src/configs/modules/typeorm-config/migrations/1692690482919-issue-name-unique.ts +++ b/apps/api/src/configs/modules/typeorm-config/migrations/1692690482919-issue-name-unique.ts @@ -1,3 +1,18 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ import { MigrationInterface, QueryRunner } from 'typeorm'; export class IssueNameUnique1692690482919 implements MigrationInterface { diff --git a/apps/api/src/configs/modules/typeorm-config/migrations/1700795163534-feedback-statistics.ts b/apps/api/src/configs/modules/typeorm-config/migrations/1700795163534-feedback-statistics.ts new file mode 100644 index 000000000..cf1717bce --- /dev/null +++ b/apps/api/src/configs/modules/typeorm-config/migrations/1700795163534-feedback-statistics.ts @@ -0,0 +1,39 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class FeedbackStatistics1700795163534 implements MigrationInterface { + name = 'FeedbackStatistics1700795163534'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE \`feedback_statistics\` (\`id\` int NOT NULL AUTO_INCREMENT, \`date\` date NOT NULL, \`count\` int NOT NULL DEFAULT '0', \`channel_id\` int NULL, UNIQUE INDEX \`channel-date-unique\` (\`channel_id\`, \`date\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`, + ); + await queryRunner.query( + `ALTER TABLE \`feedback_statistics\` ADD CONSTRAINT \`FK_7250a09c7ee486d1d24938a7054\` FOREIGN KEY (\`channel_id\`) REFERENCES \`channels\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`feedback_statistics\` DROP FOREIGN KEY \`FK_7250a09c7ee486d1d24938a7054\``, + ); + await queryRunner.query( + `DROP INDEX \`channel-date-unique\` ON \`feedback_statistics\``, + ); + await queryRunner.query(`DROP TABLE \`feedback_statistics\``); + } +} diff --git a/apps/api/src/configs/modules/typeorm-config/migrations/1700795948817-project-timezone-offset.ts b/apps/api/src/configs/modules/typeorm-config/migrations/1700795948817-project-timezone-offset.ts new file mode 100644 index 000000000..2996063e7 --- /dev/null +++ b/apps/api/src/configs/modules/typeorm-config/migrations/1700795948817-project-timezone-offset.ts @@ -0,0 +1,32 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ProjectTimezoneOffset1700795948817 implements MigrationInterface { + name = 'ProjectTimezoneOffset1700795948817'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`projects\` ADD \`timezone_offset\` varchar(255) NOT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`projects\` DROP COLUMN \`timezone_offset\``, + ); + } +} diff --git a/apps/api/src/configs/mysql.config.ts b/apps/api/src/configs/mysql.config.ts index a0269fce9..08c920f15 100644 --- a/apps/api/src/configs/mysql.config.ts +++ b/apps/api/src/configs/mysql.config.ts @@ -14,8 +14,11 @@ * under the License. */ import { registerAs } from '@nestjs/config'; +import dotenv from 'dotenv'; import Joi from 'joi'; +dotenv.config(); + export const mysqlConfigSchema = Joi.object({ MYSQL_PRIMARY_URL: Joi.string().required(), MYSQL_SECONDARY_URLS: Joi.string() diff --git a/apps/api/src/domains/channel/channel/channel.entity.ts b/apps/api/src/domains/channel/channel/channel.entity.ts index 7210927fc..47ca4336d 100644 --- a/apps/api/src/domains/channel/channel/channel.entity.ts +++ b/apps/api/src/domains/channel/channel/channel.entity.ts @@ -24,6 +24,7 @@ import { } from 'typeorm'; import { CommonEntity } from '@/common/entities'; +import { FeedbackStatisticsEntity } from '@/domains/statistics/feedback/feedback-statistics.entity'; import { FeedbackEntity } from '../../feedback/feedback.entity'; import { ProjectEntity } from '../../project/project/project.entity'; import { FieldEntity } from '../field/field.entity'; @@ -53,6 +54,15 @@ export class ChannelEntity extends CommonEntity { }) feedbacks: Relation[]; + @OneToMany( + () => FeedbackStatisticsEntity, + (feedbackStats) => feedbackStats.channel, + { + cascade: true, + }, + ) + feedbackStats: Relation[]; + static from(name: string, description: string, projectId: number) { const channel = new ChannelEntity(); channel.name = name; diff --git a/apps/api/src/domains/migration/migration.controller.ts b/apps/api/src/domains/migration/migration.controller.ts index ca3eafc58..7f9c15718 100644 --- a/apps/api/src/domains/migration/migration.controller.ts +++ b/apps/api/src/domains/migration/migration.controller.ts @@ -13,16 +13,30 @@ * License for the specific language governing permissions and limitations * under the License. */ -import { Controller, Param, ParseIntPipe, Post } from '@nestjs/common'; +import { Body, Controller, Param, ParseIntPipe, Post } from '@nestjs/common'; +import { FeedbackStatisticsService } from '../statistics/feedback/feedback-statistics.service'; import { MigrationService } from './migration.service'; -@Controller('/channels/:channelId/migration') +@Controller('/migration') export class MigrationController { - constructor(private readonly migrationService: MigrationService) {} + constructor( + private readonly migrationService: MigrationService, + private readonly feedbackStatisticsService: FeedbackStatisticsService, + ) {} - @Post() + @Post('/channels/:channelId') async migrate(@Param('channelId', ParseIntPipe) channelId: number) { await this.migrationService.migrateToESByChannelId(channelId); } + + @Post('/statistics/feedback') + async migrateFeedbackStatistics( + @Body() body: { projectId: number; day: number }, + ) { + await this.feedbackStatisticsService.createFeedbackStatistics( + body.projectId, + body.day, + ); + } } diff --git a/apps/api/src/domains/migration/migration.module.ts b/apps/api/src/domains/migration/migration.module.ts index 6a76cbf73..7f0dbb7c1 100644 --- a/apps/api/src/domains/migration/migration.module.ts +++ b/apps/api/src/domains/migration/migration.module.ts @@ -25,6 +25,7 @@ import { FieldModule } from '../channel/field/field.module'; import { OptionEntity } from '../channel/option/option.entity'; import { OptionModule } from '../channel/option/option.module'; import { FeedbackEntity } from '../feedback/feedback.entity'; +import { FeedbackStatisticsModule } from '../statistics/feedback/feedback-statistics.module'; import { MigrationController } from './migration.controller'; import { MigrationService } from './migration.service'; @@ -38,6 +39,7 @@ import { MigrationService } from './migration.service'; ]), FieldModule, OptionModule, + FeedbackStatisticsModule, ], providers: [MigrationService, OpensearchRepository], controllers: [MigrationController], diff --git a/apps/api/src/domains/project/project/dtos/create-project.dto.ts b/apps/api/src/domains/project/project/dtos/create-project.dto.ts index 8d55c1129..fafea185c 100644 --- a/apps/api/src/domains/project/project/dtos/create-project.dto.ts +++ b/apps/api/src/domains/project/project/dtos/create-project.dto.ts @@ -13,11 +13,14 @@ * License for the specific language governing permissions and limitations * under the License. */ +import type { TimezoneOffset } from '@ufb/shared'; + import type { CreateRoleDto } from '../../role/dtos'; export class CreateProjectDto { name: string; description: string; + timezoneOffset: TimezoneOffset; roles?: Omit[]; members?: { roleName: string; diff --git a/apps/api/src/domains/project/project/dtos/requests/create-project-request.dto.ts b/apps/api/src/domains/project/project/dtos/requests/create-project-request.dto.ts index 676171f8e..9830514f6 100644 --- a/apps/api/src/domains/project/project/dtos/requests/create-project-request.dto.ts +++ b/apps/api/src/domains/project/project/dtos/requests/create-project-request.dto.ts @@ -22,6 +22,8 @@ import { Length, } from 'class-validator'; +import { TimezoneOffset } from '@ufb/shared'; + import { CreateIssueTrackerRequestDto } from '@/domains/project/issue-tracker/dtos/requests'; import { CreateRoleRequestDto } from '@/domains/project/role/dtos/requests'; import { IsNullable } from '@/domains/user/decorators'; @@ -53,6 +55,10 @@ export class CreateProjectRequestDto { @IsNullable() description: string | null; + @ApiProperty() + @IsString() + timezoneOffset: TimezoneOffset; + @ApiProperty({ type: [CreateRoleRequestDto], required: false }) @IsArray() @IsOptional() diff --git a/apps/api/src/domains/project/project/dtos/responses/find-project-by-id-response.dto.ts b/apps/api/src/domains/project/project/dtos/responses/find-project-by-id-response.dto.ts index 7e7ad4868..9b5a29582 100644 --- a/apps/api/src/domains/project/project/dtos/responses/find-project-by-id-response.dto.ts +++ b/apps/api/src/domains/project/project/dtos/responses/find-project-by-id-response.dto.ts @@ -16,6 +16,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { Expose, plainToInstance } from 'class-transformer'; +import { TimezoneOffset } from '@ufb/shared'; + export class FindProjectByIdResponseDto { @Expose() @ApiProperty() @@ -29,6 +31,10 @@ export class FindProjectByIdResponseDto { @ApiProperty() description: string; + @Expose() + @ApiProperty() + timezoneOffset: TimezoneOffset; + @Expose() @ApiProperty() createdAt: Date; diff --git a/apps/api/src/domains/project/project/project.entity.ts b/apps/api/src/domains/project/project/project.entity.ts index a6c1ef855..8c74adea1 100644 --- a/apps/api/src/domains/project/project/project.entity.ts +++ b/apps/api/src/domains/project/project/project.entity.ts @@ -22,6 +22,8 @@ import { Relation, } from 'typeorm'; +import { TimezoneOffset } from '@ufb/shared'; + import { CommonEntity } from '@/common/entities'; import type { ApiKeyEntity } from '@/domains/project/api-key/api-key.entity'; import type { IssueTrackerEntity } from '@/domains/project/issue-tracker/issue-tracker.entity'; @@ -38,6 +40,9 @@ export class ProjectEntity extends CommonEntity { @Column('varchar', { nullable: true }) description: string; + @Column('varchar') + timezoneOffset: TimezoneOffset; + @OneToMany(() => ChannelEntity, (channel) => channel.project, { cascade: true, }) @@ -72,16 +77,19 @@ export class ProjectEntity extends CommonEntity { tenantId, name, description, + timezoneOffset, }: { tenantId: number; name: string; description: string; + timezoneOffset: TimezoneOffset; }) { const project = new ProjectEntity(); project.tenant = new TenantEntity(); project.tenant.id = tenantId; project.name = name; project.description = description; + project.timezoneOffset = timezoneOffset; return project; } diff --git a/apps/api/src/domains/project/project/project.module.ts b/apps/api/src/domains/project/project/project.module.ts index 60b1484a1..99e6cd4e5 100644 --- a/apps/api/src/domains/project/project/project.module.ts +++ b/apps/api/src/domains/project/project/project.module.ts @@ -24,6 +24,7 @@ import { OptionEntity } from '@/domains/channel/option/option.entity'; import { OptionModule } from '@/domains/channel/option/option.module'; import { FeedbackEntity } from '@/domains/feedback/feedback.entity'; import { FeedbackModule } from '@/domains/feedback/feedback.module'; +import { FeedbackStatisticsModule } from '@/domains/statistics/feedback/feedback-statistics.module'; import { TenantModule } from '@/domains/tenant/tenant.module'; import { ApiKeyModule } from '../api-key/api-key.module'; import { IssueTrackerModule } from '../issue-tracker/issue-tracker.module'; @@ -54,6 +55,7 @@ import { ProjectService } from './project.service'; ApiKeyModule, MemberModule, IssueTrackerModule, + FeedbackStatisticsModule, ], providers: [ProjectService, OpensearchRepository], controllers: [ProjectController], diff --git a/apps/api/src/domains/project/project/project.service.spec.ts b/apps/api/src/domains/project/project/project.service.spec.ts index 9dd3d8b35..064a75c4b 100644 --- a/apps/api/src/domains/project/project/project.service.spec.ts +++ b/apps/api/src/domains/project/project/project.service.spec.ts @@ -88,6 +88,9 @@ describe('ProjectService Test suite', () => { jest .spyOn(projectRepo, 'save') .mockResolvedValue({ id: projectId } as any); + jest.spyOn(projectRepo, 'findOne').mockResolvedValue({ + timezoneOffset: '+09:00', + } as ProjectEntity); const { id } = await projectService.create(dto); @@ -110,6 +113,9 @@ describe('ProjectService Test suite', () => { .spyOn(projectRepo, 'save') .mockResolvedValue({ id: projectId } as any); jest.spyOn(roleRepo, 'findOneBy').mockResolvedValue(null); + jest.spyOn(projectRepo, 'findOne').mockResolvedValue({ + timezoneOffset: '+09:00', + } as ProjectEntity); const { id } = await projectService.create(dto); @@ -154,6 +160,9 @@ describe('ProjectService Test suite', () => { ...MemberEntity.from({ roleId: faker.number.int(), userId }), })) as any, ); + jest.spyOn(projectRepo, 'findOne').mockResolvedValue({ + timezoneOffset: '+09:00', + } as ProjectEntity); const { id } = await projectService.create(dto); @@ -186,7 +195,7 @@ describe('ProjectService Test suite', () => { jest.spyOn(tenantRepo, 'find').mockResolvedValue([{}] as TenantEntity[]); jest .spyOn(projectRepo, 'save') - .mockResolvedValue({ id: projectId } as any); + .mockResolvedValue({ id: projectId, timezoneOffset: '+09:00' } as any); jest.spyOn(roleRepo, 'findOneBy').mockResolvedValue(null); jest.spyOn(roleRepo, 'save').mockResolvedValue( dto.roles.map((role) => ({ @@ -207,6 +216,9 @@ describe('ProjectService Test suite', () => { jest .spyOn(projectRepo, 'findOneBy') .mockResolvedValueOnce({} as ProjectEntity); + jest.spyOn(projectRepo, 'findOne').mockResolvedValue({ + timezoneOffset: '+09:00', + } as ProjectEntity); const { id } = await projectService.create(dto); @@ -245,7 +257,7 @@ describe('ProjectService Test suite', () => { jest.spyOn(tenantRepo, 'find').mockResolvedValue([{}] as TenantEntity[]); jest .spyOn(projectRepo, 'save') - .mockResolvedValue({ id: projectId } as any); + .mockResolvedValue({ id: projectId, timezoneOffset: '+09:00' } as any); jest.spyOn(roleRepo, 'findOneBy').mockResolvedValue(null); jest.spyOn(roleRepo, 'save').mockResolvedValue( dto.roles.map((role) => ({ @@ -255,7 +267,7 @@ describe('ProjectService Test suite', () => { })) as any, ); jest.spyOn(roleRepo, 'findOne').mockResolvedValue({ - project: { id: projectId }, + project: { id: projectId, timezoneOffset: '+09:00' }, } as RoleEntity); jest.spyOn(memberRepo, 'findOne').mockResolvedValue(null); jest.spyOn(memberRepo, 'save').mockResolvedValue( @@ -266,6 +278,9 @@ describe('ProjectService Test suite', () => { jest .spyOn(projectRepo, 'findOneBy') .mockResolvedValueOnce({} as ProjectEntity); + jest.spyOn(projectRepo, 'findOne').mockResolvedValue({ + timezoneOffset: '+09:00', + } as ProjectEntity); const { id } = await projectService.create(dto); @@ -285,7 +300,7 @@ describe('ProjectService Test suite', () => { jest.spyOn(tenantRepo, 'find').mockResolvedValue([{}] as TenantEntity[]); jest .spyOn(projectRepo, 'save') - .mockResolvedValue({ id: projectId } as any); + .mockResolvedValue({ id: projectId, timezoneOffset: '+09:00' } as any); await expect(projectService.create(dto)).rejects.toThrowError( ProjectAlreadyExistsException, diff --git a/apps/api/src/domains/project/project/project.service.ts b/apps/api/src/domains/project/project/project.service.ts index 45534584d..7266b74b7 100644 --- a/apps/api/src/domains/project/project/project.service.ts +++ b/apps/api/src/domains/project/project/project.service.ts @@ -21,6 +21,7 @@ import { Like, Not, Repository } from 'typeorm'; import { Transactional } from 'typeorm-transactional'; import { OpensearchRepository } from '@/common/repositories'; +import { FeedbackStatisticsService } from '@/domains/statistics/feedback/feedback-statistics.service'; import { TenantService } from '@/domains/tenant/tenant.service'; import { UserTypeEnum } from '@/domains/user/entities/enums'; import { ChannelEntity } from '../../channel/channel/channel.entity'; @@ -53,6 +54,7 @@ export class ProjectService { private readonly apiKeyService: ApiKeyService, private readonly issueTrackerService: IssueTrackerService, private readonly configService: ConfigService, + private readonly feedbackStatisticsService: FeedbackStatisticsService, ) {} async checkName(name: string) { @@ -141,6 +143,8 @@ export class ProjectService { savedProject.issueTracker = savedIssueTracker; } + await this.feedbackStatisticsService.addCronJobByProjectId(savedProject.id); + return savedProject; } diff --git a/apps/api/src/domains/statistics/feedback/dtos/get-count-by-date-by-channel.dto.ts b/apps/api/src/domains/statistics/feedback/dtos/get-count-by-date-by-channel.dto.ts new file mode 100644 index 000000000..cafc275a2 --- /dev/null +++ b/apps/api/src/domains/statistics/feedback/dtos/get-count-by-date-by-channel.dto.ts @@ -0,0 +1,21 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +export class GetCountByDateByChannelDto { + from: Date; + to: Date; + interval: 'day' | 'week' | 'month'; + channelIds: number[]; +} diff --git a/apps/api/src/domains/statistics/feedback/dtos/get-count.dto.ts b/apps/api/src/domains/statistics/feedback/dtos/get-count.dto.ts new file mode 100644 index 000000000..1c8dd9680 --- /dev/null +++ b/apps/api/src/domains/statistics/feedback/dtos/get-count.dto.ts @@ -0,0 +1,20 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +export class GetCountDto { + from: Date; + to: Date; + projectId: number; +} diff --git a/apps/api/src/domains/statistics/feedback/dtos/get-issued-rate.dto.ts b/apps/api/src/domains/statistics/feedback/dtos/get-issued-rate.dto.ts new file mode 100644 index 000000000..b7d0af607 --- /dev/null +++ b/apps/api/src/domains/statistics/feedback/dtos/get-issued-rate.dto.ts @@ -0,0 +1,20 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +export class GetIssuedRateDto { + from: Date; + to: Date; + projectId: number; +} diff --git a/apps/api/src/domains/statistics/feedback/dtos/index.ts b/apps/api/src/domains/statistics/feedback/dtos/index.ts new file mode 100644 index 000000000..b198881d9 --- /dev/null +++ b/apps/api/src/domains/statistics/feedback/dtos/index.ts @@ -0,0 +1,18 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +export { GetCountByDateByChannelDto } from './get-count-by-date-by-channel.dto'; +export { GetCountDto } from './get-count.dto'; +export { GetIssuedRateDto } from './get-issued-rate.dto'; diff --git a/apps/api/src/domains/statistics/feedback/dtos/responses/find-count-by-date-by-channel-response.dto.ts b/apps/api/src/domains/statistics/feedback/dtos/responses/find-count-by-date-by-channel-response.dto.ts new file mode 100644 index 000000000..5f421aa6b --- /dev/null +++ b/apps/api/src/domains/statistics/feedback/dtos/responses/find-count-by-date-by-channel-response.dto.ts @@ -0,0 +1,33 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, plainToInstance } from 'class-transformer'; + +export class FindCountByDateByChannelResponseDto { + @ApiProperty() + @Expose() + channels: { + id: number; + name: string; + statistics: { date: Date; count: number }; + }[]; + + public static transform(params: any): FindCountByDateByChannelResponseDto { + return plainToInstance(FindCountByDateByChannelResponseDto, params, { + excludeExtraneousValues: true, + }); + } +} diff --git a/apps/api/src/domains/statistics/feedback/dtos/responses/find-count-response.dto.ts b/apps/api/src/domains/statistics/feedback/dtos/responses/find-count-response.dto.ts new file mode 100644 index 000000000..5ed6d75ae --- /dev/null +++ b/apps/api/src/domains/statistics/feedback/dtos/responses/find-count-response.dto.ts @@ -0,0 +1,29 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, plainToInstance } from 'class-transformer'; + +export class FindCountResponseDto { + @ApiProperty() + @Expose() + count: number; + + public static transform(params: any): FindCountResponseDto { + return plainToInstance(FindCountResponseDto, params, { + excludeExtraneousValues: true, + }); + } +} diff --git a/apps/api/src/domains/statistics/feedback/dtos/responses/find-issued-rate.dto.ts b/apps/api/src/domains/statistics/feedback/dtos/responses/find-issued-rate.dto.ts new file mode 100644 index 000000000..4272964ce --- /dev/null +++ b/apps/api/src/domains/statistics/feedback/dtos/responses/find-issued-rate.dto.ts @@ -0,0 +1,29 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, plainToInstance } from 'class-transformer'; + +export class FindIssuedRateResponseDto { + @ApiProperty() + @Expose() + ratio: number; + + public static transform(params: any): FindIssuedRateResponseDto { + return plainToInstance(FindIssuedRateResponseDto, params, { + excludeExtraneousValues: true, + }); + } +} diff --git a/apps/api/src/domains/statistics/feedback/dtos/responses/index.ts b/apps/api/src/domains/statistics/feedback/dtos/responses/index.ts new file mode 100644 index 000000000..979f5445a --- /dev/null +++ b/apps/api/src/domains/statistics/feedback/dtos/responses/index.ts @@ -0,0 +1,19 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +export { FindCountByDateByChannelResponseDto } from './find-count-by-date-by-channel-response.dto'; +export { FindCountResponseDto } from './find-count-response.dto'; +export { FindIssuedRateResponseDto } from './find-issued-rate.dto'; diff --git a/apps/api/src/domains/statistics/feedback/feedback-statistics.controller.spec.ts b/apps/api/src/domains/statistics/feedback/feedback-statistics.controller.spec.ts new file mode 100644 index 000000000..0fa2c006f --- /dev/null +++ b/apps/api/src/domains/statistics/feedback/feedback-statistics.controller.spec.ts @@ -0,0 +1,86 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { faker } from '@faker-js/faker'; +import { Test } from '@nestjs/testing'; + +import { getMockProvider } from '@/test-utils/util-functions'; +import { FeedbackStatisticsController } from './feedback-statistics.controller'; +import { FeedbackStatisticsService } from './feedback-statistics.service'; + +const MockFeedbackStatisticsService = { + getCountByDateByChannel: jest.fn(), + getCount: jest.fn(), + getIssuedRatio: jest.fn(), +}; + +describe('Feedback Statistics Controller', () => { + let feedbackStatisticsController: FeedbackStatisticsController; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + controllers: [FeedbackStatisticsController], + providers: [ + getMockProvider( + FeedbackStatisticsService, + MockFeedbackStatisticsService, + ), + ], + }).compile(); + + feedbackStatisticsController = module.get( + FeedbackStatisticsController, + ); + }); + + it('getCountByDateByChannel', async () => { + jest.spyOn(MockFeedbackStatisticsService, 'getCountByDateByChannel'); + const from = faker.date.past(); + const to = faker.date.future(); + const interval = ['day', 'week', 'month'][ + faker.number.int({ min: 0, max: 2 }) + ] as 'day' | 'week' | 'month'; + const channelIds = [faker.number.int(), faker.number.int()]; + + await feedbackStatisticsController.getCountByDateByChannel( + from, + to, + interval, + channelIds.join(','), + ); + + expect( + MockFeedbackStatisticsService.getCountByDateByChannel, + ).toBeCalledTimes(1); + }); + + it('getCount', async () => { + jest.spyOn(MockFeedbackStatisticsService, 'getCountByDateByChannel'); + const from = faker.date.past(); + const to = faker.date.future(); + const projectId = faker.number.int(); + await feedbackStatisticsController.getCount(from, to, projectId); + expect(MockFeedbackStatisticsService.getCount).toBeCalledTimes(1); + }); + + it('getIssuedRatio', async () => { + jest.spyOn(MockFeedbackStatisticsService, 'getIssuedRatio'); + const from = faker.date.past(); + const to = faker.date.future(); + const projectId = faker.number.int(); + await feedbackStatisticsController.getIssuedRatio(from, to, projectId); + expect(MockFeedbackStatisticsService.getIssuedRatio).toBeCalledTimes(1); + }); +}); diff --git a/apps/api/src/domains/statistics/feedback/feedback-statistics.controller.ts b/apps/api/src/domains/statistics/feedback/feedback-statistics.controller.ts new file mode 100644 index 000000000..e94d2fc9b --- /dev/null +++ b/apps/api/src/domains/statistics/feedback/feedback-statistics.controller.ts @@ -0,0 +1,82 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiOkResponse } from '@nestjs/swagger'; + +import { + FindCountByDateByChannelResponseDto, + FindCountResponseDto, + FindIssuedRateResponseDto, +} from './dtos/responses'; +import { FeedbackStatisticsService } from './feedback-statistics.service'; + +@Controller('/statistics/feedback') +export class FeedbackStatisticsController { + constructor( + private readonly feedbackStatisticsService: FeedbackStatisticsService, + ) {} + + @ApiOkResponse({ type: [FindCountByDateByChannelResponseDto] }) + @Get() + async getCountByDateByChannel( + @Query('from') from: Date, + @Query('to') to: Date, + @Query('interval') interval: 'day' | 'week' | 'month', + @Query('channelIds') channelIds: string, + ) { + const channelIdsArray = channelIds.split(',').map((v) => parseInt(v, 10)); + return FindCountByDateByChannelResponseDto.transform( + await this.feedbackStatisticsService.getCountByDateByChannel({ + from, + to, + interval, + channelIds: channelIdsArray, + }), + ); + } + + @ApiOkResponse({ type: [FindCountResponseDto] }) + @Get('/count') + async getCount( + @Query('from') from: Date, + @Query('to') to: Date, + @Query('projectId') projectId: number, + ) { + return FindCountResponseDto.transform( + await this.feedbackStatisticsService.getCount({ + from, + to, + projectId, + }), + ); + } + + @ApiOkResponse({ type: [FindIssuedRateResponseDto] }) + @Get('/issued-ratio') + async getIssuedRatio( + @Query('from') from: Date, + @Query('to') to: Date, + @Query('projectId') projectId: number, + ) { + return FindIssuedRateResponseDto.transform( + await this.feedbackStatisticsService.getIssuedRatio({ + from, + to, + projectId, + }), + ); + } +} diff --git a/apps/api/src/domains/statistics/feedback/feedback-statistics.entity.ts b/apps/api/src/domains/statistics/feedback/feedback-statistics.entity.ts new file mode 100644 index 000000000..2fd7dd609 --- /dev/null +++ b/apps/api/src/domains/statistics/feedback/feedback-statistics.entity.ts @@ -0,0 +1,62 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { + Column, + Entity, + ManyToOne, + PrimaryGeneratedColumn, + Relation, + Unique, +} from 'typeorm'; + +import { ChannelEntity } from '../../channel/channel/channel.entity'; + +@Entity('feedback_statistics') +@Unique('channel-date-unique', ['channel', 'date']) +export class FeedbackStatisticsEntity { + @PrimaryGeneratedColumn('increment') + id: number; + + @Column('date') + date: Date; + + @Column('int', { default: 0 }) + count: number; + + @ManyToOne(() => ChannelEntity, (channel) => channel.fields, { + onDelete: 'CASCADE', + orphanedRowAction: 'delete', + }) + channel: Relation; + + static from({ + date, + count, + channelId, + }: { + date: Date; + count: number; + channelId: number; + }) { + const feedbackStats = new FeedbackStatisticsEntity(); + feedbackStats.channel = new ChannelEntity(); + feedbackStats.channel.id = channelId; + feedbackStats.date = date; + feedbackStats.count = count; + + return feedbackStats; + } +} diff --git a/apps/api/src/domains/statistics/feedback/feedback-statistics.module.ts b/apps/api/src/domains/statistics/feedback/feedback-statistics.module.ts new file mode 100644 index 000000000..b3991d212 --- /dev/null +++ b/apps/api/src/domains/statistics/feedback/feedback-statistics.module.ts @@ -0,0 +1,56 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { Module } from '@nestjs/common'; +import { InjectRepository, TypeOrmModule } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { ChannelEntity } from '@/domains/channel/channel/channel.entity'; +import { FeedbackEntity } from '@/domains/feedback/feedback.entity'; +import { IssueEntity } from '@/domains/project/issue/issue.entity'; +import { ProjectEntity } from '@/domains/project/project/project.entity'; +import { FeedbackStatisticsController } from './feedback-statistics.controller'; +import { FeedbackStatisticsEntity } from './feedback-statistics.entity'; +import { FeedbackStatisticsService } from './feedback-statistics.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + FeedbackStatisticsEntity, + FeedbackEntity, + IssueEntity, + ChannelEntity, + ProjectEntity, + ]), + ], + exports: [FeedbackStatisticsService], + providers: [FeedbackStatisticsService], + controllers: [FeedbackStatisticsController], +}) +export class FeedbackStatisticsModule { + constructor( + private readonly service: FeedbackStatisticsService, + @InjectRepository(ProjectEntity) + private readonly projectRepo: Repository, + ) {} + async onModuleInit() { + const projects = await this.projectRepo.find({ + select: ['id'], + }); + for (const { id } of projects) { + await this.service.addCronJobByProjectId(id); + } + } +} diff --git a/apps/api/src/domains/statistics/feedback/feedback-statistics.service.spec.ts b/apps/api/src/domains/statistics/feedback/feedback-statistics.service.spec.ts new file mode 100644 index 000000000..3186caf3e --- /dev/null +++ b/apps/api/src/domains/statistics/feedback/feedback-statistics.service.spec.ts @@ -0,0 +1,325 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { faker } from '@faker-js/faker'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { Test } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import type { Repository } from 'typeorm'; + +import { ChannelEntity } from '@/domains/channel/channel/channel.entity'; +import { FeedbackEntity } from '@/domains/feedback/feedback.entity'; +import { IssueEntity } from '@/domains/project/issue/issue.entity'; +import { ProjectEntity } from '@/domains/project/project/project.entity'; +import { FeedbackStatisticsServiceProviders } from '@/test-utils/providers/feedback-statistics.service.providers'; +import { createQueryBuilder, TestConfig } from '@/test-utils/util-functions'; +import { GetCountByDateByChannelDto, GetCountDto } from './dtos'; +import { FeedbackStatisticsEntity } from './feedback-statistics.entity'; +import { FeedbackStatisticsService } from './feedback-statistics.service'; + +const feedbackStatsFixture = [ + { + id: 1, + date: new Date('2023-01-01'), + count: 1, + channel: { + id: 1, + name: 'channel1', + }, + }, + { + id: 2, + date: new Date('2023-01-02'), + count: 2, + channel: { + id: 1, + name: 'channel1', + }, + }, + { + id: 3, + date: new Date('2023-01-08'), + count: 3, + channel: { + id: 1, + name: 'channel1', + }, + }, + { + id: 4, + date: new Date('2023-02-01'), + count: 4, + channel: { + id: 1, + name: 'channel1', + }, + }, +] as FeedbackStatisticsEntity[]; + +describe('FieldService suite', () => { + let feedbackStatsService: FeedbackStatisticsService; + let feedbackStatsRepo: Repository; + let feedbackRepo: Repository; + let issueRepo: Repository; + let channelRepo: Repository; + let projectRepo: Repository; + let schedulerRegistry: SchedulerRegistry; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + imports: [TestConfig], + providers: FeedbackStatisticsServiceProviders, + }).compile(); + + feedbackStatsService = module.get( + FeedbackStatisticsService, + ); + feedbackStatsRepo = module.get( + getRepositoryToken(FeedbackStatisticsEntity), + ); + feedbackRepo = module.get(getRepositoryToken(FeedbackEntity)); + issueRepo = module.get(getRepositoryToken(IssueEntity)); + channelRepo = module.get(getRepositoryToken(ChannelEntity)); + projectRepo = module.get(getRepositoryToken(ProjectEntity)); + schedulerRegistry = module.get(SchedulerRegistry); + }); + + describe('getCountByDateByChannel', () => { + it('getting counts by day by channel succeeds with valid inputs', async () => { + const from = faker.date.past(); + const to = faker.date.future(); + const interval = 'day'; + const channelIds = [faker.number.int(), faker.number.int()]; + const dto = new GetCountByDateByChannelDto(); + dto.from = from; + dto.to = to; + dto.interval = interval; + dto.channelIds = channelIds; + jest + .spyOn(feedbackStatsRepo, 'find') + .mockResolvedValue(feedbackStatsFixture); + + const countByDateByChannel = + await feedbackStatsService.getCountByDateByChannel(dto); + + expect(feedbackStatsRepo.find).toBeCalledTimes(1); + expect(countByDateByChannel).toEqual({ + channels: [ + { + id: 1, + name: 'channel1', + statistics: [ + { + count: 1, + date: '2023-01-01', + }, + { + count: 2, + date: '2023-01-02', + }, + { + count: 3, + date: '2023-01-08', + }, + { + count: 4, + date: '2023-02-01', + }, + ], + }, + ], + }); + }); + it('getting counts by week by channel succeeds with valid inputs', async () => { + const from = faker.date.past(); + const to = faker.date.future(); + const interval = 'week'; + const channelIds = [faker.number.int(), faker.number.int()]; + const dto = new GetCountByDateByChannelDto(); + dto.from = from; + dto.to = to; + dto.interval = interval; + dto.channelIds = channelIds; + jest + .spyOn(feedbackStatsRepo, 'find') + .mockResolvedValue(feedbackStatsFixture); + + const countByDateByChannel = + await feedbackStatsService.getCountByDateByChannel(dto); + + expect(feedbackStatsRepo.find).toBeCalledTimes(1); + expect(countByDateByChannel).toEqual({ + channels: [ + { + id: 1, + name: 'channel1', + statistics: [ + { + count: 3, + date: '2023-01-07', + }, + { + count: 3, + date: '2023-01-14', + }, + { + count: 4, + date: '2023-02-04', + }, + ], + }, + ], + }); + }); + it('getting counts by month by channel succeeds with valid inputs', async () => { + const from = faker.date.past(); + const to = faker.date.future(); + const interval = 'month'; + const channelIds = [faker.number.int(), faker.number.int()]; + const dto = new GetCountByDateByChannelDto(); + dto.from = from; + dto.to = to; + dto.interval = interval; + dto.channelIds = channelIds; + jest + .spyOn(feedbackStatsRepo, 'find') + .mockResolvedValue(feedbackStatsFixture); + + const countByDateByChannel = + await feedbackStatsService.getCountByDateByChannel(dto); + + expect(feedbackStatsRepo.find).toBeCalledTimes(1); + expect(countByDateByChannel).toEqual({ + channels: [ + { + id: 1, + name: 'channel1', + statistics: [ + { + count: 6, + date: '2023-01-31', + }, + { + count: 4, + date: '2023-02-28', + }, + ], + }, + ], + }); + }); + }); + + describe('getCount', () => { + it('getting count succeeds with valid inputs', async () => { + const from = faker.date.past(); + const to = faker.date.future(); + const projectId = faker.number.int(); + const dto = new GetCountDto(); + dto.from = from; + dto.to = to; + dto.projectId = projectId; + jest + .spyOn(feedbackRepo, 'count') + .mockResolvedValue(feedbackStatsFixture.length); + + const countByDateByChannel = await feedbackStatsService.getCount(dto); + + expect(feedbackRepo.count).toBeCalledTimes(1); + expect(countByDateByChannel).toEqual({ + count: feedbackStatsFixture.length, + }); + }); + }); + + describe('getIssuedRatio', () => { + it('getting issued ratio succeeds with valid inputs', async () => { + const from = faker.date.past(); + const to = faker.date.future(); + const projectId = faker.number.int(); + const dto = new GetCountDto(); + dto.from = from; + dto.to = to; + dto.projectId = projectId; + jest + .spyOn(issueRepo, 'createQueryBuilder') + .mockImplementation(() => createQueryBuilder); + jest + .spyOn(createQueryBuilder, 'getRawMany') + .mockResolvedValue(feedbackStatsFixture); + jest + .spyOn(feedbackRepo, 'count') + .mockResolvedValue(feedbackStatsFixture.length); + + const countByDateByChannel = + await feedbackStatsService.getIssuedRatio(dto); + + expect(feedbackRepo.count).toBeCalledTimes(1); + expect(countByDateByChannel).toEqual({ + ratio: 1, + }); + }); + }); + + describe('addCronJobByProjectId', () => { + it('adding a cron job succeeds with valid input', async () => { + const projectId = faker.number.int(); + jest.spyOn(projectRepo, 'findOne').mockResolvedValue({ + timezoneOffset: '+09:00', + } as ProjectEntity); + jest.spyOn(schedulerRegistry, 'addCronJob'); + + await feedbackStatsService.addCronJobByProjectId(projectId); + + expect(schedulerRegistry.addCronJob).toBeCalledTimes(1); + expect(schedulerRegistry.addCronJob).toBeCalledWith( + `feedback-statistics-${projectId}`, + expect.anything(), + ); + }); + }); + + describe('createFeedbackStatistics', () => { + it('creating feedback statistics data succeeds with valid inputs', async () => { + const projectId = faker.number.int(); + const dayToCreate = faker.number.int({ min: 2, max: 10 }); + const channelCount = faker.number.int({ min: 2, max: 10 }); + const channels = Array.from({ length: channelCount }).map(() => ({ + id: faker.number.int(), + })); + jest.spyOn(projectRepo, 'findOne').mockResolvedValue({ + timezoneOffset: '+09:00', + } as ProjectEntity); + jest + .spyOn(channelRepo, 'find') + .mockResolvedValue(channels as ChannelEntity[]); + jest.spyOn(feedbackRepo, 'count').mockResolvedValueOnce(0); + jest.spyOn(feedbackRepo, 'count').mockResolvedValue(1); + jest + .spyOn(feedbackStatsRepo, 'createQueryBuilder') + .mockImplementation(() => createQueryBuilder); + + await feedbackStatsService.createFeedbackStatistics( + projectId, + dayToCreate, + ); + + expect(feedbackRepo.count).toBeCalledTimes(dayToCreate * channelCount); + expect(feedbackStatsRepo.createQueryBuilder).toBeCalledTimes( + dayToCreate * channelCount - 1, + ); + }); + }); +}); diff --git a/apps/api/src/domains/statistics/feedback/feedback-statistics.service.ts b/apps/api/src/domains/statistics/feedback/feedback-statistics.service.ts new file mode 100644 index 000000000..ae9addfe1 --- /dev/null +++ b/apps/api/src/domains/statistics/feedback/feedback-statistics.service.ts @@ -0,0 +1,220 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { Injectable, Logger } from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { CronJob } from 'cron'; +import dayjs from 'dayjs'; +import dotenv from 'dotenv'; +import { Between, In, Repository } from 'typeorm'; +import { Transactional } from 'typeorm-transactional'; + +import { ChannelEntity } from '@/domains/channel/channel/channel.entity'; +import { FeedbackEntity } from '@/domains/feedback/feedback.entity'; +import { IssueEntity } from '@/domains/project/issue/issue.entity'; +import { ProjectEntity } from '@/domains/project/project/project.entity'; +import type { + GetCountByDateByChannelDto, + GetCountDto, + GetIssuedRateDto, +} from './dtos'; +import { FeedbackStatisticsEntity } from './feedback-statistics.entity'; + +dotenv.config(); + +@Injectable() +export class FeedbackStatisticsService { + private logger = new Logger(FeedbackStatisticsService.name); + + constructor( + @InjectRepository(FeedbackStatisticsEntity) + private readonly repository: Repository, + @InjectRepository(FeedbackEntity) + private readonly feedbackRepository: Repository, + @InjectRepository(IssueEntity) + private readonly issueRepository: Repository, + @InjectRepository(ChannelEntity) + private readonly channelRepository: Repository, + @InjectRepository(ProjectEntity) + private readonly projectRepository: Repository, + private readonly schedulerRegistry: SchedulerRegistry, + ) {} + + async getCountByDateByChannel(dto: GetCountByDateByChannelDto) { + const { from, to, interval, channelIds } = dto; + + const feedbackStatistics = await this.repository.find({ + where: { + channel: In(channelIds), + date: Between(from, to), + }, + relations: { channel: true }, + order: { channel: { id: 'ASC' }, date: 'ASC' }, + }); + + return { + channels: feedbackStatistics.reduce( + (acc, curr) => { + let channel = acc.find((ch) => ch.id === curr.channel.id); + if (!channel) { + channel = { + id: curr.channel.id, + name: curr.channel.name, + statistics: [], + }; + acc.push(channel); + } + + let endDate: dayjs.Dayjs; + switch (interval) { + case 'week': + endDate = dayjs(curr.date).endOf('week'); + break; + case 'month': + endDate = dayjs(curr.date).endOf('month'); + break; + default: + endDate = dayjs(curr.date); + } + + let statistic = channel.statistics.find( + (stat) => stat.date === endDate.format('YYYY-MM-DD'), + ); + if (!statistic) { + statistic = { + date: endDate.format('YYYY-MM-DD'), + count: 0, + }; + channel.statistics.push(statistic); + } + statistic.count += curr.count; + + return acc; + }, + [] as { + id: number; + name: string; + statistics: { date: string; count: number }[]; + }[], + ), + }; + } + + async getCount(dto: GetCountDto) { + return { + count: await this.feedbackRepository.count({ + where: { + createdAt: Between(dto.from, dto.to), + channel: { project: { id: dto.projectId } }, + }, + }), + }; + } + + async getIssuedRatio(dto: GetIssuedRateDto) { + return { + ratio: + ( + await this.issueRepository + .createQueryBuilder('issue') + .select('feedbacks.id') + .innerJoin('issue.feedbacks', 'feedbacks') + .innerJoin('feedbacks.channel', 'channel') + .innerJoin('channel.project', 'project') + .where('feedbacks.createdAt BETWEEN :from AND :to', { + from: dto.from, + to: dto.to, + }) + .andWhere('project.id = :projectId', { + projectId: dto.projectId, + }) + .groupBy('feedbacks.id') + .getRawMany() + ).length / + (await this.feedbackRepository.count({ + where: { + createdAt: Between(dto.from, dto.to), + channel: { project: { id: dto.projectId } }, + }, + })), + }; + } + + async addCronJobByProjectId(projectId: number) { + const { timezoneOffset } = await this.projectRepository.findOne({ + where: { id: projectId }, + }); + + const cronHour = (24 - Number(timezoneOffset.split(':')[0])) % 24; + + const job = new CronJob(`0 ${cronHour} * * *`, async () => { + await this.createFeedbackStatistics(projectId); + }); + this.schedulerRegistry.addCronJob(`feedback-statistics-${projectId}`, job); + job.start(); + + this.logger.log(`feedback-statistics-${projectId} cron job started`); + } + + @Transactional() + async createFeedbackStatistics(projectId: number, dayToCreate: number = 1) { + const { timezoneOffset } = await this.projectRepository.findOne({ + where: { id: projectId }, + }); + const [hours, minutes] = timezoneOffset.split(':'); + const offset = Number(hours) + Number(minutes) / 60; + + const channels = await this.channelRepository.find({ + where: { project: { id: projectId } }, + }); + + for (let day = 1; day <= dayToCreate; day++) { + for (const channel of channels) { + const feedbackCount = await this.feedbackRepository.count({ + where: { + channel: { id: channel.id }, + createdAt: Between( + dayjs() + .subtract(day, 'day') + .startOf('day') + .subtract(offset, 'hour') + .toDate(), + dayjs() + .subtract(day, 'day') + .endOf('day') + .subtract(offset, 'hour') + .toDate(), + ), + }, + }); + + if (feedbackCount === 0) continue; + + await this.repository + .createQueryBuilder() + .insert() + .values({ + date: dayjs().subtract(day, 'day').toDate(), + count: feedbackCount, + channel: { id: channel.id }, + }) + .orUpdate(['count'], ['date', 'channel']) + .updateEntity(false) + .execute(); + } + } + } +} diff --git a/apps/api/src/test-utils/providers/feedback-statistics.service.providers.ts b/apps/api/src/test-utils/providers/feedback-statistics.service.providers.ts new file mode 100644 index 000000000..a87d91314 --- /dev/null +++ b/apps/api/src/test-utils/providers/feedback-statistics.service.providers.ts @@ -0,0 +1,50 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { SchedulerRegistry } from '@nestjs/schedule'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { ChannelEntity } from '@/domains/channel/channel/channel.entity'; +import { FeedbackEntity } from '@/domains/feedback/feedback.entity'; +import { IssueEntity } from '@/domains/project/issue/issue.entity'; +import { ProjectEntity } from '@/domains/project/project/project.entity'; +import { FeedbackStatisticsEntity } from '@/domains/statistics/feedback/feedback-statistics.entity'; +import { FeedbackStatisticsService } from '@/domains/statistics/feedback/feedback-statistics.service'; +import { mockRepository } from '@/test-utils/util-functions'; + +export const FeedbackStatisticsServiceProviders = [ + FeedbackStatisticsService, + { + provide: getRepositoryToken(FeedbackStatisticsEntity), + useValue: mockRepository(), + }, + { + provide: getRepositoryToken(FeedbackEntity), + useValue: mockRepository(), + }, + { + provide: getRepositoryToken(IssueEntity), + useValue: mockRepository(), + }, + { + provide: getRepositoryToken(ChannelEntity), + useValue: mockRepository(), + }, + { + provide: getRepositoryToken(ProjectEntity), + useValue: mockRepository(), + }, + SchedulerRegistry, +]; diff --git a/apps/api/src/test-utils/providers/project.service.providers.ts b/apps/api/src/test-utils/providers/project.service.providers.ts index 5f279d61b..371181327 100644 --- a/apps/api/src/test-utils/providers/project.service.providers.ts +++ b/apps/api/src/test-utils/providers/project.service.providers.ts @@ -26,6 +26,7 @@ import { import { ProjectEntity } from '../../domains/project/project/project.entity'; import { ProjectService } from '../../domains/project/project/project.service'; import { ApiKeyServiceProviders } from './api-key.service.providers'; +import { FeedbackStatisticsServiceProviders } from './feedback-statistics.service.providers'; import { IssueTrackerServiceProviders } from './issue-tracker.service.provider'; import { MemberServiceProviders } from './member.service.providers'; import { RoleServiceProviders } from './role.service.providers'; @@ -46,4 +47,5 @@ export const ProjectServiceProviders = [ ...MemberServiceProviders, ...ApiKeyServiceProviders, ...IssueTrackerServiceProviders, + ...FeedbackStatisticsServiceProviders, ]; diff --git a/apps/api/src/test-utils/util-functions.ts b/apps/api/src/test-utils/util-functions.ts index 329b1857c..0b9804f43 100644 --- a/apps/api/src/test-utils/util-functions.ts +++ b/apps/api/src/test-utils/util-functions.ts @@ -84,10 +84,18 @@ export const signInTestUser = async ( export const DEFAULT_FIELD_COUNT = 2; export const createQueryBuilder: any = { - setFindOptions: () => - jest.fn().mockImplementation(() => { - return createQueryBuilder; - }), + setFindOptions: () => jest.fn().mockImplementation(() => createQueryBuilder), + select: () => createQueryBuilder, + innerJoin: () => createQueryBuilder, + where: () => createQueryBuilder, + andWhere: () => createQueryBuilder, + groupBy: () => createQueryBuilder, + getRawMany: () => createQueryBuilder, + insert: () => createQueryBuilder, + values: () => createQueryBuilder, + orUpdate: () => createQueryBuilder, + updateEntity: () => createQueryBuilder, + execute: () => createQueryBuilder, }; export const mockRepository = () => ({ diff --git a/packages/ufb-shared/src/index.ts b/packages/ufb-shared/src/index.ts index e6856463a..c6f2f3074 100644 --- a/packages/ufb-shared/src/index.ts +++ b/packages/ufb-shared/src/index.ts @@ -14,3 +14,4 @@ * under the License. */ export * from './error-code.enum'; +export * from './timezone'; diff --git a/packages/ufb-shared/src/timezone.ts b/packages/ufb-shared/src/timezone.ts new file mode 100644 index 000000000..70a05b047 --- /dev/null +++ b/packages/ufb-shared/src/timezone.ts @@ -0,0 +1,54 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +export type TimezoneOffset = + | '-12:00' + | '-11:00' + | '-10:00' + | '-09:30' + | '-09:00' + | '-08:00' + | '-07:00' + | '-06:00' + | '-05:00' + | '-04:00' + | '-03:30' + | '-03:00' + | '-02:00' + | '-01:00' + | '+00:00' + | '+01:00' + | '+02:00' + | '+03:00' + | '+03:30' + | '+04:00' + | '+04:30' + | '+05:00' + | '+05:30' + | '+05:45' + | '+06:00' + | '+06:30' + | '+07:00' + | '+08:00' + | '+08:45' + | '+09:00' + | '+09:30' + | '+10:00' + | '+10:30' + | '+11:00' + | '+12:00' + | '+12:45' + | '+13:00' + | '+14:00'; diff --git a/packages/ufb-ui/tsconfig.tsbuildinfo b/packages/ufb-ui/tsconfig.tsbuildinfo index 9cbbd907e..09ec3fbd9 100644 --- a/packages/ufb-ui/tsconfig.tsbuildinfo +++ b/packages/ufb-ui/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"program":{"fileNames":["../../node_modules/typescript/lib/lib.es5.d.ts","../../node_modules/typescript/lib/lib.es2015.d.ts","../../node_modules/typescript/lib/lib.es2016.d.ts","../../node_modules/typescript/lib/lib.es2017.d.ts","../../node_modules/typescript/lib/lib.es2018.d.ts","../../node_modules/typescript/lib/lib.es2019.d.ts","../../node_modules/typescript/lib/lib.es2020.d.ts","../../node_modules/typescript/lib/lib.es2021.d.ts","../../node_modules/typescript/lib/lib.es2022.d.ts","../../node_modules/typescript/lib/lib.es2023.d.ts","../../node_modules/typescript/lib/lib.esnext.d.ts","../../node_modules/typescript/lib/lib.dom.d.ts","../../node_modules/typescript/lib/lib.dom.iterable.d.ts","../../node_modules/typescript/lib/lib.es2015.core.d.ts","../../node_modules/typescript/lib/lib.es2015.collection.d.ts","../../node_modules/typescript/lib/lib.es2015.generator.d.ts","../../node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../node_modules/typescript/lib/lib.es2015.promise.d.ts","../../node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../node_modules/typescript/lib/lib.es2017.date.d.ts","../../node_modules/typescript/lib/lib.es2017.object.d.ts","../../node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2017.string.d.ts","../../node_modules/typescript/lib/lib.es2017.intl.d.ts","../../node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../node_modules/typescript/lib/lib.es2018.intl.d.ts","../../node_modules/typescript/lib/lib.es2018.promise.d.ts","../../node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../node_modules/typescript/lib/lib.es2019.array.d.ts","../../node_modules/typescript/lib/lib.es2019.object.d.ts","../../node_modules/typescript/lib/lib.es2019.string.d.ts","../../node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../node_modules/typescript/lib/lib.es2019.intl.d.ts","../../node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../node_modules/typescript/lib/lib.es2020.date.d.ts","../../node_modules/typescript/lib/lib.es2020.promise.d.ts","../../node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2020.string.d.ts","../../node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../node_modules/typescript/lib/lib.es2020.intl.d.ts","../../node_modules/typescript/lib/lib.es2020.number.d.ts","../../node_modules/typescript/lib/lib.es2021.promise.d.ts","../../node_modules/typescript/lib/lib.es2021.string.d.ts","../../node_modules/typescript/lib/lib.es2021.weakref.d.ts","../../node_modules/typescript/lib/lib.es2021.intl.d.ts","../../node_modules/typescript/lib/lib.es2022.array.d.ts","../../node_modules/typescript/lib/lib.es2022.error.d.ts","../../node_modules/typescript/lib/lib.es2022.intl.d.ts","../../node_modules/typescript/lib/lib.es2022.object.d.ts","../../node_modules/typescript/lib/lib.es2022.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2022.string.d.ts","../../node_modules/typescript/lib/lib.es2022.regexp.d.ts","../../node_modules/typescript/lib/lib.es2023.array.d.ts","../../node_modules/typescript/lib/lib.es2023.collection.d.ts","../../node_modules/typescript/lib/lib.esnext.intl.d.ts","../../node_modules/typescript/lib/lib.esnext.disposable.d.ts","../../node_modules/typescript/lib/lib.esnext.decorators.d.ts","../../node_modules/typescript/lib/lib.decorators.d.ts","../../node_modules/typescript/lib/lib.decorators.legacy.d.ts","./postcss.js","../../node_modules/source-map-js/source-map.d.ts","../../node_modules/postcss/lib/previous-map.d.ts","../../node_modules/postcss/lib/input.d.ts","../../node_modules/postcss/lib/css-syntax-error.d.ts","../../node_modules/postcss/lib/declaration.d.ts","../../node_modules/postcss/lib/root.d.ts","../../node_modules/postcss/lib/warning.d.ts","../../node_modules/postcss/lib/lazy-result.d.ts","../../node_modules/postcss/lib/no-work-result.d.ts","../../node_modules/postcss/lib/processor.d.ts","../../node_modules/postcss/lib/result.d.ts","../../node_modules/postcss/lib/document.d.ts","../../node_modules/postcss/lib/rule.d.ts","../../node_modules/postcss/lib/node.d.ts","../../node_modules/postcss/lib/comment.d.ts","../../node_modules/postcss/lib/container.d.ts","../../node_modules/postcss/lib/at-rule.d.ts","../../node_modules/postcss/lib/list.d.ts","../../node_modules/postcss/lib/postcss.d.ts","../../node_modules/tailwindcss/types/generated/corepluginlist.d.ts","../../node_modules/tailwindcss/types/generated/colors.d.ts","../../node_modules/tailwindcss/types/config.d.ts","../../node_modules/tailwindcss/types/index.d.ts","../../node_modules/tailwindcss/plugin.d.ts","../ufb-tailwind/index.d.ts","./tailwind.config.js","./node_modules/@types/react/global.d.ts","../../node_modules/csstype/index.d.ts","../../node_modules/@types/prop-types/index.d.ts","../../node_modules/@types/scheduler/tracing.d.ts","./node_modules/@types/react/index.d.ts","./src/icon/svg/index.ts","./src/icon/icon.tsx","./src/icon/index.ts","./src/inputs/input.tsx","./src/inputs/textinput.tsx","./src/inputs/index.ts","../../node_modules/@types/react/global.d.ts","../../node_modules/@types/react/index.d.ts","../../node_modules/goober/goober.d.ts","../../node_modules/react-hot-toast/dist/index.d.ts","./src/toast/toastbox.tsx","./src/toast/toastpromisebox.tsx","./src/toast/toast.tsx","./src/toast/index.ts","./src/badge/badge.tsx","./src/badge/index.ts","../../node_modules/@floating-ui/utils/src/index.d.ts","../../node_modules/@floating-ui/utils/src/types.d.ts","../../node_modules/@floating-ui/core/src/computeposition.d.ts","../../node_modules/@floating-ui/core/src/detectoverflow.d.ts","../../node_modules/@floating-ui/core/src/middleware/arrow.d.ts","../../node_modules/@floating-ui/core/src/middleware/autoplacement.d.ts","../../node_modules/@floating-ui/core/src/middleware/flip.d.ts","../../node_modules/@floating-ui/core/src/middleware/hide.d.ts","../../node_modules/@floating-ui/core/src/middleware/inline.d.ts","../../node_modules/@floating-ui/core/src/middleware/offset.d.ts","../../node_modules/@floating-ui/core/src/middleware/shift.d.ts","../../node_modules/@floating-ui/core/src/middleware/size.d.ts","../../node_modules/@floating-ui/core/src/types.d.ts","../../node_modules/@floating-ui/dom/src/autoupdate.d.ts","../../node_modules/@floating-ui/dom/src/platform.d.ts","../../node_modules/@floating-ui/utils/dom/src/index.d.ts","../../node_modules/@floating-ui/utils/dom/src/types.d.ts","../../node_modules/@floating-ui/dom/src/index.d.ts","../../node_modules/@floating-ui/dom/src/types.d.ts","../../node_modules/@floating-ui/react-dom/src/arrow.d.ts","../../node_modules/@floating-ui/react-dom/src/usefloating.d.ts","../../node_modules/@floating-ui/react-dom/src/types.d.ts","../../node_modules/@floating-ui/react/src/hooks/usedismiss.d.ts","../../node_modules/@floating-ui/react/src/components/composite.d.ts","../../node_modules/@floating-ui/react/src/components/floatingarrow.d.ts","../../node_modules/@floating-ui/react/src/components/floatingdelaygroup.d.ts","../../node_modules/@floating-ui/react/src/components/floatingfocusmanager.d.ts","../../node_modules/@floating-ui/react/src/components/floatinglist.d.ts","../../node_modules/@floating-ui/react/src/components/floatingoverlay.d.ts","../../node_modules/@floating-ui/react/src/components/floatingportal.d.ts","../../node_modules/@floating-ui/react/src/components/floatingtree.d.ts","../../node_modules/@floating-ui/react/src/hooks/useclick.d.ts","../../node_modules/@floating-ui/react/src/hooks/useclientpoint.d.ts","../../node_modules/@floating-ui/react/src/hooks/usefloating.d.ts","../../node_modules/@floating-ui/react/src/hooks/usefocus.d.ts","../../node_modules/@floating-ui/react/src/hooks/usehover.d.ts","../../node_modules/@floating-ui/react/src/hooks/useid.d.ts","../../node_modules/@floating-ui/react/src/hooks/useinteractions.d.ts","../../node_modules/@floating-ui/react/src/hooks/uselistnavigation.d.ts","../../node_modules/@floating-ui/react/src/hooks/usemergerefs.d.ts","../../node_modules/@floating-ui/react/src/hooks/userole.d.ts","../../node_modules/@floating-ui/react/src/hooks/usetransition.d.ts","../../node_modules/@floating-ui/react/src/hooks/usetypeahead.d.ts","../../node_modules/@floating-ui/react/src/inner.d.ts","../../node_modules/@floating-ui/react/src/safepolygon.d.ts","../../node_modules/@floating-ui/react/src/index.d.ts","../../node_modules/@floating-ui/react/src/types.d.ts","./src/tooltip/tooltip.tsx","./src/tooltip/index.ts","./src/popover/popover.tsx","./src/popover/index.ts","./src/index.ts","./src/icon/constants.ts","./src/types/svg.d.ts","../../node_modules/@types/node/assert.d.ts","../../node_modules/@types/node/assert/strict.d.ts","../../node_modules/buffer/index.d.ts","../../node_modules/undici-types/header.d.ts","../../node_modules/undici-types/readable.d.ts","../../node_modules/undici-types/file.d.ts","../../node_modules/undici-types/fetch.d.ts","../../node_modules/undici-types/formdata.d.ts","../../node_modules/undici-types/connector.d.ts","../../node_modules/undici-types/client.d.ts","../../node_modules/undici-types/errors.d.ts","../../node_modules/undici-types/dispatcher.d.ts","../../node_modules/undici-types/global-dispatcher.d.ts","../../node_modules/undici-types/global-origin.d.ts","../../node_modules/undici-types/pool-stats.d.ts","../../node_modules/undici-types/pool.d.ts","../../node_modules/undici-types/handlers.d.ts","../../node_modules/undici-types/balanced-pool.d.ts","../../node_modules/undici-types/agent.d.ts","../../node_modules/undici-types/mock-interceptor.d.ts","../../node_modules/undici-types/mock-agent.d.ts","../../node_modules/undici-types/mock-client.d.ts","../../node_modules/undici-types/mock-pool.d.ts","../../node_modules/undici-types/mock-errors.d.ts","../../node_modules/undici-types/proxy-agent.d.ts","../../node_modules/undici-types/api.d.ts","../../node_modules/undici-types/cookies.d.ts","../../node_modules/undici-types/patch.d.ts","../../node_modules/undici-types/filereader.d.ts","../../node_modules/undici-types/diagnostics-channel.d.ts","../../node_modules/undici-types/websocket.d.ts","../../node_modules/undici-types/content-type.d.ts","../../node_modules/undici-types/cache.d.ts","../../node_modules/undici-types/interceptors.d.ts","../../node_modules/undici-types/index.d.ts","../../node_modules/@types/node/globals.d.ts","../../node_modules/@types/node/async_hooks.d.ts","../../node_modules/@types/node/buffer.d.ts","../../node_modules/@types/node/child_process.d.ts","../../node_modules/@types/node/cluster.d.ts","../../node_modules/@types/node/console.d.ts","../../node_modules/@types/node/constants.d.ts","../../node_modules/@types/node/crypto.d.ts","../../node_modules/@types/node/dgram.d.ts","../../node_modules/@types/node/diagnostics_channel.d.ts","../../node_modules/@types/node/dns.d.ts","../../node_modules/@types/node/dns/promises.d.ts","../../node_modules/@types/node/domain.d.ts","../../node_modules/@types/node/dom-events.d.ts","../../node_modules/@types/node/events.d.ts","../../node_modules/@types/node/fs.d.ts","../../node_modules/@types/node/fs/promises.d.ts","../../node_modules/@types/node/http.d.ts","../../node_modules/@types/node/http2.d.ts","../../node_modules/@types/node/https.d.ts","../../node_modules/@types/node/inspector.d.ts","../../node_modules/@types/node/module.d.ts","../../node_modules/@types/node/net.d.ts","../../node_modules/@types/node/os.d.ts","../../node_modules/@types/node/path.d.ts","../../node_modules/@types/node/perf_hooks.d.ts","../../node_modules/@types/node/process.d.ts","../../node_modules/@types/node/punycode.d.ts","../../node_modules/@types/node/querystring.d.ts","../../node_modules/@types/node/readline.d.ts","../../node_modules/@types/node/readline/promises.d.ts","../../node_modules/@types/node/repl.d.ts","../../node_modules/@types/node/stream.d.ts","../../node_modules/@types/node/stream/promises.d.ts","../../node_modules/@types/node/stream/consumers.d.ts","../../node_modules/@types/node/stream/web.d.ts","../../node_modules/@types/node/string_decoder.d.ts","../../node_modules/@types/node/test.d.ts","../../node_modules/@types/node/timers.d.ts","../../node_modules/@types/node/timers/promises.d.ts","../../node_modules/@types/node/tls.d.ts","../../node_modules/@types/node/trace_events.d.ts","../../node_modules/@types/node/tty.d.ts","../../node_modules/@types/node/url.d.ts","../../node_modules/@types/node/util.d.ts","../../node_modules/@types/node/v8.d.ts","../../node_modules/@types/node/vm.d.ts","../../node_modules/@types/node/wasi.d.ts","../../node_modules/@types/node/worker_threads.d.ts","../../node_modules/@types/node/zlib.d.ts","../../node_modules/@types/node/globals.global.d.ts","../../node_modules/@types/node/index.d.ts","../../node_modules/@types/accepts/index.d.ts","../../node_modules/@types/aria-query/index.d.ts","../../node_modules/@babel/types/lib/index.d.ts","../../node_modules/@types/babel__generator/index.d.ts","../../node_modules/@babel/parser/typings/babel-parser.d.ts","../../node_modules/@types/babel__template/index.d.ts","../../node_modules/@types/babel__traverse/index.d.ts","../../node_modules/@types/babel__core/index.d.ts","../../node_modules/@types/bcrypt/index.d.ts","../../node_modules/@types/connect/index.d.ts","../../node_modules/@types/body-parser/index.d.ts","../../node_modules/@types/content-disposition/index.d.ts","../../node_modules/@types/cookie/index.d.ts","../../node_modules/@types/cookiejar/index.d.ts","../../node_modules/@types/send/node_modules/@types/mime/index.d.ts","../../node_modules/@types/send/index.d.ts","../../node_modules/@types/qs/index.d.ts","../../node_modules/@types/range-parser/index.d.ts","../../node_modules/@types/express-serve-static-core/index.d.ts","../../node_modules/@types/http-errors/index.d.ts","../../node_modules/@types/mime/mime.d.ts","../../node_modules/@types/mime/index.d.ts","../../node_modules/@types/serve-static/index.d.ts","../../node_modules/@types/express/index.d.ts","../../node_modules/@types/keygrip/index.d.ts","../../node_modules/@types/cookies/index.d.ts","../../node_modules/@types/eslint/helpers.d.ts","../../node_modules/@types/estree/index.d.ts","../../node_modules/@types/json-schema/index.d.ts","../../node_modules/@types/eslint/index.d.ts","../../node_modules/@types/graceful-fs/index.d.ts","../../node_modules/@types/hoist-non-react-statics/index.d.ts","../../node_modules/@types/http-assert/index.d.ts","../../node_modules/@types/istanbul-lib-coverage/index.d.ts","../../node_modules/@types/istanbul-lib-report/index.d.ts","../../node_modules/@types/istanbul-reports/index.d.ts","../../node_modules/@jest/expect-utils/build/index.d.ts","../../node_modules/chalk/index.d.ts","../../node_modules/@sinclair/typebox/typebox.d.ts","../../node_modules/@jest/schemas/build/index.d.ts","../../node_modules/pretty-format/build/index.d.ts","../../node_modules/jest-diff/build/index.d.ts","../../node_modules/jest-matcher-utils/build/index.d.ts","../../node_modules/expect/build/index.d.ts","../../node_modules/@types/jest/index.d.ts","../../node_modules/@types/js-cookie/index.d.ts","../../node_modules/parse5/dist/common/html.d.ts","../../node_modules/parse5/dist/common/token.d.ts","../../node_modules/parse5/dist/common/error-codes.d.ts","../../node_modules/parse5/dist/tokenizer/preprocessor.d.ts","../../node_modules/parse5/dist/tokenizer/index.d.ts","../../node_modules/parse5/dist/tree-adapters/interface.d.ts","../../node_modules/parse5/dist/parser/open-element-stack.d.ts","../../node_modules/parse5/dist/parser/formatting-element-list.d.ts","../../node_modules/parse5/dist/parser/index.d.ts","../../node_modules/parse5/dist/tree-adapters/default.d.ts","../../node_modules/parse5/dist/serializer/index.d.ts","../../node_modules/parse5/dist/common/foreign-content.d.ts","../../node_modules/parse5/dist/index.d.ts","../../node_modules/@types/tough-cookie/index.d.ts","../../node_modules/@types/jsdom/base.d.ts","../../node_modules/@types/jsdom/index.d.ts","../../node_modules/@types/json5/index.d.ts","../../node_modules/@types/koa-compose/index.d.ts","../../node_modules/@types/koa/index.d.ts","../../node_modules/@types/nodemailer/lib/dkim/index.d.ts","../../node_modules/@types/nodemailer/lib/mailer/mail-message.d.ts","../../node_modules/@types/nodemailer/lib/xoauth2/index.d.ts","../../node_modules/@types/nodemailer/lib/mailer/index.d.ts","../../node_modules/@types/nodemailer/lib/mime-node/index.d.ts","../../node_modules/@types/nodemailer/lib/smtp-connection/index.d.ts","../../node_modules/@types/nodemailer/lib/shared/index.d.ts","../../node_modules/@types/nodemailer/lib/json-transport/index.d.ts","../../node_modules/@types/nodemailer/lib/sendmail-transport/index.d.ts","../../node_modules/@types/nodemailer/lib/ses-transport/index.d.ts","../../node_modules/@types/nodemailer/lib/smtp-pool/index.d.ts","../../node_modules/@types/nodemailer/lib/smtp-transport/index.d.ts","../../node_modules/@types/nodemailer/lib/stream-transport/index.d.ts","../../node_modules/@types/nodemailer/index.d.ts","../../node_modules/@types/parse-json/index.d.ts","../../node_modules/@types/react-beautiful-dnd/index.d.ts","../../node_modules/@popperjs/core/lib/enums.d.ts","../../node_modules/@popperjs/core/lib/modifiers/popperoffsets.d.ts","../../node_modules/@popperjs/core/lib/modifiers/flip.d.ts","../../node_modules/@popperjs/core/lib/modifiers/hide.d.ts","../../node_modules/@popperjs/core/lib/modifiers/offset.d.ts","../../node_modules/@popperjs/core/lib/modifiers/eventlisteners.d.ts","../../node_modules/@popperjs/core/lib/modifiers/computestyles.d.ts","../../node_modules/@popperjs/core/lib/modifiers/arrow.d.ts","../../node_modules/@popperjs/core/lib/modifiers/preventoverflow.d.ts","../../node_modules/@popperjs/core/lib/modifiers/applystyles.d.ts","../../node_modules/@popperjs/core/lib/types.d.ts","../../node_modules/@popperjs/core/lib/modifiers/index.d.ts","../../node_modules/@popperjs/core/lib/utils/detectoverflow.d.ts","../../node_modules/@popperjs/core/lib/createpopper.d.ts","../../node_modules/@popperjs/core/lib/popper-lite.d.ts","../../node_modules/@popperjs/core/lib/popper.d.ts","../../node_modules/@popperjs/core/lib/index.d.ts","../../node_modules/@popperjs/core/index.d.ts","../../node_modules/date-fns/typings.d.ts","../../node_modules/react-popper/typings/react-popper.d.ts","../../node_modules/@types/react-datepicker/index.d.ts","../../node_modules/@types/react-dom/index.d.ts","../../node_modules/redux/index.d.ts","../../node_modules/@types/react-redux/index.d.ts","../../node_modules/@types/react-transition-group/config.d.ts","../../node_modules/@types/react-transition-group/transition.d.ts","../../node_modules/@types/react-transition-group/csstransition.d.ts","../../node_modules/@types/react-transition-group/switchtransition.d.ts","../../node_modules/@types/react-transition-group/transitiongroup.d.ts","../../node_modules/@types/react-transition-group/index.d.ts","../../node_modules/@types/scheduler/index.d.ts","../../node_modules/@types/semver/index.d.ts","../../node_modules/@types/stack-utils/index.d.ts","../../node_modules/@types/superagent/index.d.ts","../../node_modules/@types/supertest/index.d.ts","../../node_modules/@types/validator/lib/isboolean.d.ts","../../node_modules/@types/validator/lib/isemail.d.ts","../../node_modules/@types/validator/lib/isfqdn.d.ts","../../node_modules/@types/validator/lib/isiban.d.ts","../../node_modules/@types/validator/lib/isiso31661alpha2.d.ts","../../node_modules/@types/validator/lib/isiso4217.d.ts","../../node_modules/@types/validator/lib/isiso6391.d.ts","../../node_modules/@types/validator/lib/istaxid.d.ts","../../node_modules/@types/validator/lib/isurl.d.ts","../../node_modules/@types/validator/index.d.ts","../../node_modules/@types/yargs-parser/index.d.ts","../../node_modules/@types/yargs/index.d.ts"],"fileInfos":[{"version":"2ac9cdcfb8f8875c18d14ec5796a8b029c426f73ad6dc3ffb580c228b58d1c44","affectsGlobalScope":true},"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","dc48272d7c333ccf58034c0026162576b7d50ea0e69c3b9292f803fc20720fd5","9a68c0c07ae2fa71b44384a839b7b8d81662a236d4b9ac30916718f7510b1b2d","5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","5514e54f17d6d74ecefedc73c504eadffdeda79c7ea205cf9febead32d45c4bc","1c0cdb8dc619bc549c3e5020643e7cf7ae7940058e8c7e5aefa5871b6d86f44b","bed7b7ba0eb5a160b69af72814b4dde371968e40b6c5e73d3a9f7bee407d158c",{"version":"0075fa5ceda385bcdf3488e37786b5a33be730e8bc4aa3cf1e78c63891752ce8","affectsGlobalScope":true},{"version":"35299ae4a62086698444a5aaee27fc7aa377c68cbb90b441c9ace246ffd05c97","affectsGlobalScope":true},{"version":"f296963760430fb65b4e5d91f0ed770a91c6e77455bacf8fa23a1501654ede0e","affectsGlobalScope":true},{"version":"09226e53d1cfda217317074a97724da3e71e2c545e18774484b61562afc53cd2","affectsGlobalScope":true},{"version":"4443e68b35f3332f753eacc66a04ac1d2053b8b035a0e0ac1d455392b5e243b3","affectsGlobalScope":true},{"version":"8b41361862022eb72fcc8a7f34680ac842aca802cf4bc1f915e8c620c9ce4331","affectsGlobalScope":true},{"version":"f7bd636ae3a4623c503359ada74510c4005df5b36de7f23e1db8a5c543fd176b","affectsGlobalScope":true},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true},{"version":"0c20f4d2358eb679e4ae8a4432bdd96c857a2960fd6800b21ec4008ec59d60ea","affectsGlobalScope":true},{"version":"93495ff27b8746f55d19fcbcdbaccc99fd95f19d057aed1bd2c0cafe1335fbf0","affectsGlobalScope":true},{"version":"82d0d8e269b9eeac02c3bd1c9e884e85d483fcb2cd168bccd6bc54df663da031","affectsGlobalScope":true},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true},{"version":"b8deab98702588840be73d67f02412a2d45a417a3c097b2e96f7f3a42ac483d1","affectsGlobalScope":true},{"version":"4738f2420687fd85629c9efb470793bb753709c2379e5f85bc1815d875ceadcd","affectsGlobalScope":true},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true},{"version":"376d554d042fb409cb55b5cbaf0b2b4b7e669619493c5d18d5fa8bd67273f82a","affectsGlobalScope":true},{"version":"9fc46429fbe091ac5ad2608c657201eb68b6f1b8341bd6d670047d32ed0a88fa","affectsGlobalScope":true},{"version":"61c37c1de663cf4171e1192466e52c7a382afa58da01b1dc75058f032ddf0839","affectsGlobalScope":true},{"version":"c4138a3dd7cd6cf1f363ca0f905554e8d81b45844feea17786cdf1626cb8ea06","affectsGlobalScope":true},{"version":"6ff3e2452b055d8f0ec026511c6582b55d935675af67cdb67dd1dc671e8065df","affectsGlobalScope":true},{"version":"03de17b810f426a2f47396b0b99b53a82c1b60e9cba7a7edda47f9bb077882f4","affectsGlobalScope":true},{"version":"8184c6ddf48f0c98429326b428478ecc6143c27f79b79e85740f17e6feb090f1","affectsGlobalScope":true},{"version":"261c4d2cf86ac5a89ad3fb3fafed74cbb6f2f7c1d139b0540933df567d64a6ca","affectsGlobalScope":true},{"version":"6af1425e9973f4924fca986636ac19a0cf9909a7e0d9d3009c349e6244e957b6","affectsGlobalScope":true},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true},{"version":"15a630d6817718a2ddd7088c4f83e4673fde19fa992d2eae2cf51132a302a5d3","affectsGlobalScope":true},{"version":"b7e9f95a7387e3f66be0ed6db43600c49cec33a3900437ce2fd350d9b7cb16f2","affectsGlobalScope":true},{"version":"01e0ee7e1f661acedb08b51f8a9b7d7f959e9cdb6441360f06522cc3aea1bf2e","affectsGlobalScope":true},{"version":"ac17a97f816d53d9dd79b0d235e1c0ed54a8cc6a0677e9a3d61efb480b2a3e4e","affectsGlobalScope":true},{"version":"bf14a426dbbf1022d11bd08d6b8e709a2e9d246f0c6c1032f3b2edb9a902adbe","affectsGlobalScope":true},{"version":"ec0104fee478075cb5171e5f4e3f23add8e02d845ae0165bfa3f1099241fa2aa","affectsGlobalScope":true},{"version":"2b72d528b2e2fe3c57889ca7baef5e13a56c957b946906d03767c642f386bbc3","affectsGlobalScope":true},{"version":"9cc66b0513ad41cb5f5372cca86ef83a0d37d1c1017580b7dace3ea5661836df","affectsGlobalScope":true},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true},{"version":"709efdae0cb5df5f49376cde61daacc95cdd44ae4671da13a540da5088bf3f30","affectsGlobalScope":true},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true},{"version":"bc496ef4377553e461efcf7cc5a5a57cf59f9962aea06b5e722d54a36bf66ea1","affectsGlobalScope":true},{"version":"038a2f66a34ee7a9c2fbc3584c8ab43dff2995f8c68e3f566f4c300d2175e31e","affectsGlobalScope":true},{"version":"4fa6ed14e98aa80b91f61b9805c653ee82af3502dc21c9da5268d3857772ca05","affectsGlobalScope":true},{"version":"f5c92f2c27b06c1a41b88f6db8299205aee52c2a2943f7ed29bd585977f254e8","affectsGlobalScope":true},{"version":"930b0e15811f84e203d3c23508674d5ded88266df4b10abee7b31b2ac77632d2","affectsGlobalScope":true},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true},{"version":"b9ea5778ff8b50d7c04c9890170db34c26a5358cccba36844fe319f50a43a61a","affectsGlobalScope":true},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true},{"version":"50d53ccd31f6667aff66e3d62adf948879a3a16f05d89882d1188084ee415bbc","affectsGlobalScope":true},{"version":"65be38e881453e16f128a12a8d36f8b012aa279381bf3d4dc4332a4905ceec83","affectsGlobalScope":true},{"version":"436aaf437562f276ec2ddbee2f2cdedac7664c1e4c1d2c36839ddd582eeb3d0a","affectsGlobalScope":true},{"version":"307c8b7ebbd7f23a92b73a4c6c0a697beca05b06b036c23a34553e5fe65e4fdc","affectsGlobalScope":true},{"version":"e1913f656c156a9e4245aa111fbb436d357d9e1fe0379b9a802da7fe3f03d736","affectsGlobalScope":true},{"version":"d4b1d2c51d058fc21ec2629fff7a76249dec2e36e12960ea056e3ef89174080f","affectsGlobalScope":true},{"version":"f35a831e4f0fe3b3697f4a0fe0e3caa7624c92b78afbecaf142c0f93abfaf379","affectsGlobalScope":true},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true},"f7a0d79b95dcbb3b9a2774c641e2715094e5acf0baed7232350f0f1f86954dfb","858d0d831826c6eb563df02f7db71c90e26deadd0938652096bea3cc14899700","8885cf05f3e2abf117590bbb951dcf6359e3e5ac462af1c901cfd24c6a6472e2","18c04c22baee54d13b505fa6e8bcd4223f8ba32beee80ec70e6cac972d1cc9a6","5e92a2e8ba5cbcdfd9e51428f94f7bd0ab6e45c9805b1c9552b64abaffad3ce3","53ca39fe70232633759dd3006fc5f467ecda540252c0c819ab53e9f6ad97b226","e7174a839d4732630d904a8b488f22380e5bcf1d6405d1f59614e10795eca17d","7ec359bbc29b69d4063fe7dad0baaf35f1856f914db16b3f4f6e3e1bca4099fa","b9261ac3e9944d3d72c5ee4cf888ad35d9743a5563405c6963c4e43ee3708ca4","c84fd54e8400def0d1ef1569cafd02e9f39a622df9fa69b57ccc82128856b916","c7a38c1ef8d6ae4bf252be67bd9a8b012b2cdea65bd6225a3d1a726c4f0d52b6","e773630f8772a06e82d97046fc92da59ada8414c61689894fff0155dd08f102c","74f2815d9e1b8530120dcad409ed5f706df8513c4d93e99fc6213997aa4dd60e","9d1f36ccd354f2e286b909bf01d626a3a28dd6590770303a18afa7796fe50db9","c4bc6a572f9d763ac7fa0d839be3de80273a67660e2002e3225e00ef716b4f37","106e607866d6c3e9a497a696ac949c3e2ec46b6e7dda35aabe76100bf740833b","8a6c755dc994d16c4e072bba010830fa2500d98ff322c442c7c91488d160a10d","d4514d11e7d11c53da7d43b948654d6e608a3d93d666a36f8d01e18ece04c9bd","3d65182eff7bbb16de1a69e17651c51083f740af11a1a92359be6dab939e8bcf","bb53fe9074a25dfa9410e2ee1c4db8c71d02275f916d2019de7fd9cadd50c30b","b5f622e0916bfab17f24bf37f54ef2fe822dbd3f88a8c80ba0f006c716f415d2","01374379f82be05d25c08d2f30779fa4a4c41895a18b93b33f14aeef51768692","9ae83384abbd32d2e949f73c79ec09834a37d969b0a55af921be5e4a829145f9","e2e69d4946fe8da5ee1001a3ef5011ff2a3d0be02a1bff580b7f1c7d2cf4a02f","c3916141089b022b0b5aab813af5e5159123ec7a019d057c8a41db5c6fd57401","8e3891f7bd9ec48de663a3519d22bd76da13850bb5ff579308a85458663f3133","46d6d58af6749aab2307f85a892158c0123ec1d5185a781e9920a960333e6604",{"version":"0bd5e7096c7bc02bf70b2cc017fc45ef489cb19bd2f32a71af39ff5787f1b56a","affectsGlobalScope":true},"4c68749a564a6facdf675416d75789ee5a557afda8960e0803cf6711fa569288","60ecad5852d4d83edae430e597405132d278a79c10499e9363aecbe1ddc0eade","5f8f00356f6a82e21493b2d57b2178f11b00cf8960df00bd37bdcae24c9333ca",{"version":"3b0cb15c510ca8c5199d6801638bc8b24f6c3fe6af188d090ed3a5bd81f44032","affectsGlobalScope":true},{"version":"33f6d30a4929e904adc916343c878f23d25893a2cc9a94033b90e292c1b08cbb","signature":"e5cfe00ee8589cc56dbcffb0c1f61fd2091c89d2ce3e0fd2eba6efedf3d6839f"},"d371978c363195ad6a4720a74ce467f6570c851fe7e582ae75203eb3ebab5632","bafa6b0d2c6e4c78c097ec4b798f9209ef7030aa84e1bc95757b0ecea08205c9","429cd18b5ed2d3237232e3623254cf4e0fb904689c6db27ae3a28fc9cab27051","fd63a8edca6b78eb98af76a3039289b0c55ffbeb0c4d6d95127b2508a0277546","54f243b34272f6e25f353023bb33eb7fdee99c1257c4fb3ae5c15f05fdb5cdcf",{"version":"0bd5e7096c7bc02bf70b2cc017fc45ef489cb19bd2f32a71af39ff5787f1b56a","affectsGlobalScope":true},{"version":"b781d7fafad75c82703e2d53ee96c8add4715bc9996ca0e3cf0809bc15e7011f","affectsGlobalScope":true},"e84626067725d75771368f7f33a6b8898cfcff1190f4e9bde24c48f9fa818993","f3ba20ed94c93bf36db218afaba8d7b8f0c2db22948fc3e4fcb2d28d86046c23","bcc7feb7c7e3abdb2579d8962e517cca98f0e1bd9ee0d5c848b02823f2cc4a4a","01f94e325cf362cbbe30a02318901e55dca811232c9af97d22655e5fcac6a45b","07ad5dd203de3ea10f53f63d6f58c6bf8ad4a7bc87235e2ed790a02c60114151","fd53ea71c3b79ccbea36884bf8874df8a25e34de3b7d92f3b0e53ab05608c3b1","b3db557101f80dd3abbd4a9a8687de84272e01f8f093a0a5d3589a5eddedc6b5","b84eab2e61a64291f336da30aa9b559fe492e38a29df552c2e49a33134ada469","db8fc5c1917265860fd2a6963989985322e37882515284e5a33e3e61362798e3","4336423981fc67f99d5c364a64ec671641d4a5a796dee33bc4ddbc852da7de72","8dc56f817d20cf0717842ac1dcf3ac1a450ac3c667f1285754901e4af97bbe47","72156b7624d678a2686a22a45014218be4da7147f1058a5a5072fe981c492dd5","a20d054b8be41316bcc29752c5b9b42fab727b887a076a3af3bee18813d6e8c2","2eba4ddfbd6837ef33e58644f7dffbfe465d7222f9a9101789f02427f9541488","bb82c16e8371bfc6ee2ffd0095fd321d6acfa57c6c6445019567fec1b6b5e92a","6c65126b657a275259c4759b2b2f7b04879e61bc6c51df025b3748f87652a9eb","a5ad75d3dd3d3df0f62cb7a8ea12e76e0b4a3143ecde9e29c9cc0586b88365c0","03cd482c80ad0b724ca7d55cef7e3823d593b7cced98d7ef9d1bf757c5055b77","e93f395b0d6e68525181a6ecb3e7b81b1c5bf551f32f6cebbdffdef354fbe4a3","b881b069efb9d55b4b4b98de76d8c3920a29254e24f8f0c38557186e38cf6955","deb697b1102b461b0ea63a7fb59738a6c218d61b095e6040f3e0f00f18d870c1","1f542c79756fda13fdeda2c6c90a560ebddb14758eb5c3278ef8b750a03c9bec","b0ed00813c153e350a56faa8c15668bf21375928e9b88bcc482d20f1f8415179","2c421f4e1d8793d38c14a8c9985acb26d3a3ed517dd02fd7ebafb4f069536d48","9140f2acce87195a28377cb0c079de679eb3ee4aa027f2c69f8a70f5c58231f4","93557733190db162b067dd324a0516b8cd4313fda14c228468d865145245ed04","8ace7c551f55726a0bd825aa163fa5789a1f816a236739ca122ca80aac13661f","d7dc9cff3eb07213b3887a231c6729ac0a0adf8ae54bc1db69c551827cab97b7","85a3930c87a4275e3b39c760eb21472309b58135e9c624a872869e0f9bd08d23","4ab32839b87ba4bf87ac0169d65d02af289536b1ce1b7e8da3eefdc282dc030d","000164cebbe192519378950498453bcb07e0788cd5ac724369ca3fd6508d5ea5","dc8434313627a9bb9e7108ac2d3ecd60848bd624782539aa2f8980e71bf3bab4","4d0ccec357785fbd68e09276300d909b84f933fecc1ea5d92761a9c7d36b1bb4","6216c65f72d2f0db713e7b5e483c95c937bc779a18f9e52afa72702f01f83b89","8ef5a55e956a7a0a8ef406f96168a55a9940e9cbe05fb0565cf40097580b4377","d4b5b7028959c3e1e8847c71b7e845604ff49ffe6e3edf57af07d838d69668c7","db3f4c30a1c61447687dab0185608906e9e69212c2120f19a947b2078573ca41","b082be572ccc60fa650ef6eefdaf59005dedd325dc09d6134a1112cae61e9d93","d17718ff094281efa9a20798eec185815ecd4f5affa1f479d954880cc89758b4","52740032b2d8c0ef763fe40bbf9a6c6b7e215f59cd218b9a4912b377580d3856","205eeb1efdf796604cc543cb81894f598345555c4b1f89450dcceda768e61076","e9c213e0c278ba15ef2446c365c12e78e87a6240df1d2a6e80abcc04f3860512","4a0e6d702351d06abfa7b309c671d3231c98fe305a08d0422e99c049fbed3b88","73191fd675953c2aa8b757b38d347fe803bf0faefe3b3f6c2f662d9216806f7b","56c90a2b4699433a11ee09e068e59e0135340213a41046a4c11cfc9ed5ccef77","da3c82549a428acb82afe8032f31d0ab992e382fc12dd65d526a422ac5a2d865","e75629407d0cbe5f8c160d2d35b5afae3d9ed1042cde9ab9e1edc444d1d6c9ee","3a97c0c4e8f19915f4feb50f2cf0c06edb6cd85e9ffbf8382deedcaebc1f74b4","a75962251edcd1f1e949fe2828558d235cc81f6523cdf05c179c552e16df556d","583c4f3f1c233ecfd273f1bb315699aed9eeae4bc40b1a1398f56de889309e7f","b82185269794856a51705d86ad34a31c1b0d51a19d158e42f0208cc906478c85","f2a2be315c3df9ef66b167785a4fdac194268411af71e71489f3767dbc2ef8fa","e58682765ca9ad05163a6545719ba16e9a70011fb6d60f3b808c6d22df4a22e1","1fe3569c0268b82287a07b82866f5cbb95bc80fafad176b2627a11a0ab80d688","815b1e2d384bc5d9c5f84dddd6a00c03abf674a61ce08c825baac5d9cace9745",{"version":"d7260cd88beecb2e6a750ee933047b31b4a8d55bb4aaf66b6dbb6bb344b8e4f1","signature":"a2889e367decd6ea9c78f6bbd2a25afe771849e0bfe933dfad0113a3e9ae40bd"},"d4f200d88a45cd9d0b5f94d789c1691f96d03b63028436b5394b9a79770ec7d8","78d6ea86a676af53b6b9c1308d763818f573b8a88fdf6ef1c0525e72cf3f5148","7b8cd1e4f1de41abaa39535a767274383c6097e80c7031229f432cd379d6857a","e13f2b2d2f77a8847fe57e6e3d0be38bdfcf6400764571bd59c27036c0f62327","d33a37ba5729267033d4ebb17b1f605b6b21a86654c89b4af82ae1bbaf59e4b5","f8c483ea6a323b1ce441d4dce1c9434e0fc6061fb781d98ad47e4a5737f95e31","09df3b4f1c937f02e7fee2836d4c4d7a63e66db70fd4d4e97126f4542cc21d9d","7394959e5a741b185456e1ef5d64599c36c60a323207450991e7a42e08911419","4967529644e391115ca5592184d4b63980569adf60ee685f968fd59ab1557188","5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","7180c03fd3cb6e22f911ce9ba0f8a7008b1a6ddbe88ccf16a9c8140ef9ac1686","25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","54cb85a47d760da1c13c00add10d26b5118280d44d58e6908d8e89abbd9d7725","3e4825171442666d31c845aeb47fcd34b62e14041bb353ae2b874285d78482aa","adda9e3915c6bf15e360356a41d950881a51dbe44f9a6088155836b040820663","b4855526ac5a822d6e0005e4b62ee49c599bf89897e4109135283d660e60291c","e9775e97ac4877aebf963a0289c81abe76d1ec9a2a7778dbe637e5151f25c5f3","471e1da5a78350bc55ef8cef24eb3aca6174143c281b8b214ca2beda51f5e04a","cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","db3435f3525cd785bf21ec6769bf8da7e8a776be1a99e2e7efb5f244a2ef5fee","c3b170c45fc031db31f782e612adf7314b167e60439d304b49e704010e7bafe5","40383ebef22b943d503c6ce2cb2e060282936b952a01bea5f9f493d5fb487cc7","80ad053918e96087d9da8d092ff9f90520c9fc199c8bfd9340266dd8f38f364e","3a84b7cb891141824bd00ef8a50b6a44596aded4075da937f180c90e362fe5f6","13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","33203609eba548914dc83ddf6cadbc0bcb6e8ef89f6d648ca0908ae887f9fcc5","0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","e53a3c2a9f624d90f24bf4588aacd223e7bec1b9d0d479b68d2f4a9e6011147f","339dc5265ee5ed92e536a93a04c4ebbc2128f45eeec6ed29f379e0085283542c","9f0a92164925aa37d4a5d9dd3e0134cff8177208dba55fd2310cd74beea40ee2","8bfdb79bf1a9d435ec48d9372dc93291161f152c0865b81fc0b2694aedb4578d","2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","d32275be3546f252e3ad33976caf8c5e842c09cb87d468cb40d5f4cf092d1acc","d70119390aece1794bf4988f10ea750d13455f5286977d35027d43dd2e9841cf",{"version":"4d719cfab49ae4045d15cb6bed0f38ad3d7d6eb7f277d2603502a0f862ca3182","affectsGlobalScope":true},"cce1f5f86974c1e916ec4a8cab6eec9aa8e31e8148845bf07fbaa8e1d97b1a2c",{"version":"5a856afb15f9dc9983faa391dde989826995a33983c1cccb173e9606688e9709","affectsGlobalScope":true},"546ab07e19116d935ad982e76a223275b53bff7771dab94f433b7ab04652936e","7b43160a49cf2c6082da0465876c4a0b164e160b81187caeb0a6ca7a281e85ba",{"version":"aefb5a4a209f756b580eb53ea771cca8aad411603926f307a5e5b8ec6b16dcf6","affectsGlobalScope":true},"a40826e8476694e90da94aa008283a7de50d1dafd37beada623863f1901cb7fb","f5a8b7ec4b798c88679194a8ebc25dcb6f5368e6e5811fcda9fe12b0d445b8db","b86e1a45b29437f3a99bad4147cb9fe2357617e8008c0484568e5bb5138d6e13","b5b719a47968cd61a6f83f437236bb6fe22a39223b6620da81ef89f5d7a78fb7","42c431e7965b641106b5e25ab3283aa4865ca7bb9909610a2abfa6226e4348be","0b7e732af0a9599be28c091d6bd1cb22c856ec0d415d4749c087c3881ca07a56","b7fe70be794e13d1b7940e318b8770cd1fb3eced7707805318a2e3aaac2c3e9e",{"version":"2c71199d1fc83bf17636ad5bf63a945633406b7b94887612bba4ef027c662b3e","affectsGlobalScope":true},{"version":"8d6138a264ddc6f94f16e99d4e117a2d6eb31b217891cf091b6437a2f114d561","affectsGlobalScope":true},"3b4c85eea12187de9929a76792b98406e8778ce575caca8c574f06da82622c54","f788131a39c81e0c9b9e463645dd7132b5bc1beb609b0e31e5c1ceaea378b4df","0c236069ce7bded4f6774946e928e4b3601894d294054af47a553f7abcafe2c1","21894466693f64957b9bd4c80fa3ec7fdfd4efa9d1861e070aca23f10220c9b2","396a8939b5e177542bdf9b5262b4eee85d29851b2d57681fa9d7eae30e225830","ad8848c289c0b633452e58179f46edccd14b5a0fe90ebce411f79ff040b803e0",{"version":"6ec93c745c5e3e25e278fa35451bf18ef857f733de7e57c15e7920ac463baa2a","affectsGlobalScope":true},"91f8b5abcdff8f9ecb9656b9852878718416fb7700b2c4fad8331e5b97c080bb","59d8f064f86a4a2be03b33c0efcc9e7a268ad27b22f82dce16899f3364f70ba8","0f05c06ff6196958d76b865ae17245b52d8fe01773626ac3c43214a2458ea7b7",{"version":"f49fb15c4aa06b65b0dce4db4584bfd8a9f74644baef1511b404dc95be34af00","affectsGlobalScope":true},{"version":"d48009cbe8a30a504031cc82e1286f78fed33b7a42abf7602c23b5547b382563","affectsGlobalScope":true},"7aaeb5e62f90e1b2be0fc4844df78cdb1be15c22b427bc6c39d57308785b8f10","3ba30205a029ebc0c91d7b1ab4da73f6277d730ca1fc6692d5a9144c6772c76b","d8dba11dc34d50cb4202de5effa9a1b296d7a2f4a029eec871f894bddfb6430d","8b71dd18e7e63b6f991b511a201fad7c3bf8d1e0dd98acb5e3d844f335a73634","01d8e1419c84affad359cc240b2b551fb9812b450b4d3d456b64cda8102d4f60","458b216959c231df388a5de9dcbcafd4b4ca563bc3784d706d0455467d7d4942","269929a24b2816343a178008ac9ae9248304d92a8ba8e233055e0ed6dbe6ef71","93452d394fdd1dc551ec62f5042366f011a00d342d36d50793b3529bfc9bd633","f8c87b19eae111f8720b0345ab301af8d81add39621b63614dfc2d15fd6f140a","831c22d257717bf2cbb03afe9c4bcffc5ccb8a2074344d4238bf16d3a857bb12",{"version":"24ba151e213906027e2b1f5223d33575a3612b0234a0e2b56119520bbe0e594b","affectsGlobalScope":true},{"version":"cbf046714f3a3ba2544957e1973ac94aa819fa8aa668846fa8de47eb1c41b0b2","affectsGlobalScope":true},"aa34c3aa493d1c699601027c441b9664547c3024f9dbab1639df7701d63d18fa","eae74e3d50820f37c72c0679fed959cd1e63c98f6a146a55b8c4361582fa6a52","7c651f8dce91a927ab62925e73f190763574c46098f2b11fb8ddc1b147a6709a","7440ab60f4cb031812940cc38166b8bb6fbf2540cfe599f87c41c08011f0c1df",{"version":"aed89e3c18f4c659ee8153a76560dffda23e2d801e1e60d7a67abd84bc555f8d","affectsGlobalScope":true},{"version":"0ed13c80faeb2b7160bffb4926ff299c468e67a37a645b3ae0917ba0db633c1b","affectsGlobalScope":true},"e393915d3dc385e69c0e2390739c87b2d296a610662eb0b1cb85224e55992250","2f940651c2f30e6b29f8743fae3f40b7b1c03615184f837132b56ea75edad08b","5749c327c3f789f658072f8340786966c8b05ea124a56c1d8d60e04649495a4d",{"version":"c9d62b2a51b2ff166314d8be84f6881a7fcbccd37612442cf1c70d27d5352f50","affectsGlobalScope":true},"e7dbf5716d76846c7522e910896c5747b6df1abd538fee8f5291bdc843461795",{"version":"ab9b9a36e5284fd8d3bf2f7d5fcbc60052f25f27e4d20954782099282c60d23e","affectsGlobalScope":true},"d3f4c342fb62f348f25f54b473b0ab7371828cbf637134194fa9cdf04856f87b","6738101ae8e56cd3879ab3f99630ada7d78097fc9fd334df7e766216778ca219","a4a548b5c2db93a5e084760fe9f01e733bde167592de69ac4a257e24b10c8b75","f713064ca751dc588bc13832137c418cb70cf0446de92ade60ad631071558fca","dfefd34e8ab60f41d0c130527d5092d6ce662dc9fa85bc8c97682baf65830b51","96c23535f4f9dd15beb767e070559ea672f6a35f103152836a67100605136a96","b0f4dd1a825912da8f12fd3388d839ef4aa51165ea0e60e4869b50b7ccb4f6fc","9cb7c5f710dc84d2e9500831a3e9a27afd3c3710f5a1b8744a50473e565b41fc","cf6b2edde490f303918809bfab1da8b6d059b50c160bec72005ff4c248bdd079","160b24efb5a868df9c54f337656b4ef55fcbe0548fe15408e1c0630ec559c559","82819f9ecc249a6a3e284003540d02ea1b1f56f410c23231797b9e1e4b9622df","81a109b6bb6adf5ed70f2c7e6d907b8c3adcf7b47b5ee09701c5f97370fd29b7","64fcc79ee3c237816b9cef0a9289b00bf3da5b17040cd970ac04ba03c4ac1595","117ffeecf6c55e25b6446f449ad079029b5e7317399b0a693858faaaea5ca73e","8d48b8f8a377ade8dd1f000625bc276eea067f2529cc9cafdf082d17142107d6","f6218314af6f492ce5461bdadac5b829f5b4b31a3d1da3d04e77ed0afe0829fb","7167d932a7e2e991084421bb22af20024ada5a046d948c742de0f89996de5d0b","c7da551241b7be719b7bd654ab12a5098c3206fbb189076dd2d8871011a6ab5a","e2d3bfa79f0fad3ad67dfb0685c50dbe19b364a440160a2d40d0e3f44c75938c",{"version":"1c598f8d911f0bc39f04910c8c93f2f76fbb65f892ee5ecc38a2b58bb95af752","affectsGlobalScope":true},"4eadf1158f1ae8f7b0deea0f96b391359042cf74d1eb3ce1dacdb69de96e590d","d2a38ad7bb4676e7fd5d058a08105d81ac232c363ee56be0b401fc277d50dbb1","6d1438dc186a32e0441133a6ea7befa732b92d37d96020fafa82ff3b9a7f9cc5","f0120fc76274f614e7b8f5420a74abce69eee25b81e2084479fa426f33ccd46a","003f07cf566395059625b39785398f18652c8952e19790e7d6eeb22a9cbe0440","432dc46f22f9790797d98ccf09f7dc4a897bb5e874921217b951fb808947446b","28ed61ddc42936537ad29ade1404d533b4b28967460e29811409e5a40d9fc3b3",{"version":"64d4b35c5456adf258d2cf56c341e203a073253f229ef3208fc0d5020253b241","affectsGlobalScope":true},"42fe73978ddb3a82329bf41a116e921deb266551e4f0ad9e9c7bdc581c24f085","dd89872dd0647dfd63665f3d525c06d114310a2f7a5a9277e5982a152b31be2b","fdd574c45ab01286d64b1e2e78e9ea647c4527e954e27ae281d372f5fba41567","6adaa01cba6e7bae17d8291089e9e38bfc3fffcd522e2161214cbaccab4c1b2b","a5bb013d950d8fb43ee54eeeef427ad9b97feed7b9b61e3a731a181567356c0d","e98185f4249720ace1921d59c1ff4612fa5c633a183fc9bf28e2e7b8e3c7fd51","8b06ac3faeacb8484d84ddb44571d8f410697f98d7bfa86c0fda60373a9f5215","c79bd2f3e5c05e7ad80dc82ce8d339cac23ac1f5e6cfab96c860bb70d5162873","e3328cedfe4d7fac23ba75d00bf5169269800ab949d0837cd88c4211a52c3762","cdcc132f207d097d7d3aa75615ab9a2e71d6a478162dde8b67f88ea19f3e54de","0d14fa22c41fdc7277e6f71473b20ebc07f40f00e38875142335d5b63cdfc9d2","c085e9aa62d1ae1375794c1fb927a445fa105fed891a7e24edbb1c3300f7384a","f315e1e65a1f80992f0509e84e4ae2df15ecd9ef73df975f7c98813b71e4c8da","5b9586e9b0b6322e5bfbd2c29bd3b8e21ab9d871f82346cb71020e3d84bae73e","3e70a7e67c2cb16f8cd49097360c0309fe9d1e3210ff9222e9dac1f8df9d4fb6","ab68d2a3e3e8767c3fba8f80de099a1cfc18c0de79e42cb02ae66e22dfe14a66","d96cc6598148bf1a98fb2e8dcf01c63a4b3558bdaec6ef35e087fd0562eb40ec",{"version":"9afcfd847523b81d526c73130a247fbb65aa1eba2a1d4195cfacd677a9e4de08","affectsGlobalScope":true},"b3338366fe1f2c5f978e2ec200f57d35c5bd2c4c90c2191f1e638cfa5621c1f6","3411c785dbe8fd42f7d644d1e05a7e72b624774a08a9356479754999419c3c5a","8fb8fdda477cd7382477ffda92c2bb7d9f7ef583b1aa531eb6b2dc2f0a206c10","66995b0c991b5c5d42eff1d950733f85482c7419f7296ab8952e03718169e379","33f3795a4617f98b1bb8dac36312119d02f31897ae75436a1e109ce042b48ee8","2850c9c5dc28d34ad5f354117d0419f325fc8932d2a62eadc4dc52c018cd569b","c753948f7e0febe7aa1a5b71a714001a127a68861309b2c4127775aa9b6d4f24","3e7a40e023e1d4a9eef1a6f08a3ded8edacb67ae5fce072014205d730f717ba5","a77be6fc44c876bc10c897107f84eaba10790913ebdcad40fcda7e47469b2160","382100b010774614310d994bbf16cc9cd291c14f0d417126c7a7cfad1dc1d3f8","91f5dbcdb25d145a56cffe957ec665256827892d779ef108eb2f3864faff523b","4fdf56315340bd1770eb52e1601c3a98e45b1d207202831357e99ce29c35b55c","927955a3de5857e0a1c575ced5a4245e74e6821d720ed213141347dd1870197f","be6fd74528b32986fbf0cd2cfa9192a5ed7f369060b32a7adcb0c8d055708e61","54fe5f476c5049c39e5b58927d98b96aad0f18a9fd3e21b51fb3ee812631c8c0","fd0589ca571ad090b531d8c095e26caa53d4825c64d3ff2b2b1ab95d72294175",{"version":"669843ecafb89ae1e944df06360e8966219e4c1c34c0d28aa2503272cdd444a7","affectsGlobalScope":true},"96d14f21b7652903852eef49379d04dbda28c16ed36468f8c9fa08f7c14c9538","fa849c825ac37d70ca78097a1cd06bb5ac281651f765fff8e491cfb0709de57b","c39e1ee964fa0bb318ee2db72c430b3aede3b50dbde414b03b4e43915f80c292","6825eb4d1c8beb77e9ed6681c830326a15ebf52b171f83ffbca1b1574c90a3b0","1741975791f9be7f803a826457273094096e8bba7a50f8fa960d5ed2328cdbcc","6ec0d1c15d14d63d08ccb10d09d839bf8a724f6b4b9ed134a3ab5042c54a7721","75dabc9afdb451a85e6d46e9ca65ec82ead2256476c0686f671f3421923667a7","ddfc215bfbddf5854d80ab8fb0256bd802f2a8acb6be62f9e630041266d56cd5","2c3bcb8a4ea2fcb4208a06672af7540dd65bf08298d742f041ffa6cbe487cf80","1cce0460d75645fc40044c729da9a16c2e0dabe11a58b5e4bfd62ac840a1835d","c784a9f75a6f27cf8c43cc9a12c66d68d3beb2e7376e1babfae5ae4998ffbc4a","feb4c51948d875fdbbaa402dad77ee40cf1752b179574094b613d8ad98921ce1","a6d3984b706cefe5f4a83c1d3f0918ff603475a2a3afa9d247e4114f18b1f1ef","b457d606cabde6ea3b0bc32c23dc0de1c84bb5cb06d9e101f7076440fc244727","9d59919309a2d462b249abdefba8ca36b06e8e480a77b36c0d657f83a63af465","9faa2661daa32d2369ec31e583df91fd556f74bcbd036dab54184303dee4f311","b08de5693ec0119e033ced692f3ad0c0449c7331fd1d84033ea9b4b22e7f269c","2b8264b2fefd7367e0f20e2c04eed5d3038831fe00f5efbc110ff0131aab899b","e66f26a75bd5a23640087e17bfd965bf5e9f7d2983590bc5bf32c500db8cf9fd","70a29119482d358ab4f28d28ee2dcd05d6cbf8e678068855d016e10a9256ec12","869ac759ae8f304536d609082732cb025a08dcc38237fe619caf3fcdd41dde6f","0ea900fe6565f9133e06bce92e3e9a4b5a69234e83d40b7df2e1752b8d2b5002","e5408f95ca9ac5997c0fea772d68b1bf390e16c2a8cad62858553409f2b12412","3c1332a48695617fc5c8a1aead8f09758c2e73018bd139882283fb5a5b8536a6","9260b03453970e98ce9b1ad851275acd9c7d213c26c7d86bae096e8e9db4e62b","083838d2f5fea0c28f02ce67087101f43bd6e8697c51fd48029261653095080c","969132719f0f5822e669f6da7bd58ea0eb47f7899c1db854f8f06379f753b365","94ca5d43ff6f9dc8b1812b0770b761392e6eac1948d99d2da443dc63c32b2ec1","2cbc88cf54c50e74ee5642c12217e6fd5415e1b35232d5666d53418bae210b3b","ccb226557417c606f8b1bba85d178f4bcea3f8ae67b0e86292709a634a1d389d","5ea98f44cc9de1fe05d037afe4813f3dcd3a8c5de43bdd7db24624a364fad8e6","5260a62a7d326565c7b42293ed427e4186b9d43d6f160f50e134a18385970d02","0b3fc2d2d41ad187962c43cb38117d0aee0d3d515c8a6750aaea467da76b42aa","ed219f328224100dad91505388453a8c24a97367d1bc13dcec82c72ab13012b7","6847b17c96eb44634daa112849db0c9ade344fe23e6ced190b7eeb862beca9f4","d479a5128f27f63b58d57a61e062bd68fa43b684271449a73a4d3e3666a599a7","6f308b141358ac799edc3e83e887441852205dc1348310d30b62c69438b93ca0",{"version":"d204bd5d20ca52a553f7ba993dc2a422e9d1fce0b8178ce2bfe55fbd027c11ae","affectsGlobalScope":true},"7ed8a817989d55241e710dd80af79d02004ca675ad73d92894c0d61248ad423d","20760f286c49ea8ee2be865b1d4ebc3b46752669dde6c0f8ca91029904a01d65","4355c807c60f6b8a69ee3307c5f9adde7d8303172bcfa4805fa804511a6c3ce2",{"version":"fd624f7d7b264922476685870f08c5e1c6d6a0f05dee2429a9747b41f6b699d4","affectsGlobalScope":true},"716a2a83ae87f4e37097eb0c2f512148651848e254ee10e5db3d7bb91ee6781c","960a68ced7820108787135bdae5265d2cc4b511b7dcfd5b8f213432a8483daf1","e27ecc0d7bbbb4b12c9688e2f728e09c0be5a73dff4257008790f60cc6df5d54","2e7ebdc7d8af978c263890bbde991e88d6aa31cc29d46735c9c5f45f0a41243b","b57fd1c0a680d220e714b76d83eff51a08670f56efcc5d68abc82f5a2684f0c0","53e37f594066c9a083bb1a3ff2d2fa1be5fa617fcfad963a22841648e9139378","a4156fbcb9cbaa4e641fd5c62b1add36e6fe00d90ea44dc2afabd469355f1dc7","4964ba28dd6c9d086735062e8f4c63f23dd14e20b9b6d2acdc5774760d47b132","9af49718d3e0c01d64e27c2d627da83849e5f08b57f80b4c8990817f5353c8a9","b0d10e46cfe3f6c476b69af02eaa38e4ccc7430221ce3109ae84bb9fb8282298","e0cab2148d11374247b33dc7c5644e037b0c0db73d221031b1b7f99147178339","dbe69644ab6e699ad2ef740056c637c34f3348af61d3764ff555d623703525db","9eb48a18d9d78d2dc2683bfb79d083954d13cf066d9579cbdb8652b86601fbd7","2f4f96af192dc44a12bf238bcc08ebac498c9073f459740f6497fe0f8e1a432c","c5b3da7e2ecd5968f723282aba49d8d1a2e178d0afe48998dad93f81e2724091","efd2860dc74358ffa01d3de4c8fa2f966ae52c13c12b41ad931c078151b36601","09acacae732e3cc67a6415026cfae979ebe900905500147a629837b790a366b3","72154a9d896b0a0aed69fd2a58aa5aa8ab526078a65ff92f0d3c2237e9992610","99236ea5c4c583082975823fd19bcce6a44963c5c894e20384bc72e7eccf9b03","f6688a02946a3f7490aa9e26d76d1c97a388e42e77388cbab010b69982c86e9e","b027979b9e4e83be23db2d81e01d973b91fefe677feb93823486a83762f65012","7eb1ec2dd7db758b625f52be624bbcf238b1367c5d929c2c8fbcc402d1765216","75fa6a9be075402ea969c1fcec4bb1421f72efbc3e2f340032684cdd3115197c","2ec6204e9750249048f390f520197cc67d52c1c769c1ed866285cd9070aa2bab"],"root":[66,92,[98,103],[108,113],[161,167]],"options":{"esModuleInterop":true,"jsx":1,"module":99,"noUncheckedIndexedAccess":true,"skipLibCheck":true,"strict":true,"target":4},"fileIdsList":[[247,257,354],[247,354],[126,247,354],[117,126,247,354],[115,116,117,118,119,120,121,122,123,124,125,247,354],[132,247,354],[126,127,128,130,132,247,354],[126,127,128,130,131,247,354],[105,132,247,354],[105,132,133,134,247,354],[135,247,354],[105,247,354],[105,160,247,354],[160,247,354],[135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,247,354],[149,160,247,354],[105,135,136,138,140,145,146,148,149,152,154,155,156,157,159,247,354],[129,247,354],[115,247,354],[114,247,354],[247,293,354],[247,352,354],[247,346,348,354],[247,336,346,347,349,350,351,354],[247,346,354],[247,336,346,354],[247,337,338,339,340,341,342,343,344,345,354],[247,337,341,342,345,346,349,354],[247,337,338,339,340,341,342,343,344,345,346,347,349,350,354],[247,336,337,338,339,340,341,342,343,344,345,354],[220,247,254,354],[247,257,258,259,260,261,354],[247,257,259,354],[247,254,354],[220,247,254,264,354],[220,247,254,264,278,279,354],[247,281,282,283,354],[217,220,247,254,270,271,272,354],[247,265,271,273,277,354],[218,247,254,354],[247,288,354],[247,289,354],[247,295,298,354],[217,247,249,254,313,314,316,354],[247,315,354],[247,319,354],[217,220,221,225,231,246,247,254,255,266,274,279,280,287,318,354],[247,275,354],[247,276,354],[168,247,354],[204,247,354],[205,210,238,247,354],[206,217,218,225,235,246,247,354],[206,207,217,225,247,354],[208,247,354],[209,210,218,226,247,354],[210,235,243,247,354],[211,213,217,225,247,354],[212,247,354],[213,214,247,354],[217,247,354],[215,217,247,354],[204,217,247,354],[217,218,219,235,246,247,354],[217,218,219,232,235,238,247,354],[202,247,251,354],[213,217,220,225,235,246,247,354],[217,218,220,221,225,235,243,246,247,354],[220,222,235,243,246,247,354],[168,169,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,354],[217,223,247,354],[224,246,247,251,354],[213,217,225,235,247,354],[226,247,354],[227,247,354],[204,228,247,354],[229,245,247,251,354],[230,247,354],[231,247,354],[217,232,233,247,354],[232,234,247,249,354],[205,217,235,236,237,238,247,354],[205,235,237,247,354],[235,236,247,354],[238,247,354],[239,247,354],[204,235,247,354],[217,241,242,247,354],[241,242,247,354],[210,225,235,243,247,354],[244,247,354],[225,245,247,354],[205,220,231,246,247,354],[210,247,354],[235,247,248,354],[224,247,249,354],[247,250,354],[205,210,217,219,228,235,246,247,249,251,354],[235,247,252,354],[247,254,321,323,327,328,329,330,331,332,354],[235,247,254,354],[217,247,254,321,323,324,326,333,354],[217,225,235,246,247,254,320,321,322,324,325,326,333,354],[235,247,254,323,324,354],[235,247,254,323,325,354],[247,254,321,323,324,326,333,354],[235,247,254,325,354],[217,225,235,243,247,254,322,324,326,354],[217,247,254,321,323,324,325,326,333,354],[217,235,247,254,321,322,323,324,325,326,333,354],[217,235,247,254,321,323,324,326,333,354],[220,235,247,254,326,354],[105,247,353,354,355],[105,247,286,354,358],[105,247,354,361],[247,354,360,361,362,363,364],[94,95,96,104,247,354],[218,235,247,254,269,354],[220,247,254,274,276,354],[205,218,220,235,247,254,268,354],[247,354,369],[247,354,371,372,373,374,375,376,377,378,379],[247,354,380],[247,354,381],[247,291,297,354],[94,247,354],[247,295,354],[247,292,296,354],[247,302,354],[247,301,302,354],[247,301,354],[247,301,302,303,305,306,309,310,311,312,354],[247,302,306,354],[247,301,302,303,305,306,307,308,354],[247,301,306,354],[247,306,310,354],[247,302,303,304,354],[247,303,354],[247,301,302,306,354],[82,247,354],[80,82,247,354],[71,79,80,81,83,247,354],[69,247,354],[72,77,82,85,247,354],[68,85,247,354],[72,73,76,77,78,85,247,354],[72,73,74,76,77,85,247,354],[69,70,71,72,73,77,78,79,81,82,83,85,247,354],[67,69,70,71,72,73,74,76,77,78,79,80,81,82,83,84,247,354],[67,85,247,354],[72,74,75,77,78,85,247,354],[76,85,247,354],[77,78,82,85,247,354],[70,80,247,354],[247,294,354],[105,106,247,354],[105,247,353,354],[67,247,354],[88,247,354],[86,87,247,354],[85,88,247,354],[179,183,246,247,354],[179,235,246,247,354],[174,247,354],[176,179,243,246,247,354],[225,243,247,354],[174,247,254,354],[176,179,225,246,247,354],[171,172,175,178,205,217,235,246,247,354],[171,177,247,354],[175,179,205,238,246,247,254,354],[205,247,254,354],[195,205,247,254,354],[173,174,247,254,354],[179,247,354],[173,174,175,176,177,178,179,180,181,183,184,185,186,187,188,189,190,191,192,193,194,196,197,198,199,200,201,247,354],[179,186,187,247,354],[177,179,187,188,247,354],[178,247,354],[171,174,179,247,354],[179,183,187,188,247,354],[183,247,354],[177,179,182,246,247,354],[171,176,177,179,183,186,247,354],[205,235,247,354],[174,179,195,205,247,251,254,354],[90,247,354],[93,94,95,96,247,354],[97,100,247,354],[112,247,354],[97,98,247,354],[99,247,354],[167,247,354],[100,103,111,113,162,164,247,354],[101,102,247,354],[97,247,354],[97,100,101,247,354],[163,247,354],[97,100,160,247,354],[110,247,354],[100,107,108,109,247,354],[97,100,107,247,354],[161,247,354],[97,160,247,354],[89,91,247,354],[167],[97,105,115,126,135,160]],"referencedMap":[[259,1],[257,2],[116,3],[117,3],[118,3],[119,4],[120,3],[121,4],[122,3],[123,3],[124,4],[125,4],[126,5],[127,6],[131,7],[128,6],[132,8],[133,9],[135,10],[134,11],[137,12],[138,13],[139,13],[140,13],[141,12],[142,12],[143,13],[144,13],[145,14],[146,14],[136,14],[147,14],[148,14],[149,14],[150,2],[151,13],[152,13],[153,12],[154,14],[155,13],[156,13],[159,15],[157,13],[158,16],[160,17],[129,2],[130,18],[114,19],[115,20],[291,2],[294,21],[353,22],[349,23],[336,2],[352,24],[345,25],[343,26],[342,26],[341,25],[338,26],[339,25],[347,27],[340,26],[337,25],[344,26],[350,28],[351,29],[346,30],[348,26],[293,2],[255,31],[256,2],[262,32],[258,1],[260,33],[261,1],[263,34],[265,35],[264,31],[266,2],[267,2],[268,2],[280,36],[281,2],[284,37],[282,2],[273,38],[278,39],[285,40],[286,12],[287,2],[274,2],[288,2],[289,41],[290,42],[299,43],[300,2],[315,44],[316,45],[283,2],[317,2],[279,2],[318,46],[319,47],[276,48],[275,49],[168,50],[169,50],[204,51],[205,52],[206,53],[207,54],[208,55],[209,56],[210,57],[211,58],[212,59],[213,60],[214,60],[216,61],[215,62],[217,63],[218,64],[219,65],[203,66],[253,2],[220,67],[221,68],[222,69],[254,70],[223,71],[224,72],[225,73],[226,74],[227,75],[228,76],[229,77],[230,78],[231,79],[232,80],[233,80],[234,81],[235,82],[237,83],[236,84],[238,85],[239,86],[240,87],[241,88],[242,89],[243,90],[244,91],[245,92],[246,93],[247,94],[248,95],[249,96],[250,97],[251,98],[252,99],[333,100],[320,101],[327,102],[323,103],[321,104],[324,105],[328,106],[329,102],[326,107],[325,108],[330,109],[331,110],[332,111],[322,112],[334,2],[95,2],[271,2],[272,2],[335,12],[356,113],[357,12],[359,114],[360,2],[362,115],[365,116],[363,12],[361,12],[364,115],[104,2],[105,117],[366,2],[96,2],[367,2],[270,118],[269,2],[277,119],[368,2],[369,120],[370,121],[314,2],[380,122],[371,123],[372,2],[373,2],[374,2],[375,2],[376,2],[377,2],[378,2],[379,2],[381,2],[382,124],[170,2],[292,2],[94,2],[354,2],[298,125],[106,126],[296,127],[297,128],[303,129],[312,130],[301,2],[302,131],[313,132],[308,133],[309,134],[307,135],[311,136],[305,137],[304,138],[310,139],[306,130],[83,140],[81,141],[82,142],[70,143],[71,141],[78,144],[69,145],[74,146],[84,2],[75,147],[80,148],[85,149],[68,150],[76,151],[77,152],[72,153],[79,140],[73,154],[295,155],[107,156],[355,157],[358,2],[67,158],[90,159],[88,160],[87,2],[86,2],[89,161],[64,2],[65,2],[12,2],[13,2],[15,2],[14,2],[2,2],[16,2],[17,2],[18,2],[19,2],[20,2],[21,2],[22,2],[23,2],[3,2],[4,2],[24,2],[28,2],[25,2],[26,2],[27,2],[29,2],[30,2],[31,2],[5,2],[32,2],[33,2],[34,2],[35,2],[6,2],[39,2],[36,2],[37,2],[38,2],[40,2],[7,2],[41,2],[46,2],[47,2],[42,2],[43,2],[44,2],[45,2],[8,2],[51,2],[48,2],[49,2],[50,2],[52,2],[9,2],[53,2],[54,2],[55,2],[58,2],[56,2],[57,2],[59,2],[60,2],[10,2],[1,2],[11,2],[63,2],[62,2],[61,2],[186,162],[193,163],[185,162],[200,164],[177,165],[176,166],[199,34],[194,167],[197,168],[179,169],[178,170],[174,171],[173,172],[196,173],[175,174],[180,175],[181,2],[184,175],[171,2],[202,176],[201,175],[188,177],[189,178],[191,179],[187,180],[190,181],[195,34],[182,182],[183,183],[192,184],[172,185],[198,186],[91,187],[93,2],[97,188],[66,2],[112,189],[113,190],[166,2],[99,191],[100,192],[98,193],[165,194],[103,195],[101,196],[102,197],[164,198],[163,199],[111,200],[110,201],[108,202],[109,202],[162,203],[161,204],[167,196],[92,205]],"exportedModulesMap":[[259,1],[257,2],[116,3],[117,3],[118,3],[119,4],[120,3],[121,4],[122,3],[123,3],[124,4],[125,4],[126,5],[127,6],[131,7],[128,6],[132,8],[133,9],[135,10],[134,11],[137,12],[138,13],[139,13],[140,13],[141,12],[142,12],[143,13],[144,13],[145,14],[146,14],[136,14],[147,14],[148,14],[149,14],[150,2],[151,13],[152,13],[153,12],[154,14],[155,13],[156,13],[159,15],[157,13],[158,16],[160,17],[129,2],[130,18],[114,19],[115,20],[291,2],[294,21],[353,22],[349,23],[336,2],[352,24],[345,25],[343,26],[342,26],[341,25],[338,26],[339,25],[347,27],[340,26],[337,25],[344,26],[350,28],[351,29],[346,30],[348,26],[293,2],[255,31],[256,2],[262,32],[258,1],[260,33],[261,1],[263,34],[265,35],[264,31],[266,2],[267,2],[268,2],[280,36],[281,2],[284,37],[282,2],[273,38],[278,39],[285,40],[286,12],[287,2],[274,2],[288,2],[289,41],[290,42],[299,43],[300,2],[315,44],[316,45],[283,2],[317,2],[279,2],[318,46],[319,47],[276,48],[275,49],[168,50],[169,50],[204,51],[205,52],[206,53],[207,54],[208,55],[209,56],[210,57],[211,58],[212,59],[213,60],[214,60],[216,61],[215,62],[217,63],[218,64],[219,65],[203,66],[253,2],[220,67],[221,68],[222,69],[254,70],[223,71],[224,72],[225,73],[226,74],[227,75],[228,76],[229,77],[230,78],[231,79],[232,80],[233,80],[234,81],[235,82],[237,83],[236,84],[238,85],[239,86],[240,87],[241,88],[242,89],[243,90],[244,91],[245,92],[246,93],[247,94],[248,95],[249,96],[250,97],[251,98],[252,99],[333,100],[320,101],[327,102],[323,103],[321,104],[324,105],[328,106],[329,102],[326,107],[325,108],[330,109],[331,110],[332,111],[322,112],[334,2],[95,2],[271,2],[272,2],[335,12],[356,113],[357,12],[359,114],[360,2],[362,115],[365,116],[363,12],[361,12],[364,115],[104,2],[105,117],[366,2],[96,2],[367,2],[270,118],[269,2],[277,119],[368,2],[369,120],[370,121],[314,2],[380,122],[371,123],[372,2],[373,2],[374,2],[375,2],[376,2],[377,2],[378,2],[379,2],[381,2],[382,124],[170,2],[292,2],[94,2],[354,2],[298,125],[106,126],[296,127],[297,128],[303,129],[312,130],[301,2],[302,131],[313,132],[308,133],[309,134],[307,135],[311,136],[305,137],[304,138],[310,139],[306,130],[83,140],[81,141],[82,142],[70,143],[71,141],[78,144],[69,145],[74,146],[84,2],[75,147],[80,148],[85,149],[68,150],[76,151],[77,152],[72,153],[79,140],[73,154],[295,155],[107,156],[355,157],[358,2],[67,158],[90,159],[88,160],[87,2],[86,2],[89,161],[64,2],[65,2],[12,2],[13,2],[15,2],[14,2],[2,2],[16,2],[17,2],[18,2],[19,2],[20,2],[21,2],[22,2],[23,2],[3,2],[4,2],[24,2],[28,2],[25,2],[26,2],[27,2],[29,2],[30,2],[31,2],[5,2],[32,2],[33,2],[34,2],[35,2],[6,2],[39,2],[36,2],[37,2],[38,2],[40,2],[7,2],[41,2],[46,2],[47,2],[42,2],[43,2],[44,2],[45,2],[8,2],[51,2],[48,2],[49,2],[50,2],[52,2],[9,2],[53,2],[54,2],[55,2],[58,2],[56,2],[57,2],[59,2],[60,2],[10,2],[1,2],[11,2],[63,2],[62,2],[61,2],[186,162],[193,163],[185,162],[200,164],[177,165],[176,166],[199,34],[194,167],[197,168],[179,169],[178,170],[174,171],[173,172],[196,173],[175,174],[180,175],[181,2],[184,175],[171,2],[202,176],[201,175],[188,177],[189,178],[191,179],[187,180],[190,181],[195,34],[182,182],[183,183],[192,184],[172,185],[198,186],[91,187],[93,2],[97,188],[66,2],[112,189],[113,190],[166,2],[99,191],[100,192],[98,206],[165,194],[103,195],[101,196],[102,197],[164,198],[163,199],[111,200],[110,201],[108,202],[109,202],[162,203],[161,207],[167,196],[92,205]],"semanticDiagnosticsPerFile":[259,257,116,117,118,119,120,121,122,123,124,125,126,127,131,128,132,133,135,134,137,138,139,140,141,142,143,144,145,146,136,147,148,149,150,151,152,153,154,155,156,159,157,158,160,129,130,114,115,291,294,353,349,336,352,345,343,342,341,338,339,347,340,337,344,350,351,346,348,293,255,256,262,258,260,261,263,265,264,266,267,268,280,281,284,282,273,278,285,286,287,274,288,289,290,299,300,315,316,283,317,279,318,319,276,275,168,169,204,205,206,207,208,209,210,211,212,213,214,216,215,217,218,219,203,253,220,221,222,254,223,224,225,226,227,228,229,230,231,232,233,234,235,237,236,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,333,320,327,323,321,324,328,329,326,325,330,331,332,322,334,95,271,272,335,356,357,359,360,362,365,363,361,364,104,105,366,96,367,270,269,277,368,369,370,314,380,371,372,373,374,375,376,377,378,379,381,382,170,292,94,354,298,106,296,297,303,312,301,302,313,308,309,307,311,305,304,310,306,83,81,82,70,71,78,69,74,84,75,80,85,68,76,77,72,79,73,295,107,355,358,67,90,88,87,86,89,64,65,12,13,15,14,2,16,17,18,19,20,21,22,23,3,4,24,28,25,26,27,29,30,31,5,32,33,34,35,6,39,36,37,38,40,7,41,46,47,42,43,44,45,8,51,48,49,50,52,9,53,54,55,58,56,57,59,60,10,1,11,63,62,61,186,193,185,200,177,176,199,194,197,179,178,174,173,196,175,180,181,184,171,202,201,188,189,191,187,190,195,182,183,192,172,198,91,93,97,66,112,113,166,99,100,98,165,103,101,102,164,163,111,110,108,109,162,161,167,92],"affectedFilesPendingEmit":[66,112,113,166,99,100,98,165,103,101,102,164,163,111,110,108,109,162,161,92]},"version":"5.2.2"} \ No newline at end of file +{"program":{"fileNames":["../../node_modules/typescript/lib/lib.es5.d.ts","../../node_modules/typescript/lib/lib.es2015.d.ts","../../node_modules/typescript/lib/lib.es2016.d.ts","../../node_modules/typescript/lib/lib.es2017.d.ts","../../node_modules/typescript/lib/lib.es2018.d.ts","../../node_modules/typescript/lib/lib.es2019.d.ts","../../node_modules/typescript/lib/lib.es2020.d.ts","../../node_modules/typescript/lib/lib.es2021.d.ts","../../node_modules/typescript/lib/lib.es2022.d.ts","../../node_modules/typescript/lib/lib.es2023.d.ts","../../node_modules/typescript/lib/lib.esnext.d.ts","../../node_modules/typescript/lib/lib.dom.d.ts","../../node_modules/typescript/lib/lib.dom.iterable.d.ts","../../node_modules/typescript/lib/lib.es2015.core.d.ts","../../node_modules/typescript/lib/lib.es2015.collection.d.ts","../../node_modules/typescript/lib/lib.es2015.generator.d.ts","../../node_modules/typescript/lib/lib.es2015.iterable.d.ts","../../node_modules/typescript/lib/lib.es2015.promise.d.ts","../../node_modules/typescript/lib/lib.es2015.proxy.d.ts","../../node_modules/typescript/lib/lib.es2015.reflect.d.ts","../../node_modules/typescript/lib/lib.es2015.symbol.d.ts","../../node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","../../node_modules/typescript/lib/lib.es2016.array.include.d.ts","../../node_modules/typescript/lib/lib.es2017.date.d.ts","../../node_modules/typescript/lib/lib.es2017.object.d.ts","../../node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2017.string.d.ts","../../node_modules/typescript/lib/lib.es2017.intl.d.ts","../../node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","../../node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","../../node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","../../node_modules/typescript/lib/lib.es2018.intl.d.ts","../../node_modules/typescript/lib/lib.es2018.promise.d.ts","../../node_modules/typescript/lib/lib.es2018.regexp.d.ts","../../node_modules/typescript/lib/lib.es2019.array.d.ts","../../node_modules/typescript/lib/lib.es2019.object.d.ts","../../node_modules/typescript/lib/lib.es2019.string.d.ts","../../node_modules/typescript/lib/lib.es2019.symbol.d.ts","../../node_modules/typescript/lib/lib.es2019.intl.d.ts","../../node_modules/typescript/lib/lib.es2020.bigint.d.ts","../../node_modules/typescript/lib/lib.es2020.date.d.ts","../../node_modules/typescript/lib/lib.es2020.promise.d.ts","../../node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2020.string.d.ts","../../node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","../../node_modules/typescript/lib/lib.es2020.intl.d.ts","../../node_modules/typescript/lib/lib.es2020.number.d.ts","../../node_modules/typescript/lib/lib.es2021.promise.d.ts","../../node_modules/typescript/lib/lib.es2021.string.d.ts","../../node_modules/typescript/lib/lib.es2021.weakref.d.ts","../../node_modules/typescript/lib/lib.es2021.intl.d.ts","../../node_modules/typescript/lib/lib.es2022.array.d.ts","../../node_modules/typescript/lib/lib.es2022.error.d.ts","../../node_modules/typescript/lib/lib.es2022.intl.d.ts","../../node_modules/typescript/lib/lib.es2022.object.d.ts","../../node_modules/typescript/lib/lib.es2022.sharedmemory.d.ts","../../node_modules/typescript/lib/lib.es2022.string.d.ts","../../node_modules/typescript/lib/lib.es2022.regexp.d.ts","../../node_modules/typescript/lib/lib.es2023.array.d.ts","../../node_modules/typescript/lib/lib.es2023.collection.d.ts","../../node_modules/typescript/lib/lib.esnext.intl.d.ts","../../node_modules/typescript/lib/lib.esnext.disposable.d.ts","../../node_modules/typescript/lib/lib.esnext.decorators.d.ts","../../node_modules/typescript/lib/lib.decorators.d.ts","../../node_modules/typescript/lib/lib.decorators.legacy.d.ts","./postcss.js","../../node_modules/source-map-js/source-map.d.ts","../../node_modules/postcss/lib/previous-map.d.ts","../../node_modules/postcss/lib/input.d.ts","../../node_modules/postcss/lib/css-syntax-error.d.ts","../../node_modules/postcss/lib/declaration.d.ts","../../node_modules/postcss/lib/root.d.ts","../../node_modules/postcss/lib/warning.d.ts","../../node_modules/postcss/lib/lazy-result.d.ts","../../node_modules/postcss/lib/no-work-result.d.ts","../../node_modules/postcss/lib/processor.d.ts","../../node_modules/postcss/lib/result.d.ts","../../node_modules/postcss/lib/document.d.ts","../../node_modules/postcss/lib/rule.d.ts","../../node_modules/postcss/lib/node.d.ts","../../node_modules/postcss/lib/comment.d.ts","../../node_modules/postcss/lib/container.d.ts","../../node_modules/postcss/lib/at-rule.d.ts","../../node_modules/postcss/lib/list.d.ts","../../node_modules/postcss/lib/postcss.d.ts","../../node_modules/tailwindcss/types/generated/corepluginlist.d.ts","../../node_modules/tailwindcss/types/generated/colors.d.ts","../../node_modules/tailwindcss/types/config.d.ts","../../node_modules/tailwindcss/types/index.d.ts","../../node_modules/tailwindcss/plugin.d.ts","../ufb-tailwind/index.d.ts","./tailwind.config.js","./node_modules/@types/react/global.d.ts","../../node_modules/csstype/index.d.ts","../../node_modules/@types/prop-types/index.d.ts","../../node_modules/@types/scheduler/tracing.d.ts","./node_modules/@types/react/index.d.ts","./src/icon/svg/index.ts","./src/icon/icon.tsx","./src/icon/index.ts","./src/inputs/input.tsx","./src/inputs/textinput.tsx","./src/inputs/index.ts","../../node_modules/@types/react/global.d.ts","../../node_modules/@types/react/index.d.ts","../../node_modules/goober/goober.d.ts","../../node_modules/react-hot-toast/dist/index.d.ts","./src/toast/toastbox.tsx","./src/toast/toastpromisebox.tsx","./src/toast/toast.tsx","./src/toast/index.ts","./src/types/color.type.ts","./src/badge/badge.tsx","./src/badge/index.ts","../../node_modules/@floating-ui/utils/src/index.d.ts","../../node_modules/@floating-ui/utils/src/types.d.ts","../../node_modules/@floating-ui/core/src/computeposition.d.ts","../../node_modules/@floating-ui/core/src/detectoverflow.d.ts","../../node_modules/@floating-ui/core/src/middleware/arrow.d.ts","../../node_modules/@floating-ui/core/src/middleware/autoplacement.d.ts","../../node_modules/@floating-ui/core/src/middleware/flip.d.ts","../../node_modules/@floating-ui/core/src/middleware/hide.d.ts","../../node_modules/@floating-ui/core/src/middleware/inline.d.ts","../../node_modules/@floating-ui/core/src/middleware/offset.d.ts","../../node_modules/@floating-ui/core/src/middleware/shift.d.ts","../../node_modules/@floating-ui/core/src/middleware/size.d.ts","../../node_modules/@floating-ui/core/src/types.d.ts","../../node_modules/@floating-ui/dom/src/autoupdate.d.ts","../../node_modules/@floating-ui/dom/src/platform.d.ts","../../node_modules/@floating-ui/utils/dom/src/index.d.ts","../../node_modules/@floating-ui/utils/dom/src/types.d.ts","../../node_modules/@floating-ui/dom/src/index.d.ts","../../node_modules/@floating-ui/dom/src/types.d.ts","../../node_modules/@floating-ui/react-dom/src/arrow.d.ts","../../node_modules/@floating-ui/react-dom/src/usefloating.d.ts","../../node_modules/@floating-ui/react-dom/src/types.d.ts","../../node_modules/@floating-ui/react/src/hooks/usedismiss.d.ts","../../node_modules/@floating-ui/react/src/components/composite.d.ts","../../node_modules/@floating-ui/react/src/components/floatingarrow.d.ts","../../node_modules/@floating-ui/react/src/components/floatingdelaygroup.d.ts","../../node_modules/@floating-ui/react/src/components/floatingfocusmanager.d.ts","../../node_modules/@floating-ui/react/src/components/floatinglist.d.ts","../../node_modules/@floating-ui/react/src/components/floatingoverlay.d.ts","../../node_modules/@floating-ui/react/src/components/floatingportal.d.ts","../../node_modules/@floating-ui/react/src/components/floatingtree.d.ts","../../node_modules/@floating-ui/react/src/hooks/useclick.d.ts","../../node_modules/@floating-ui/react/src/hooks/useclientpoint.d.ts","../../node_modules/@floating-ui/react/src/hooks/usefloating.d.ts","../../node_modules/@floating-ui/react/src/hooks/usefocus.d.ts","../../node_modules/@floating-ui/react/src/hooks/usehover.d.ts","../../node_modules/@floating-ui/react/src/hooks/useid.d.ts","../../node_modules/@floating-ui/react/src/hooks/useinteractions.d.ts","../../node_modules/@floating-ui/react/src/hooks/uselistnavigation.d.ts","../../node_modules/@floating-ui/react/src/hooks/usemergerefs.d.ts","../../node_modules/@floating-ui/react/src/hooks/userole.d.ts","../../node_modules/@floating-ui/react/src/hooks/usetransition.d.ts","../../node_modules/@floating-ui/react/src/hooks/usetypeahead.d.ts","../../node_modules/@floating-ui/react/src/inner.d.ts","../../node_modules/@floating-ui/react/src/safepolygon.d.ts","../../node_modules/@floating-ui/react/src/index.d.ts","../../node_modules/@floating-ui/react/src/types.d.ts","./src/tooltip/tooltip.tsx","./src/tooltip/index.ts","./src/popover/popover.tsx","./src/popover/index.ts","./src/index.ts","./src/icon/constants.ts","./src/types/svg.d.ts","../../node_modules/@types/node/assert.d.ts","../../node_modules/@types/node/assert/strict.d.ts","../../node_modules/buffer/index.d.ts","../../node_modules/undici-types/header.d.ts","../../node_modules/undici-types/readable.d.ts","../../node_modules/undici-types/file.d.ts","../../node_modules/undici-types/fetch.d.ts","../../node_modules/undici-types/formdata.d.ts","../../node_modules/undici-types/connector.d.ts","../../node_modules/undici-types/client.d.ts","../../node_modules/undici-types/errors.d.ts","../../node_modules/undici-types/dispatcher.d.ts","../../node_modules/undici-types/global-dispatcher.d.ts","../../node_modules/undici-types/global-origin.d.ts","../../node_modules/undici-types/pool-stats.d.ts","../../node_modules/undici-types/pool.d.ts","../../node_modules/undici-types/handlers.d.ts","../../node_modules/undici-types/balanced-pool.d.ts","../../node_modules/undici-types/agent.d.ts","../../node_modules/undici-types/mock-interceptor.d.ts","../../node_modules/undici-types/mock-agent.d.ts","../../node_modules/undici-types/mock-client.d.ts","../../node_modules/undici-types/mock-pool.d.ts","../../node_modules/undici-types/mock-errors.d.ts","../../node_modules/undici-types/proxy-agent.d.ts","../../node_modules/undici-types/api.d.ts","../../node_modules/undici-types/cookies.d.ts","../../node_modules/undici-types/patch.d.ts","../../node_modules/undici-types/filereader.d.ts","../../node_modules/undici-types/diagnostics-channel.d.ts","../../node_modules/undici-types/websocket.d.ts","../../node_modules/undici-types/content-type.d.ts","../../node_modules/undici-types/cache.d.ts","../../node_modules/undici-types/interceptors.d.ts","../../node_modules/undici-types/index.d.ts","../../node_modules/@types/node/globals.d.ts","../../node_modules/@types/node/async_hooks.d.ts","../../node_modules/@types/node/buffer.d.ts","../../node_modules/@types/node/child_process.d.ts","../../node_modules/@types/node/cluster.d.ts","../../node_modules/@types/node/console.d.ts","../../node_modules/@types/node/constants.d.ts","../../node_modules/@types/node/crypto.d.ts","../../node_modules/@types/node/dgram.d.ts","../../node_modules/@types/node/diagnostics_channel.d.ts","../../node_modules/@types/node/dns.d.ts","../../node_modules/@types/node/dns/promises.d.ts","../../node_modules/@types/node/domain.d.ts","../../node_modules/@types/node/dom-events.d.ts","../../node_modules/@types/node/events.d.ts","../../node_modules/@types/node/fs.d.ts","../../node_modules/@types/node/fs/promises.d.ts","../../node_modules/@types/node/http.d.ts","../../node_modules/@types/node/http2.d.ts","../../node_modules/@types/node/https.d.ts","../../node_modules/@types/node/inspector.d.ts","../../node_modules/@types/node/module.d.ts","../../node_modules/@types/node/net.d.ts","../../node_modules/@types/node/os.d.ts","../../node_modules/@types/node/path.d.ts","../../node_modules/@types/node/perf_hooks.d.ts","../../node_modules/@types/node/process.d.ts","../../node_modules/@types/node/punycode.d.ts","../../node_modules/@types/node/querystring.d.ts","../../node_modules/@types/node/readline.d.ts","../../node_modules/@types/node/readline/promises.d.ts","../../node_modules/@types/node/repl.d.ts","../../node_modules/@types/node/stream.d.ts","../../node_modules/@types/node/stream/promises.d.ts","../../node_modules/@types/node/stream/consumers.d.ts","../../node_modules/@types/node/stream/web.d.ts","../../node_modules/@types/node/string_decoder.d.ts","../../node_modules/@types/node/test.d.ts","../../node_modules/@types/node/timers.d.ts","../../node_modules/@types/node/timers/promises.d.ts","../../node_modules/@types/node/tls.d.ts","../../node_modules/@types/node/trace_events.d.ts","../../node_modules/@types/node/tty.d.ts","../../node_modules/@types/node/url.d.ts","../../node_modules/@types/node/util.d.ts","../../node_modules/@types/node/v8.d.ts","../../node_modules/@types/node/vm.d.ts","../../node_modules/@types/node/wasi.d.ts","../../node_modules/@types/node/worker_threads.d.ts","../../node_modules/@types/node/zlib.d.ts","../../node_modules/@types/node/globals.global.d.ts","../../node_modules/@types/node/index.d.ts","../../node_modules/@types/accepts/index.d.ts","../../node_modules/@types/aria-query/index.d.ts","../../node_modules/@babel/types/lib/index.d.ts","../../node_modules/@types/babel__generator/index.d.ts","../../node_modules/@babel/parser/typings/babel-parser.d.ts","../../node_modules/@types/babel__template/index.d.ts","../../node_modules/@types/babel__traverse/index.d.ts","../../node_modules/@types/babel__core/index.d.ts","../../node_modules/@types/bcrypt/index.d.ts","../../node_modules/@types/connect/index.d.ts","../../node_modules/@types/body-parser/index.d.ts","../../node_modules/@types/content-disposition/index.d.ts","../../node_modules/@types/cookie/index.d.ts","../../node_modules/@types/cookiejar/index.d.ts","../../node_modules/@types/send/node_modules/@types/mime/index.d.ts","../../node_modules/@types/send/index.d.ts","../../node_modules/@types/qs/index.d.ts","../../node_modules/@types/range-parser/index.d.ts","../../node_modules/@types/express-serve-static-core/index.d.ts","../../node_modules/@types/http-errors/index.d.ts","../../node_modules/@types/mime/mime.d.ts","../../node_modules/@types/mime/index.d.ts","../../node_modules/@types/serve-static/index.d.ts","../../node_modules/@types/express/index.d.ts","../../node_modules/@types/keygrip/index.d.ts","../../node_modules/@types/cookies/index.d.ts","../../node_modules/@types/eslint/helpers.d.ts","../../node_modules/@types/estree/index.d.ts","../../node_modules/@types/json-schema/index.d.ts","../../node_modules/@types/eslint/index.d.ts","../../node_modules/@types/graceful-fs/index.d.ts","../../node_modules/@types/hoist-non-react-statics/index.d.ts","../../node_modules/@types/http-assert/index.d.ts","../../node_modules/@types/istanbul-lib-coverage/index.d.ts","../../node_modules/@types/istanbul-lib-report/index.d.ts","../../node_modules/@types/istanbul-reports/index.d.ts","../../node_modules/@jest/expect-utils/build/index.d.ts","../../node_modules/chalk/index.d.ts","../../node_modules/@sinclair/typebox/typebox.d.ts","../../node_modules/@jest/schemas/build/index.d.ts","../../node_modules/pretty-format/build/index.d.ts","../../node_modules/jest-diff/build/index.d.ts","../../node_modules/jest-matcher-utils/build/index.d.ts","../../node_modules/expect/build/index.d.ts","../../node_modules/@types/jest/index.d.ts","../../node_modules/@types/js-cookie/index.d.ts","../../node_modules/parse5/dist/common/html.d.ts","../../node_modules/parse5/dist/common/token.d.ts","../../node_modules/parse5/dist/common/error-codes.d.ts","../../node_modules/parse5/dist/tokenizer/preprocessor.d.ts","../../node_modules/parse5/dist/tokenizer/index.d.ts","../../node_modules/parse5/dist/tree-adapters/interface.d.ts","../../node_modules/parse5/dist/parser/open-element-stack.d.ts","../../node_modules/parse5/dist/parser/formatting-element-list.d.ts","../../node_modules/parse5/dist/parser/index.d.ts","../../node_modules/parse5/dist/tree-adapters/default.d.ts","../../node_modules/parse5/dist/serializer/index.d.ts","../../node_modules/parse5/dist/common/foreign-content.d.ts","../../node_modules/parse5/dist/index.d.ts","../../node_modules/@types/tough-cookie/index.d.ts","../../node_modules/@types/jsdom/base.d.ts","../../node_modules/@types/jsdom/index.d.ts","../../node_modules/@types/json5/index.d.ts","../../node_modules/@types/koa-compose/index.d.ts","../../node_modules/@types/koa/index.d.ts","../../node_modules/@types/nodemailer/lib/dkim/index.d.ts","../../node_modules/@types/nodemailer/lib/mailer/mail-message.d.ts","../../node_modules/@types/nodemailer/lib/xoauth2/index.d.ts","../../node_modules/@types/nodemailer/lib/mailer/index.d.ts","../../node_modules/@types/nodemailer/lib/mime-node/index.d.ts","../../node_modules/@types/nodemailer/lib/smtp-connection/index.d.ts","../../node_modules/@types/nodemailer/lib/shared/index.d.ts","../../node_modules/@types/nodemailer/lib/json-transport/index.d.ts","../../node_modules/@types/nodemailer/lib/sendmail-transport/index.d.ts","../../node_modules/@types/nodemailer/lib/ses-transport/index.d.ts","../../node_modules/@types/nodemailer/lib/smtp-pool/index.d.ts","../../node_modules/@types/nodemailer/lib/smtp-transport/index.d.ts","../../node_modules/@types/nodemailer/lib/stream-transport/index.d.ts","../../node_modules/@types/nodemailer/index.d.ts","../../node_modules/@types/parse-json/index.d.ts","../../node_modules/@types/react-beautiful-dnd/index.d.ts","../../node_modules/@popperjs/core/lib/enums.d.ts","../../node_modules/@popperjs/core/lib/modifiers/popperoffsets.d.ts","../../node_modules/@popperjs/core/lib/modifiers/flip.d.ts","../../node_modules/@popperjs/core/lib/modifiers/hide.d.ts","../../node_modules/@popperjs/core/lib/modifiers/offset.d.ts","../../node_modules/@popperjs/core/lib/modifiers/eventlisteners.d.ts","../../node_modules/@popperjs/core/lib/modifiers/computestyles.d.ts","../../node_modules/@popperjs/core/lib/modifiers/arrow.d.ts","../../node_modules/@popperjs/core/lib/modifiers/preventoverflow.d.ts","../../node_modules/@popperjs/core/lib/modifiers/applystyles.d.ts","../../node_modules/@popperjs/core/lib/types.d.ts","../../node_modules/@popperjs/core/lib/modifiers/index.d.ts","../../node_modules/@popperjs/core/lib/utils/detectoverflow.d.ts","../../node_modules/@popperjs/core/lib/createpopper.d.ts","../../node_modules/@popperjs/core/lib/popper-lite.d.ts","../../node_modules/@popperjs/core/lib/popper.d.ts","../../node_modules/@popperjs/core/lib/index.d.ts","../../node_modules/@popperjs/core/index.d.ts","../../node_modules/date-fns/typings.d.ts","../../node_modules/react-popper/typings/react-popper.d.ts","../../node_modules/@types/react-datepicker/index.d.ts","../../node_modules/@types/react-dom/index.d.ts","../../node_modules/redux/index.d.ts","../../node_modules/@types/react-redux/index.d.ts","../../node_modules/@types/react-transition-group/config.d.ts","../../node_modules/@types/react-transition-group/transition.d.ts","../../node_modules/@types/react-transition-group/csstransition.d.ts","../../node_modules/@types/react-transition-group/switchtransition.d.ts","../../node_modules/@types/react-transition-group/transitiongroup.d.ts","../../node_modules/@types/react-transition-group/index.d.ts","../../node_modules/@types/scheduler/index.d.ts","../../node_modules/@types/semver/index.d.ts","../../node_modules/@types/stack-utils/index.d.ts","../../node_modules/@types/superagent/index.d.ts","../../node_modules/@types/supertest/index.d.ts","../../node_modules/@types/validator/lib/isboolean.d.ts","../../node_modules/@types/validator/lib/isemail.d.ts","../../node_modules/@types/validator/lib/isfqdn.d.ts","../../node_modules/@types/validator/lib/isiban.d.ts","../../node_modules/@types/validator/lib/isiso31661alpha2.d.ts","../../node_modules/@types/validator/lib/isiso4217.d.ts","../../node_modules/@types/validator/lib/isiso6391.d.ts","../../node_modules/@types/validator/lib/istaxid.d.ts","../../node_modules/@types/validator/lib/isurl.d.ts","../../node_modules/@types/validator/index.d.ts","../../node_modules/@types/yargs-parser/index.d.ts","../../node_modules/@types/yargs/index.d.ts"],"fileInfos":[{"version":"2ac9cdcfb8f8875c18d14ec5796a8b029c426f73ad6dc3ffb580c228b58d1c44","affectsGlobalScope":true},"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","dc48272d7c333ccf58034c0026162576b7d50ea0e69c3b9292f803fc20720fd5","9a68c0c07ae2fa71b44384a839b7b8d81662a236d4b9ac30916718f7510b1b2d","5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","5514e54f17d6d74ecefedc73c504eadffdeda79c7ea205cf9febead32d45c4bc","1c0cdb8dc619bc549c3e5020643e7cf7ae7940058e8c7e5aefa5871b6d86f44b","bed7b7ba0eb5a160b69af72814b4dde371968e40b6c5e73d3a9f7bee407d158c",{"version":"0075fa5ceda385bcdf3488e37786b5a33be730e8bc4aa3cf1e78c63891752ce8","affectsGlobalScope":true},{"version":"35299ae4a62086698444a5aaee27fc7aa377c68cbb90b441c9ace246ffd05c97","affectsGlobalScope":true},{"version":"f296963760430fb65b4e5d91f0ed770a91c6e77455bacf8fa23a1501654ede0e","affectsGlobalScope":true},{"version":"09226e53d1cfda217317074a97724da3e71e2c545e18774484b61562afc53cd2","affectsGlobalScope":true},{"version":"4443e68b35f3332f753eacc66a04ac1d2053b8b035a0e0ac1d455392b5e243b3","affectsGlobalScope":true},{"version":"8b41361862022eb72fcc8a7f34680ac842aca802cf4bc1f915e8c620c9ce4331","affectsGlobalScope":true},{"version":"f7bd636ae3a4623c503359ada74510c4005df5b36de7f23e1db8a5c543fd176b","affectsGlobalScope":true},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true},{"version":"0c20f4d2358eb679e4ae8a4432bdd96c857a2960fd6800b21ec4008ec59d60ea","affectsGlobalScope":true},{"version":"93495ff27b8746f55d19fcbcdbaccc99fd95f19d057aed1bd2c0cafe1335fbf0","affectsGlobalScope":true},{"version":"82d0d8e269b9eeac02c3bd1c9e884e85d483fcb2cd168bccd6bc54df663da031","affectsGlobalScope":true},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true},{"version":"b8deab98702588840be73d67f02412a2d45a417a3c097b2e96f7f3a42ac483d1","affectsGlobalScope":true},{"version":"4738f2420687fd85629c9efb470793bb753709c2379e5f85bc1815d875ceadcd","affectsGlobalScope":true},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true},{"version":"376d554d042fb409cb55b5cbaf0b2b4b7e669619493c5d18d5fa8bd67273f82a","affectsGlobalScope":true},{"version":"9fc46429fbe091ac5ad2608c657201eb68b6f1b8341bd6d670047d32ed0a88fa","affectsGlobalScope":true},{"version":"61c37c1de663cf4171e1192466e52c7a382afa58da01b1dc75058f032ddf0839","affectsGlobalScope":true},{"version":"c4138a3dd7cd6cf1f363ca0f905554e8d81b45844feea17786cdf1626cb8ea06","affectsGlobalScope":true},{"version":"6ff3e2452b055d8f0ec026511c6582b55d935675af67cdb67dd1dc671e8065df","affectsGlobalScope":true},{"version":"03de17b810f426a2f47396b0b99b53a82c1b60e9cba7a7edda47f9bb077882f4","affectsGlobalScope":true},{"version":"8184c6ddf48f0c98429326b428478ecc6143c27f79b79e85740f17e6feb090f1","affectsGlobalScope":true},{"version":"261c4d2cf86ac5a89ad3fb3fafed74cbb6f2f7c1d139b0540933df567d64a6ca","affectsGlobalScope":true},{"version":"6af1425e9973f4924fca986636ac19a0cf9909a7e0d9d3009c349e6244e957b6","affectsGlobalScope":true},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true},{"version":"15a630d6817718a2ddd7088c4f83e4673fde19fa992d2eae2cf51132a302a5d3","affectsGlobalScope":true},{"version":"b7e9f95a7387e3f66be0ed6db43600c49cec33a3900437ce2fd350d9b7cb16f2","affectsGlobalScope":true},{"version":"01e0ee7e1f661acedb08b51f8a9b7d7f959e9cdb6441360f06522cc3aea1bf2e","affectsGlobalScope":true},{"version":"ac17a97f816d53d9dd79b0d235e1c0ed54a8cc6a0677e9a3d61efb480b2a3e4e","affectsGlobalScope":true},{"version":"bf14a426dbbf1022d11bd08d6b8e709a2e9d246f0c6c1032f3b2edb9a902adbe","affectsGlobalScope":true},{"version":"ec0104fee478075cb5171e5f4e3f23add8e02d845ae0165bfa3f1099241fa2aa","affectsGlobalScope":true},{"version":"2b72d528b2e2fe3c57889ca7baef5e13a56c957b946906d03767c642f386bbc3","affectsGlobalScope":true},{"version":"9cc66b0513ad41cb5f5372cca86ef83a0d37d1c1017580b7dace3ea5661836df","affectsGlobalScope":true},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true},{"version":"709efdae0cb5df5f49376cde61daacc95cdd44ae4671da13a540da5088bf3f30","affectsGlobalScope":true},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true},{"version":"bc496ef4377553e461efcf7cc5a5a57cf59f9962aea06b5e722d54a36bf66ea1","affectsGlobalScope":true},{"version":"038a2f66a34ee7a9c2fbc3584c8ab43dff2995f8c68e3f566f4c300d2175e31e","affectsGlobalScope":true},{"version":"4fa6ed14e98aa80b91f61b9805c653ee82af3502dc21c9da5268d3857772ca05","affectsGlobalScope":true},{"version":"f5c92f2c27b06c1a41b88f6db8299205aee52c2a2943f7ed29bd585977f254e8","affectsGlobalScope":true},{"version":"930b0e15811f84e203d3c23508674d5ded88266df4b10abee7b31b2ac77632d2","affectsGlobalScope":true},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true},{"version":"b9ea5778ff8b50d7c04c9890170db34c26a5358cccba36844fe319f50a43a61a","affectsGlobalScope":true},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true},{"version":"50d53ccd31f6667aff66e3d62adf948879a3a16f05d89882d1188084ee415bbc","affectsGlobalScope":true},{"version":"65be38e881453e16f128a12a8d36f8b012aa279381bf3d4dc4332a4905ceec83","affectsGlobalScope":true},{"version":"436aaf437562f276ec2ddbee2f2cdedac7664c1e4c1d2c36839ddd582eeb3d0a","affectsGlobalScope":true},{"version":"307c8b7ebbd7f23a92b73a4c6c0a697beca05b06b036c23a34553e5fe65e4fdc","affectsGlobalScope":true},{"version":"e1913f656c156a9e4245aa111fbb436d357d9e1fe0379b9a802da7fe3f03d736","affectsGlobalScope":true},{"version":"d4b1d2c51d058fc21ec2629fff7a76249dec2e36e12960ea056e3ef89174080f","affectsGlobalScope":true},{"version":"f35a831e4f0fe3b3697f4a0fe0e3caa7624c92b78afbecaf142c0f93abfaf379","affectsGlobalScope":true},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true},"f7a0d79b95dcbb3b9a2774c641e2715094e5acf0baed7232350f0f1f86954dfb","858d0d831826c6eb563df02f7db71c90e26deadd0938652096bea3cc14899700","8885cf05f3e2abf117590bbb951dcf6359e3e5ac462af1c901cfd24c6a6472e2","18c04c22baee54d13b505fa6e8bcd4223f8ba32beee80ec70e6cac972d1cc9a6","5e92a2e8ba5cbcdfd9e51428f94f7bd0ab6e45c9805b1c9552b64abaffad3ce3","53ca39fe70232633759dd3006fc5f467ecda540252c0c819ab53e9f6ad97b226","e7174a839d4732630d904a8b488f22380e5bcf1d6405d1f59614e10795eca17d","7ec359bbc29b69d4063fe7dad0baaf35f1856f914db16b3f4f6e3e1bca4099fa","b9261ac3e9944d3d72c5ee4cf888ad35d9743a5563405c6963c4e43ee3708ca4","c84fd54e8400def0d1ef1569cafd02e9f39a622df9fa69b57ccc82128856b916","c7a38c1ef8d6ae4bf252be67bd9a8b012b2cdea65bd6225a3d1a726c4f0d52b6","e773630f8772a06e82d97046fc92da59ada8414c61689894fff0155dd08f102c","74f2815d9e1b8530120dcad409ed5f706df8513c4d93e99fc6213997aa4dd60e","9d1f36ccd354f2e286b909bf01d626a3a28dd6590770303a18afa7796fe50db9","c4bc6a572f9d763ac7fa0d839be3de80273a67660e2002e3225e00ef716b4f37","106e607866d6c3e9a497a696ac949c3e2ec46b6e7dda35aabe76100bf740833b","8a6c755dc994d16c4e072bba010830fa2500d98ff322c442c7c91488d160a10d","d4514d11e7d11c53da7d43b948654d6e608a3d93d666a36f8d01e18ece04c9bd","3d65182eff7bbb16de1a69e17651c51083f740af11a1a92359be6dab939e8bcf","bb53fe9074a25dfa9410e2ee1c4db8c71d02275f916d2019de7fd9cadd50c30b","b5f622e0916bfab17f24bf37f54ef2fe822dbd3f88a8c80ba0f006c716f415d2","01374379f82be05d25c08d2f30779fa4a4c41895a18b93b33f14aeef51768692","9ae83384abbd32d2e949f73c79ec09834a37d969b0a55af921be5e4a829145f9","e2e69d4946fe8da5ee1001a3ef5011ff2a3d0be02a1bff580b7f1c7d2cf4a02f","c3916141089b022b0b5aab813af5e5159123ec7a019d057c8a41db5c6fd57401","8e3891f7bd9ec48de663a3519d22bd76da13850bb5ff579308a85458663f3133","46d6d58af6749aab2307f85a892158c0123ec1d5185a781e9920a960333e6604",{"version":"0bd5e7096c7bc02bf70b2cc017fc45ef489cb19bd2f32a71af39ff5787f1b56a","affectsGlobalScope":true},"4c68749a564a6facdf675416d75789ee5a557afda8960e0803cf6711fa569288","60ecad5852d4d83edae430e597405132d278a79c10499e9363aecbe1ddc0eade","5f8f00356f6a82e21493b2d57b2178f11b00cf8960df00bd37bdcae24c9333ca",{"version":"3b0cb15c510ca8c5199d6801638bc8b24f6c3fe6af188d090ed3a5bd81f44032","affectsGlobalScope":true},{"version":"33f6d30a4929e904adc916343c878f23d25893a2cc9a94033b90e292c1b08cbb","signature":"e5cfe00ee8589cc56dbcffb0c1f61fd2091c89d2ce3e0fd2eba6efedf3d6839f"},"d371978c363195ad6a4720a74ce467f6570c851fe7e582ae75203eb3ebab5632","bafa6b0d2c6e4c78c097ec4b798f9209ef7030aa84e1bc95757b0ecea08205c9","429cd18b5ed2d3237232e3623254cf4e0fb904689c6db27ae3a28fc9cab27051","fd63a8edca6b78eb98af76a3039289b0c55ffbeb0c4d6d95127b2508a0277546","54f243b34272f6e25f353023bb33eb7fdee99c1257c4fb3ae5c15f05fdb5cdcf",{"version":"0bd5e7096c7bc02bf70b2cc017fc45ef489cb19bd2f32a71af39ff5787f1b56a","affectsGlobalScope":true},{"version":"b781d7fafad75c82703e2d53ee96c8add4715bc9996ca0e3cf0809bc15e7011f","affectsGlobalScope":true},"e84626067725d75771368f7f33a6b8898cfcff1190f4e9bde24c48f9fa818993","f3ba20ed94c93bf36db218afaba8d7b8f0c2db22948fc3e4fcb2d28d86046c23","bcc7feb7c7e3abdb2579d8962e517cca98f0e1bd9ee0d5c848b02823f2cc4a4a","01f94e325cf362cbbe30a02318901e55dca811232c9af97d22655e5fcac6a45b","07ad5dd203de3ea10f53f63d6f58c6bf8ad4a7bc87235e2ed790a02c60114151","fd53ea71c3b79ccbea36884bf8874df8a25e34de3b7d92f3b0e53ab05608c3b1",{"version":"1c3c4e3074a25c90bdd834649435f93517ffa5a3cb6775bc7e089feffe4ea259","signature":"46169b4c7cf564ab42ff5acb3c23001e1b80b3c9c87f36dda164dfbe8d22d549"},{"version":"2e22d459f3fe84c97bf6e344e43e552db88be06ca42ffb13acc8ead09e1cabfb","signature":"ac3bbd76aa8e63ebfa51b05e6bb4e4c90d1c26a96d6ece451cb43c5c663e39bf"},"b84eab2e61a64291f336da30aa9b559fe492e38a29df552c2e49a33134ada469","db8fc5c1917265860fd2a6963989985322e37882515284e5a33e3e61362798e3","4336423981fc67f99d5c364a64ec671641d4a5a796dee33bc4ddbc852da7de72","8dc56f817d20cf0717842ac1dcf3ac1a450ac3c667f1285754901e4af97bbe47","72156b7624d678a2686a22a45014218be4da7147f1058a5a5072fe981c492dd5","a20d054b8be41316bcc29752c5b9b42fab727b887a076a3af3bee18813d6e8c2","2eba4ddfbd6837ef33e58644f7dffbfe465d7222f9a9101789f02427f9541488","bb82c16e8371bfc6ee2ffd0095fd321d6acfa57c6c6445019567fec1b6b5e92a","6c65126b657a275259c4759b2b2f7b04879e61bc6c51df025b3748f87652a9eb","a5ad75d3dd3d3df0f62cb7a8ea12e76e0b4a3143ecde9e29c9cc0586b88365c0","03cd482c80ad0b724ca7d55cef7e3823d593b7cced98d7ef9d1bf757c5055b77","e93f395b0d6e68525181a6ecb3e7b81b1c5bf551f32f6cebbdffdef354fbe4a3","b881b069efb9d55b4b4b98de76d8c3920a29254e24f8f0c38557186e38cf6955","deb697b1102b461b0ea63a7fb59738a6c218d61b095e6040f3e0f00f18d870c1","1f542c79756fda13fdeda2c6c90a560ebddb14758eb5c3278ef8b750a03c9bec","b0ed00813c153e350a56faa8c15668bf21375928e9b88bcc482d20f1f8415179","2c421f4e1d8793d38c14a8c9985acb26d3a3ed517dd02fd7ebafb4f069536d48","9140f2acce87195a28377cb0c079de679eb3ee4aa027f2c69f8a70f5c58231f4","93557733190db162b067dd324a0516b8cd4313fda14c228468d865145245ed04","8ace7c551f55726a0bd825aa163fa5789a1f816a236739ca122ca80aac13661f","d7dc9cff3eb07213b3887a231c6729ac0a0adf8ae54bc1db69c551827cab97b7","85a3930c87a4275e3b39c760eb21472309b58135e9c624a872869e0f9bd08d23","4ab32839b87ba4bf87ac0169d65d02af289536b1ce1b7e8da3eefdc282dc030d","000164cebbe192519378950498453bcb07e0788cd5ac724369ca3fd6508d5ea5","dc8434313627a9bb9e7108ac2d3ecd60848bd624782539aa2f8980e71bf3bab4","4d0ccec357785fbd68e09276300d909b84f933fecc1ea5d92761a9c7d36b1bb4","6216c65f72d2f0db713e7b5e483c95c937bc779a18f9e52afa72702f01f83b89","8ef5a55e956a7a0a8ef406f96168a55a9940e9cbe05fb0565cf40097580b4377","d4b5b7028959c3e1e8847c71b7e845604ff49ffe6e3edf57af07d838d69668c7","db3f4c30a1c61447687dab0185608906e9e69212c2120f19a947b2078573ca41","b082be572ccc60fa650ef6eefdaf59005dedd325dc09d6134a1112cae61e9d93","d17718ff094281efa9a20798eec185815ecd4f5affa1f479d954880cc89758b4","52740032b2d8c0ef763fe40bbf9a6c6b7e215f59cd218b9a4912b377580d3856","205eeb1efdf796604cc543cb81894f598345555c4b1f89450dcceda768e61076","e9c213e0c278ba15ef2446c365c12e78e87a6240df1d2a6e80abcc04f3860512","4a0e6d702351d06abfa7b309c671d3231c98fe305a08d0422e99c049fbed3b88","73191fd675953c2aa8b757b38d347fe803bf0faefe3b3f6c2f662d9216806f7b","56c90a2b4699433a11ee09e068e59e0135340213a41046a4c11cfc9ed5ccef77","da3c82549a428acb82afe8032f31d0ab992e382fc12dd65d526a422ac5a2d865","e75629407d0cbe5f8c160d2d35b5afae3d9ed1042cde9ab9e1edc444d1d6c9ee","3a97c0c4e8f19915f4feb50f2cf0c06edb6cd85e9ffbf8382deedcaebc1f74b4","a75962251edcd1f1e949fe2828558d235cc81f6523cdf05c179c552e16df556d","583c4f3f1c233ecfd273f1bb315699aed9eeae4bc40b1a1398f56de889309e7f","b82185269794856a51705d86ad34a31c1b0d51a19d158e42f0208cc906478c85","f2a2be315c3df9ef66b167785a4fdac194268411af71e71489f3767dbc2ef8fa","e58682765ca9ad05163a6545719ba16e9a70011fb6d60f3b808c6d22df4a22e1","1fe3569c0268b82287a07b82866f5cbb95bc80fafad176b2627a11a0ab80d688","815b1e2d384bc5d9c5f84dddd6a00c03abf674a61ce08c825baac5d9cace9745",{"version":"5f69055592151a5bdc226242a28b969c2d7f1df487af03a2a4fa01822c291a23","signature":"26d47bbfb25f963e7d576363e312c481299bdf6a8047cd33ad68e92b9099ebd7"},"d4f200d88a45cd9d0b5f94d789c1691f96d03b63028436b5394b9a79770ec7d8","78d6ea86a676af53b6b9c1308d763818f573b8a88fdf6ef1c0525e72cf3f5148","7b8cd1e4f1de41abaa39535a767274383c6097e80c7031229f432cd379d6857a","e13f2b2d2f77a8847fe57e6e3d0be38bdfcf6400764571bd59c27036c0f62327","d33a37ba5729267033d4ebb17b1f605b6b21a86654c89b4af82ae1bbaf59e4b5","f8c483ea6a323b1ce441d4dce1c9434e0fc6061fb781d98ad47e4a5737f95e31","09df3b4f1c937f02e7fee2836d4c4d7a63e66db70fd4d4e97126f4542cc21d9d","7394959e5a741b185456e1ef5d64599c36c60a323207450991e7a42e08911419","4967529644e391115ca5592184d4b63980569adf60ee685f968fd59ab1557188","5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","7180c03fd3cb6e22f911ce9ba0f8a7008b1a6ddbe88ccf16a9c8140ef9ac1686","25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","54cb85a47d760da1c13c00add10d26b5118280d44d58e6908d8e89abbd9d7725","3e4825171442666d31c845aeb47fcd34b62e14041bb353ae2b874285d78482aa","adda9e3915c6bf15e360356a41d950881a51dbe44f9a6088155836b040820663","b4855526ac5a822d6e0005e4b62ee49c599bf89897e4109135283d660e60291c","e9775e97ac4877aebf963a0289c81abe76d1ec9a2a7778dbe637e5151f25c5f3","471e1da5a78350bc55ef8cef24eb3aca6174143c281b8b214ca2beda51f5e04a","cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","db3435f3525cd785bf21ec6769bf8da7e8a776be1a99e2e7efb5f244a2ef5fee","c3b170c45fc031db31f782e612adf7314b167e60439d304b49e704010e7bafe5","40383ebef22b943d503c6ce2cb2e060282936b952a01bea5f9f493d5fb487cc7","80ad053918e96087d9da8d092ff9f90520c9fc199c8bfd9340266dd8f38f364e","3a84b7cb891141824bd00ef8a50b6a44596aded4075da937f180c90e362fe5f6","13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","33203609eba548914dc83ddf6cadbc0bcb6e8ef89f6d648ca0908ae887f9fcc5","0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","e53a3c2a9f624d90f24bf4588aacd223e7bec1b9d0d479b68d2f4a9e6011147f","339dc5265ee5ed92e536a93a04c4ebbc2128f45eeec6ed29f379e0085283542c","9f0a92164925aa37d4a5d9dd3e0134cff8177208dba55fd2310cd74beea40ee2","8bfdb79bf1a9d435ec48d9372dc93291161f152c0865b81fc0b2694aedb4578d","2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","d32275be3546f252e3ad33976caf8c5e842c09cb87d468cb40d5f4cf092d1acc","d70119390aece1794bf4988f10ea750d13455f5286977d35027d43dd2e9841cf",{"version":"4d719cfab49ae4045d15cb6bed0f38ad3d7d6eb7f277d2603502a0f862ca3182","affectsGlobalScope":true},"cce1f5f86974c1e916ec4a8cab6eec9aa8e31e8148845bf07fbaa8e1d97b1a2c",{"version":"5a856afb15f9dc9983faa391dde989826995a33983c1cccb173e9606688e9709","affectsGlobalScope":true},"546ab07e19116d935ad982e76a223275b53bff7771dab94f433b7ab04652936e","7b43160a49cf2c6082da0465876c4a0b164e160b81187caeb0a6ca7a281e85ba",{"version":"aefb5a4a209f756b580eb53ea771cca8aad411603926f307a5e5b8ec6b16dcf6","affectsGlobalScope":true},"a40826e8476694e90da94aa008283a7de50d1dafd37beada623863f1901cb7fb","f5a8b7ec4b798c88679194a8ebc25dcb6f5368e6e5811fcda9fe12b0d445b8db","b86e1a45b29437f3a99bad4147cb9fe2357617e8008c0484568e5bb5138d6e13","b5b719a47968cd61a6f83f437236bb6fe22a39223b6620da81ef89f5d7a78fb7","42c431e7965b641106b5e25ab3283aa4865ca7bb9909610a2abfa6226e4348be","0b7e732af0a9599be28c091d6bd1cb22c856ec0d415d4749c087c3881ca07a56","b7fe70be794e13d1b7940e318b8770cd1fb3eced7707805318a2e3aaac2c3e9e",{"version":"2c71199d1fc83bf17636ad5bf63a945633406b7b94887612bba4ef027c662b3e","affectsGlobalScope":true},{"version":"8d6138a264ddc6f94f16e99d4e117a2d6eb31b217891cf091b6437a2f114d561","affectsGlobalScope":true},"3b4c85eea12187de9929a76792b98406e8778ce575caca8c574f06da82622c54","f788131a39c81e0c9b9e463645dd7132b5bc1beb609b0e31e5c1ceaea378b4df","0c236069ce7bded4f6774946e928e4b3601894d294054af47a553f7abcafe2c1","21894466693f64957b9bd4c80fa3ec7fdfd4efa9d1861e070aca23f10220c9b2","396a8939b5e177542bdf9b5262b4eee85d29851b2d57681fa9d7eae30e225830","ad8848c289c0b633452e58179f46edccd14b5a0fe90ebce411f79ff040b803e0",{"version":"6ec93c745c5e3e25e278fa35451bf18ef857f733de7e57c15e7920ac463baa2a","affectsGlobalScope":true},"91f8b5abcdff8f9ecb9656b9852878718416fb7700b2c4fad8331e5b97c080bb","59d8f064f86a4a2be03b33c0efcc9e7a268ad27b22f82dce16899f3364f70ba8","0f05c06ff6196958d76b865ae17245b52d8fe01773626ac3c43214a2458ea7b7",{"version":"f49fb15c4aa06b65b0dce4db4584bfd8a9f74644baef1511b404dc95be34af00","affectsGlobalScope":true},{"version":"d48009cbe8a30a504031cc82e1286f78fed33b7a42abf7602c23b5547b382563","affectsGlobalScope":true},"7aaeb5e62f90e1b2be0fc4844df78cdb1be15c22b427bc6c39d57308785b8f10","3ba30205a029ebc0c91d7b1ab4da73f6277d730ca1fc6692d5a9144c6772c76b","d8dba11dc34d50cb4202de5effa9a1b296d7a2f4a029eec871f894bddfb6430d","8b71dd18e7e63b6f991b511a201fad7c3bf8d1e0dd98acb5e3d844f335a73634","01d8e1419c84affad359cc240b2b551fb9812b450b4d3d456b64cda8102d4f60","458b216959c231df388a5de9dcbcafd4b4ca563bc3784d706d0455467d7d4942","269929a24b2816343a178008ac9ae9248304d92a8ba8e233055e0ed6dbe6ef71","93452d394fdd1dc551ec62f5042366f011a00d342d36d50793b3529bfc9bd633","f8c87b19eae111f8720b0345ab301af8d81add39621b63614dfc2d15fd6f140a","831c22d257717bf2cbb03afe9c4bcffc5ccb8a2074344d4238bf16d3a857bb12",{"version":"24ba151e213906027e2b1f5223d33575a3612b0234a0e2b56119520bbe0e594b","affectsGlobalScope":true},{"version":"cbf046714f3a3ba2544957e1973ac94aa819fa8aa668846fa8de47eb1c41b0b2","affectsGlobalScope":true},"aa34c3aa493d1c699601027c441b9664547c3024f9dbab1639df7701d63d18fa","eae74e3d50820f37c72c0679fed959cd1e63c98f6a146a55b8c4361582fa6a52","7c651f8dce91a927ab62925e73f190763574c46098f2b11fb8ddc1b147a6709a","7440ab60f4cb031812940cc38166b8bb6fbf2540cfe599f87c41c08011f0c1df",{"version":"aed89e3c18f4c659ee8153a76560dffda23e2d801e1e60d7a67abd84bc555f8d","affectsGlobalScope":true},{"version":"0ed13c80faeb2b7160bffb4926ff299c468e67a37a645b3ae0917ba0db633c1b","affectsGlobalScope":true},"e393915d3dc385e69c0e2390739c87b2d296a610662eb0b1cb85224e55992250","2f940651c2f30e6b29f8743fae3f40b7b1c03615184f837132b56ea75edad08b","5749c327c3f789f658072f8340786966c8b05ea124a56c1d8d60e04649495a4d",{"version":"c9d62b2a51b2ff166314d8be84f6881a7fcbccd37612442cf1c70d27d5352f50","affectsGlobalScope":true},"e7dbf5716d76846c7522e910896c5747b6df1abd538fee8f5291bdc843461795",{"version":"ab9b9a36e5284fd8d3bf2f7d5fcbc60052f25f27e4d20954782099282c60d23e","affectsGlobalScope":true},"d3f4c342fb62f348f25f54b473b0ab7371828cbf637134194fa9cdf04856f87b","6738101ae8e56cd3879ab3f99630ada7d78097fc9fd334df7e766216778ca219","a4a548b5c2db93a5e084760fe9f01e733bde167592de69ac4a257e24b10c8b75","f713064ca751dc588bc13832137c418cb70cf0446de92ade60ad631071558fca","dfefd34e8ab60f41d0c130527d5092d6ce662dc9fa85bc8c97682baf65830b51","96c23535f4f9dd15beb767e070559ea672f6a35f103152836a67100605136a96","b0f4dd1a825912da8f12fd3388d839ef4aa51165ea0e60e4869b50b7ccb4f6fc","9cb7c5f710dc84d2e9500831a3e9a27afd3c3710f5a1b8744a50473e565b41fc","cf6b2edde490f303918809bfab1da8b6d059b50c160bec72005ff4c248bdd079","160b24efb5a868df9c54f337656b4ef55fcbe0548fe15408e1c0630ec559c559","82819f9ecc249a6a3e284003540d02ea1b1f56f410c23231797b9e1e4b9622df","81a109b6bb6adf5ed70f2c7e6d907b8c3adcf7b47b5ee09701c5f97370fd29b7","64fcc79ee3c237816b9cef0a9289b00bf3da5b17040cd970ac04ba03c4ac1595","117ffeecf6c55e25b6446f449ad079029b5e7317399b0a693858faaaea5ca73e","8d48b8f8a377ade8dd1f000625bc276eea067f2529cc9cafdf082d17142107d6","f6218314af6f492ce5461bdadac5b829f5b4b31a3d1da3d04e77ed0afe0829fb","7167d932a7e2e991084421bb22af20024ada5a046d948c742de0f89996de5d0b","c7da551241b7be719b7bd654ab12a5098c3206fbb189076dd2d8871011a6ab5a","e2d3bfa79f0fad3ad67dfb0685c50dbe19b364a440160a2d40d0e3f44c75938c",{"version":"1c598f8d911f0bc39f04910c8c93f2f76fbb65f892ee5ecc38a2b58bb95af752","affectsGlobalScope":true},"4eadf1158f1ae8f7b0deea0f96b391359042cf74d1eb3ce1dacdb69de96e590d","d2a38ad7bb4676e7fd5d058a08105d81ac232c363ee56be0b401fc277d50dbb1","6d1438dc186a32e0441133a6ea7befa732b92d37d96020fafa82ff3b9a7f9cc5","f0120fc76274f614e7b8f5420a74abce69eee25b81e2084479fa426f33ccd46a","003f07cf566395059625b39785398f18652c8952e19790e7d6eeb22a9cbe0440","432dc46f22f9790797d98ccf09f7dc4a897bb5e874921217b951fb808947446b","28ed61ddc42936537ad29ade1404d533b4b28967460e29811409e5a40d9fc3b3",{"version":"64d4b35c5456adf258d2cf56c341e203a073253f229ef3208fc0d5020253b241","affectsGlobalScope":true},"42fe73978ddb3a82329bf41a116e921deb266551e4f0ad9e9c7bdc581c24f085","dd89872dd0647dfd63665f3d525c06d114310a2f7a5a9277e5982a152b31be2b","fdd574c45ab01286d64b1e2e78e9ea647c4527e954e27ae281d372f5fba41567","6adaa01cba6e7bae17d8291089e9e38bfc3fffcd522e2161214cbaccab4c1b2b","a5bb013d950d8fb43ee54eeeef427ad9b97feed7b9b61e3a731a181567356c0d","e98185f4249720ace1921d59c1ff4612fa5c633a183fc9bf28e2e7b8e3c7fd51","8b06ac3faeacb8484d84ddb44571d8f410697f98d7bfa86c0fda60373a9f5215","c79bd2f3e5c05e7ad80dc82ce8d339cac23ac1f5e6cfab96c860bb70d5162873","e3328cedfe4d7fac23ba75d00bf5169269800ab949d0837cd88c4211a52c3762","cdcc132f207d097d7d3aa75615ab9a2e71d6a478162dde8b67f88ea19f3e54de","0d14fa22c41fdc7277e6f71473b20ebc07f40f00e38875142335d5b63cdfc9d2","c085e9aa62d1ae1375794c1fb927a445fa105fed891a7e24edbb1c3300f7384a","f315e1e65a1f80992f0509e84e4ae2df15ecd9ef73df975f7c98813b71e4c8da","5b9586e9b0b6322e5bfbd2c29bd3b8e21ab9d871f82346cb71020e3d84bae73e","3e70a7e67c2cb16f8cd49097360c0309fe9d1e3210ff9222e9dac1f8df9d4fb6","ab68d2a3e3e8767c3fba8f80de099a1cfc18c0de79e42cb02ae66e22dfe14a66","d96cc6598148bf1a98fb2e8dcf01c63a4b3558bdaec6ef35e087fd0562eb40ec",{"version":"9afcfd847523b81d526c73130a247fbb65aa1eba2a1d4195cfacd677a9e4de08","affectsGlobalScope":true},"b3338366fe1f2c5f978e2ec200f57d35c5bd2c4c90c2191f1e638cfa5621c1f6","3411c785dbe8fd42f7d644d1e05a7e72b624774a08a9356479754999419c3c5a","8fb8fdda477cd7382477ffda92c2bb7d9f7ef583b1aa531eb6b2dc2f0a206c10","66995b0c991b5c5d42eff1d950733f85482c7419f7296ab8952e03718169e379","33f3795a4617f98b1bb8dac36312119d02f31897ae75436a1e109ce042b48ee8","2850c9c5dc28d34ad5f354117d0419f325fc8932d2a62eadc4dc52c018cd569b","c753948f7e0febe7aa1a5b71a714001a127a68861309b2c4127775aa9b6d4f24","3e7a40e023e1d4a9eef1a6f08a3ded8edacb67ae5fce072014205d730f717ba5","a77be6fc44c876bc10c897107f84eaba10790913ebdcad40fcda7e47469b2160","382100b010774614310d994bbf16cc9cd291c14f0d417126c7a7cfad1dc1d3f8","91f5dbcdb25d145a56cffe957ec665256827892d779ef108eb2f3864faff523b","4fdf56315340bd1770eb52e1601c3a98e45b1d207202831357e99ce29c35b55c","927955a3de5857e0a1c575ced5a4245e74e6821d720ed213141347dd1870197f","be6fd74528b32986fbf0cd2cfa9192a5ed7f369060b32a7adcb0c8d055708e61","54fe5f476c5049c39e5b58927d98b96aad0f18a9fd3e21b51fb3ee812631c8c0","fd0589ca571ad090b531d8c095e26caa53d4825c64d3ff2b2b1ab95d72294175",{"version":"669843ecafb89ae1e944df06360e8966219e4c1c34c0d28aa2503272cdd444a7","affectsGlobalScope":true},"96d14f21b7652903852eef49379d04dbda28c16ed36468f8c9fa08f7c14c9538","fa849c825ac37d70ca78097a1cd06bb5ac281651f765fff8e491cfb0709de57b","c39e1ee964fa0bb318ee2db72c430b3aede3b50dbde414b03b4e43915f80c292","6825eb4d1c8beb77e9ed6681c830326a15ebf52b171f83ffbca1b1574c90a3b0","1741975791f9be7f803a826457273094096e8bba7a50f8fa960d5ed2328cdbcc","6ec0d1c15d14d63d08ccb10d09d839bf8a724f6b4b9ed134a3ab5042c54a7721","75dabc9afdb451a85e6d46e9ca65ec82ead2256476c0686f671f3421923667a7","ddfc215bfbddf5854d80ab8fb0256bd802f2a8acb6be62f9e630041266d56cd5","2c3bcb8a4ea2fcb4208a06672af7540dd65bf08298d742f041ffa6cbe487cf80","1cce0460d75645fc40044c729da9a16c2e0dabe11a58b5e4bfd62ac840a1835d","c784a9f75a6f27cf8c43cc9a12c66d68d3beb2e7376e1babfae5ae4998ffbc4a","feb4c51948d875fdbbaa402dad77ee40cf1752b179574094b613d8ad98921ce1","a6d3984b706cefe5f4a83c1d3f0918ff603475a2a3afa9d247e4114f18b1f1ef","b457d606cabde6ea3b0bc32c23dc0de1c84bb5cb06d9e101f7076440fc244727","9d59919309a2d462b249abdefba8ca36b06e8e480a77b36c0d657f83a63af465","9faa2661daa32d2369ec31e583df91fd556f74bcbd036dab54184303dee4f311","b08de5693ec0119e033ced692f3ad0c0449c7331fd1d84033ea9b4b22e7f269c","2b8264b2fefd7367e0f20e2c04eed5d3038831fe00f5efbc110ff0131aab899b","e66f26a75bd5a23640087e17bfd965bf5e9f7d2983590bc5bf32c500db8cf9fd","70a29119482d358ab4f28d28ee2dcd05d6cbf8e678068855d016e10a9256ec12","869ac759ae8f304536d609082732cb025a08dcc38237fe619caf3fcdd41dde6f","0ea900fe6565f9133e06bce92e3e9a4b5a69234e83d40b7df2e1752b8d2b5002","e5408f95ca9ac5997c0fea772d68b1bf390e16c2a8cad62858553409f2b12412","3c1332a48695617fc5c8a1aead8f09758c2e73018bd139882283fb5a5b8536a6","9260b03453970e98ce9b1ad851275acd9c7d213c26c7d86bae096e8e9db4e62b","083838d2f5fea0c28f02ce67087101f43bd6e8697c51fd48029261653095080c","969132719f0f5822e669f6da7bd58ea0eb47f7899c1db854f8f06379f753b365","94ca5d43ff6f9dc8b1812b0770b761392e6eac1948d99d2da443dc63c32b2ec1","2cbc88cf54c50e74ee5642c12217e6fd5415e1b35232d5666d53418bae210b3b","ccb226557417c606f8b1bba85d178f4bcea3f8ae67b0e86292709a634a1d389d","5ea98f44cc9de1fe05d037afe4813f3dcd3a8c5de43bdd7db24624a364fad8e6","5260a62a7d326565c7b42293ed427e4186b9d43d6f160f50e134a18385970d02","0b3fc2d2d41ad187962c43cb38117d0aee0d3d515c8a6750aaea467da76b42aa","ed219f328224100dad91505388453a8c24a97367d1bc13dcec82c72ab13012b7","6847b17c96eb44634daa112849db0c9ade344fe23e6ced190b7eeb862beca9f4","d479a5128f27f63b58d57a61e062bd68fa43b684271449a73a4d3e3666a599a7","6f308b141358ac799edc3e83e887441852205dc1348310d30b62c69438b93ca0",{"version":"d204bd5d20ca52a553f7ba993dc2a422e9d1fce0b8178ce2bfe55fbd027c11ae","affectsGlobalScope":true},"7ed8a817989d55241e710dd80af79d02004ca675ad73d92894c0d61248ad423d","20760f286c49ea8ee2be865b1d4ebc3b46752669dde6c0f8ca91029904a01d65","4355c807c60f6b8a69ee3307c5f9adde7d8303172bcfa4805fa804511a6c3ce2",{"version":"fd624f7d7b264922476685870f08c5e1c6d6a0f05dee2429a9747b41f6b699d4","affectsGlobalScope":true},"716a2a83ae87f4e37097eb0c2f512148651848e254ee10e5db3d7bb91ee6781c","960a68ced7820108787135bdae5265d2cc4b511b7dcfd5b8f213432a8483daf1","e27ecc0d7bbbb4b12c9688e2f728e09c0be5a73dff4257008790f60cc6df5d54","2e7ebdc7d8af978c263890bbde991e88d6aa31cc29d46735c9c5f45f0a41243b","b57fd1c0a680d220e714b76d83eff51a08670f56efcc5d68abc82f5a2684f0c0","53e37f594066c9a083bb1a3ff2d2fa1be5fa617fcfad963a22841648e9139378","a4156fbcb9cbaa4e641fd5c62b1add36e6fe00d90ea44dc2afabd469355f1dc7","4964ba28dd6c9d086735062e8f4c63f23dd14e20b9b6d2acdc5774760d47b132","9af49718d3e0c01d64e27c2d627da83849e5f08b57f80b4c8990817f5353c8a9","b0d10e46cfe3f6c476b69af02eaa38e4ccc7430221ce3109ae84bb9fb8282298","e0cab2148d11374247b33dc7c5644e037b0c0db73d221031b1b7f99147178339","dbe69644ab6e699ad2ef740056c637c34f3348af61d3764ff555d623703525db","9eb48a18d9d78d2dc2683bfb79d083954d13cf066d9579cbdb8652b86601fbd7","2f4f96af192dc44a12bf238bcc08ebac498c9073f459740f6497fe0f8e1a432c","c5b3da7e2ecd5968f723282aba49d8d1a2e178d0afe48998dad93f81e2724091","efd2860dc74358ffa01d3de4c8fa2f966ae52c13c12b41ad931c078151b36601","09acacae732e3cc67a6415026cfae979ebe900905500147a629837b790a366b3","72154a9d896b0a0aed69fd2a58aa5aa8ab526078a65ff92f0d3c2237e9992610","99236ea5c4c583082975823fd19bcce6a44963c5c894e20384bc72e7eccf9b03","f6688a02946a3f7490aa9e26d76d1c97a388e42e77388cbab010b69982c86e9e","b027979b9e4e83be23db2d81e01d973b91fefe677feb93823486a83762f65012","7eb1ec2dd7db758b625f52be624bbcf238b1367c5d929c2c8fbcc402d1765216","75fa6a9be075402ea969c1fcec4bb1421f72efbc3e2f340032684cdd3115197c","2ec6204e9750249048f390f520197cc67d52c1c769c1ed866285cd9070aa2bab"],"root":[66,92,[98,103],[108,114],[162,168]],"options":{"esModuleInterop":true,"jsx":1,"module":99,"noUncheckedIndexedAccess":true,"skipLibCheck":true,"strict":true,"target":4},"fileIdsList":[[248,258,355],[248,355],[127,248,355],[118,127,248,355],[116,117,118,119,120,121,122,123,124,125,126,248,355],[133,248,355],[127,128,129,131,133,248,355],[127,128,129,131,132,248,355],[105,133,248,355],[105,133,134,135,248,355],[136,248,355],[105,248,355],[105,161,248,355],[161,248,355],[136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,248,355],[150,161,248,355],[105,136,137,139,141,146,147,149,150,153,155,156,157,158,160,248,355],[130,248,355],[116,248,355],[115,248,355],[248,294,355],[248,353,355],[248,347,349,355],[248,337,347,348,350,351,352,355],[248,347,355],[248,337,347,355],[248,338,339,340,341,342,343,344,345,346,355],[248,338,342,343,346,347,350,355],[248,338,339,340,341,342,343,344,345,346,347,348,350,351,355],[248,337,338,339,340,341,342,343,344,345,346,355],[221,248,255,355],[248,258,259,260,261,262,355],[248,258,260,355],[248,255,355],[221,248,255,265,355],[221,248,255,265,279,280,355],[248,282,283,284,355],[218,221,248,255,271,272,273,355],[248,266,272,274,278,355],[219,248,255,355],[248,289,355],[248,290,355],[248,296,299,355],[218,248,250,255,314,315,317,355],[248,316,355],[248,320,355],[218,221,222,226,232,247,248,255,256,267,275,280,281,288,319,355],[248,276,355],[248,277,355],[169,248,355],[205,248,355],[206,211,239,248,355],[207,218,219,226,236,247,248,355],[207,208,218,226,248,355],[209,248,355],[210,211,219,227,248,355],[211,236,244,248,355],[212,214,218,226,248,355],[213,248,355],[214,215,248,355],[218,248,355],[216,218,248,355],[205,218,248,355],[218,219,220,236,247,248,355],[218,219,220,233,236,239,248,355],[203,248,252,355],[214,218,221,226,236,247,248,355],[218,219,221,222,226,236,244,247,248,355],[221,223,236,244,247,248,355],[169,170,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,355],[218,224,248,355],[225,247,248,252,355],[214,218,226,236,248,355],[227,248,355],[228,248,355],[205,229,248,355],[230,246,248,252,355],[231,248,355],[232,248,355],[218,233,234,248,355],[233,235,248,250,355],[206,218,236,237,238,239,248,355],[206,236,238,248,355],[236,237,248,355],[239,248,355],[240,248,355],[205,236,248,355],[218,242,243,248,355],[242,243,248,355],[211,226,236,244,248,355],[245,248,355],[226,246,248,355],[206,221,232,247,248,355],[211,248,355],[236,248,249,355],[225,248,250,355],[248,251,355],[206,211,218,220,229,236,247,248,250,252,355],[236,248,253,355],[248,255,322,324,328,329,330,331,332,333,355],[236,248,255,355],[218,248,255,322,324,325,327,334,355],[218,226,236,247,248,255,321,322,323,325,326,327,334,355],[236,248,255,324,325,355],[236,248,255,324,326,355],[248,255,322,324,325,327,334,355],[236,248,255,326,355],[218,226,236,244,248,255,323,325,327,355],[218,248,255,322,324,325,326,327,334,355],[218,236,248,255,322,323,324,325,326,327,334,355],[218,236,248,255,322,324,325,327,334,355],[221,236,248,255,327,355],[105,248,354,355,356],[105,248,287,355,359],[105,248,355,362],[248,355,361,362,363,364,365],[94,95,96,104,248,355],[219,236,248,255,270,355],[221,248,255,275,277,355],[206,219,221,236,248,255,269,355],[248,355,370],[248,355,372,373,374,375,376,377,378,379,380],[248,355,381],[248,355,382],[248,292,298,355],[94,248,355],[248,296,355],[248,293,297,355],[248,303,355],[248,302,303,355],[248,302,355],[248,302,303,304,306,307,310,311,312,313,355],[248,303,307,355],[248,302,303,304,306,307,308,309,355],[248,302,307,355],[248,307,311,355],[248,303,304,305,355],[248,304,355],[248,302,303,307,355],[82,248,355],[80,82,248,355],[71,79,80,81,83,248,355],[69,248,355],[72,77,82,85,248,355],[68,85,248,355],[72,73,76,77,78,85,248,355],[72,73,74,76,77,85,248,355],[69,70,71,72,73,77,78,79,81,82,83,85,248,355],[67,69,70,71,72,73,74,76,77,78,79,80,81,82,83,84,248,355],[67,85,248,355],[72,74,75,77,78,85,248,355],[76,85,248,355],[77,78,82,85,248,355],[70,80,248,355],[248,295,355],[105,106,248,355],[105,248,354,355],[67,248,355],[88,248,355],[86,87,248,355],[85,88,248,355],[180,184,247,248,355],[180,236,247,248,355],[175,248,355],[177,180,244,247,248,355],[226,244,248,355],[175,248,255,355],[177,180,226,247,248,355],[172,173,176,179,206,218,236,247,248,355],[172,178,248,355],[176,180,206,239,247,248,255,355],[206,248,255,355],[196,206,248,255,355],[174,175,248,255,355],[180,248,355],[174,175,176,177,178,179,180,181,182,184,185,186,187,188,189,190,191,192,193,194,195,197,198,199,200,201,202,248,355],[180,187,188,248,355],[178,180,188,189,248,355],[179,248,355],[172,175,180,248,355],[180,184,188,189,248,355],[184,248,355],[178,180,183,247,248,355],[172,177,178,180,184,187,248,355],[206,236,248,355],[175,180,196,206,248,252,255,355],[90,248,355],[93,94,95,96,248,355],[97,100,112,248,355],[113,248,355],[97,98,248,355],[99,248,355],[168,248,355],[100,103,111,114,163,165,248,355],[101,102,248,355],[97,248,355],[97,100,101,248,355],[164,248,355],[97,100,161,248,355],[110,248,355],[100,107,108,109,248,355],[97,100,107,248,355],[162,248,355],[97,112,161,248,355],[89,91,248,355],[97,100,112],[168],[97,105,112,116,127,136,161]],"referencedMap":[[260,1],[258,2],[117,3],[118,3],[119,3],[120,4],[121,3],[122,4],[123,3],[124,3],[125,4],[126,4],[127,5],[128,6],[132,7],[129,6],[133,8],[134,9],[136,10],[135,11],[138,12],[139,13],[140,13],[141,13],[142,12],[143,12],[144,13],[145,13],[146,14],[147,14],[137,14],[148,14],[149,14],[150,14],[151,2],[152,13],[153,13],[154,12],[155,14],[156,13],[157,13],[160,15],[158,13],[159,16],[161,17],[130,2],[131,18],[115,19],[116,20],[292,2],[295,21],[354,22],[350,23],[337,2],[353,24],[346,25],[344,26],[343,26],[342,25],[339,26],[340,25],[348,27],[341,26],[338,25],[345,26],[351,28],[352,29],[347,30],[349,26],[294,2],[256,31],[257,2],[263,32],[259,1],[261,33],[262,1],[264,34],[266,35],[265,31],[267,2],[268,2],[269,2],[281,36],[282,2],[285,37],[283,2],[274,38],[279,39],[286,40],[287,12],[288,2],[275,2],[289,2],[290,41],[291,42],[300,43],[301,2],[316,44],[317,45],[284,2],[318,2],[280,2],[319,46],[320,47],[277,48],[276,49],[169,50],[170,50],[205,51],[206,52],[207,53],[208,54],[209,55],[210,56],[211,57],[212,58],[213,59],[214,60],[215,60],[217,61],[216,62],[218,63],[219,64],[220,65],[204,66],[254,2],[221,67],[222,68],[223,69],[255,70],[224,71],[225,72],[226,73],[227,74],[228,75],[229,76],[230,77],[231,78],[232,79],[233,80],[234,80],[235,81],[236,82],[238,83],[237,84],[239,85],[240,86],[241,87],[242,88],[243,89],[244,90],[245,91],[246,92],[247,93],[248,94],[249,95],[250,96],[251,97],[252,98],[253,99],[334,100],[321,101],[328,102],[324,103],[322,104],[325,105],[329,106],[330,102],[327,107],[326,108],[331,109],[332,110],[333,111],[323,112],[335,2],[95,2],[272,2],[273,2],[336,12],[357,113],[358,12],[360,114],[361,2],[363,115],[366,116],[364,12],[362,12],[365,115],[104,2],[105,117],[367,2],[96,2],[368,2],[271,118],[270,2],[278,119],[369,2],[370,120],[371,121],[315,2],[381,122],[372,123],[373,2],[374,2],[375,2],[376,2],[377,2],[378,2],[379,2],[380,2],[382,2],[383,124],[171,2],[293,2],[94,2],[355,2],[299,125],[106,126],[297,127],[298,128],[304,129],[313,130],[302,2],[303,131],[314,132],[309,133],[310,134],[308,135],[312,136],[306,137],[305,138],[311,139],[307,130],[83,140],[81,141],[82,142],[70,143],[71,141],[78,144],[69,145],[74,146],[84,2],[75,147],[80,148],[85,149],[68,150],[76,151],[77,152],[72,153],[79,140],[73,154],[296,155],[107,156],[356,157],[359,2],[67,158],[90,159],[88,160],[87,2],[86,2],[89,161],[64,2],[65,2],[12,2],[13,2],[15,2],[14,2],[2,2],[16,2],[17,2],[18,2],[19,2],[20,2],[21,2],[22,2],[23,2],[3,2],[4,2],[24,2],[28,2],[25,2],[26,2],[27,2],[29,2],[30,2],[31,2],[5,2],[32,2],[33,2],[34,2],[35,2],[6,2],[39,2],[36,2],[37,2],[38,2],[40,2],[7,2],[41,2],[46,2],[47,2],[42,2],[43,2],[44,2],[45,2],[8,2],[51,2],[48,2],[49,2],[50,2],[52,2],[9,2],[53,2],[54,2],[55,2],[58,2],[56,2],[57,2],[59,2],[60,2],[10,2],[1,2],[11,2],[63,2],[62,2],[61,2],[187,162],[194,163],[186,162],[201,164],[178,165],[177,166],[200,34],[195,167],[198,168],[180,169],[179,170],[175,171],[174,172],[197,173],[176,174],[181,175],[182,2],[185,175],[172,2],[203,176],[202,175],[189,177],[190,178],[192,179],[188,180],[191,181],[196,34],[183,182],[184,183],[193,184],[173,185],[199,186],[91,187],[93,2],[97,188],[66,2],[113,189],[114,190],[167,2],[99,191],[100,192],[98,193],[166,194],[103,195],[101,196],[102,197],[165,198],[164,199],[111,200],[110,201],[108,202],[109,202],[163,203],[162,204],[112,2],[168,196],[92,205]],"exportedModulesMap":[[260,1],[258,2],[117,3],[118,3],[119,3],[120,4],[121,3],[122,4],[123,3],[124,3],[125,4],[126,4],[127,5],[128,6],[132,7],[129,6],[133,8],[134,9],[136,10],[135,11],[138,12],[139,13],[140,13],[141,13],[142,12],[143,12],[144,13],[145,13],[146,14],[147,14],[137,14],[148,14],[149,14],[150,14],[151,2],[152,13],[153,13],[154,12],[155,14],[156,13],[157,13],[160,15],[158,13],[159,16],[161,17],[130,2],[131,18],[115,19],[116,20],[292,2],[295,21],[354,22],[350,23],[337,2],[353,24],[346,25],[344,26],[343,26],[342,25],[339,26],[340,25],[348,27],[341,26],[338,25],[345,26],[351,28],[352,29],[347,30],[349,26],[294,2],[256,31],[257,2],[263,32],[259,1],[261,33],[262,1],[264,34],[266,35],[265,31],[267,2],[268,2],[269,2],[281,36],[282,2],[285,37],[283,2],[274,38],[279,39],[286,40],[287,12],[288,2],[275,2],[289,2],[290,41],[291,42],[300,43],[301,2],[316,44],[317,45],[284,2],[318,2],[280,2],[319,46],[320,47],[277,48],[276,49],[169,50],[170,50],[205,51],[206,52],[207,53],[208,54],[209,55],[210,56],[211,57],[212,58],[213,59],[214,60],[215,60],[217,61],[216,62],[218,63],[219,64],[220,65],[204,66],[254,2],[221,67],[222,68],[223,69],[255,70],[224,71],[225,72],[226,73],[227,74],[228,75],[229,76],[230,77],[231,78],[232,79],[233,80],[234,80],[235,81],[236,82],[238,83],[237,84],[239,85],[240,86],[241,87],[242,88],[243,89],[244,90],[245,91],[246,92],[247,93],[248,94],[249,95],[250,96],[251,97],[252,98],[253,99],[334,100],[321,101],[328,102],[324,103],[322,104],[325,105],[329,106],[330,102],[327,107],[326,108],[331,109],[332,110],[333,111],[323,112],[335,2],[95,2],[272,2],[273,2],[336,12],[357,113],[358,12],[360,114],[361,2],[363,115],[366,116],[364,12],[362,12],[365,115],[104,2],[105,117],[367,2],[96,2],[368,2],[271,118],[270,2],[278,119],[369,2],[370,120],[371,121],[315,2],[381,122],[372,123],[373,2],[374,2],[375,2],[376,2],[377,2],[378,2],[379,2],[380,2],[382,2],[383,124],[171,2],[293,2],[94,2],[355,2],[299,125],[106,126],[297,127],[298,128],[304,129],[313,130],[302,2],[303,131],[314,132],[309,133],[310,134],[308,135],[312,136],[306,137],[305,138],[311,139],[307,130],[83,140],[81,141],[82,142],[70,143],[71,141],[78,144],[69,145],[74,146],[84,2],[75,147],[80,148],[85,149],[68,150],[76,151],[77,152],[72,153],[79,140],[73,154],[296,155],[107,156],[356,157],[359,2],[67,158],[90,159],[88,160],[87,2],[86,2],[89,161],[64,2],[65,2],[12,2],[13,2],[15,2],[14,2],[2,2],[16,2],[17,2],[18,2],[19,2],[20,2],[21,2],[22,2],[23,2],[3,2],[4,2],[24,2],[28,2],[25,2],[26,2],[27,2],[29,2],[30,2],[31,2],[5,2],[32,2],[33,2],[34,2],[35,2],[6,2],[39,2],[36,2],[37,2],[38,2],[40,2],[7,2],[41,2],[46,2],[47,2],[42,2],[43,2],[44,2],[45,2],[8,2],[51,2],[48,2],[49,2],[50,2],[52,2],[9,2],[53,2],[54,2],[55,2],[58,2],[56,2],[57,2],[59,2],[60,2],[10,2],[1,2],[11,2],[63,2],[62,2],[61,2],[187,162],[194,163],[186,162],[201,164],[178,165],[177,166],[200,34],[195,167],[198,168],[180,169],[179,170],[175,171],[174,172],[197,173],[176,174],[181,175],[182,2],[185,175],[172,2],[203,176],[202,175],[189,177],[190,178],[192,179],[188,180],[191,181],[196,34],[183,182],[184,183],[193,184],[173,185],[199,186],[91,187],[93,2],[97,188],[66,2],[113,206],[114,190],[167,2],[99,191],[100,192],[98,207],[166,194],[103,195],[101,196],[102,197],[165,198],[164,199],[111,200],[110,201],[108,202],[109,202],[163,203],[162,208],[168,196],[92,205]],"semanticDiagnosticsPerFile":[260,258,117,118,119,120,121,122,123,124,125,126,127,128,132,129,133,134,136,135,138,139,140,141,142,143,144,145,146,147,137,148,149,150,151,152,153,154,155,156,157,160,158,159,161,130,131,115,116,292,295,354,350,337,353,346,344,343,342,339,340,348,341,338,345,351,352,347,349,294,256,257,263,259,261,262,264,266,265,267,268,269,281,282,285,283,274,279,286,287,288,275,289,290,291,300,301,316,317,284,318,280,319,320,277,276,169,170,205,206,207,208,209,210,211,212,213,214,215,217,216,218,219,220,204,254,221,222,223,255,224,225,226,227,228,229,230,231,232,233,234,235,236,238,237,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,334,321,328,324,322,325,329,330,327,326,331,332,333,323,335,95,272,273,336,357,358,360,361,363,366,364,362,365,104,105,367,96,368,271,270,278,369,370,371,315,381,372,373,374,375,376,377,378,379,380,382,383,171,293,94,355,299,106,297,298,304,313,302,303,314,309,310,308,312,306,305,311,307,83,81,82,70,71,78,69,74,84,75,80,85,68,76,77,72,79,73,296,107,356,359,67,90,88,87,86,89,64,65,12,13,15,14,2,16,17,18,19,20,21,22,23,3,4,24,28,25,26,27,29,30,31,5,32,33,34,35,6,39,36,37,38,40,7,41,46,47,42,43,44,45,8,51,48,49,50,52,9,53,54,55,58,56,57,59,60,10,1,11,63,62,61,187,194,186,201,178,177,200,195,198,180,179,175,174,197,176,181,182,185,172,203,202,189,190,192,188,191,196,183,184,193,173,199,91,93,97,66,113,114,167,99,100,98,166,103,101,102,165,164,111,110,108,109,163,162,112,168,92],"affectedFilesPendingEmit":[66,113,114,167,99,100,98,166,103,101,102,165,164,111,110,108,109,163,162,112,92]},"version":"5.2.2"} \ No newline at end of file diff --git a/turbo.json b/turbo.json index 0f3f9e220..2f6f84d89 100644 --- a/turbo.json +++ b/turbo.json @@ -1,38 +1,66 @@ { "$schema": "https://turbo.build/schema.json", - "globalDependencies": ["**/.env"], + "globalDependencies": [ + "**/.env" + ], "pipeline": { "topo": { - "dependsOn": ["^topo"] + "dependsOn": [ + "^topo" + ] }, "build": { - "dependsOn": ["^build"], - "outputs": ["dist/**", ".next/**", "next-env.d.ts", "!.next/cache/**"] + "dependsOn": [ + "^build" + ], + "outputs": [ + "dist/**", + ".next/**", + "next-env.d.ts", + "!.next/cache/**" + ] }, "dev": { "cache": false, "persistent": true }, "web#dev": { - "dependsOn": ["@ufb/tailwind#build"] + "dependsOn": [ + "@ufb/tailwind#build" + ] }, "@ufb/tailwind#build": { - "outputs": ["dist/**"] + "outputs": [ + "dist/**" + ] }, "format": { - "outputs": ["node_modules/.cache/.prettiercache"], + "outputs": [ + "node_modules/.cache/.prettiercache" + ], "outputMode": "new-only" }, "lint": { - "dependsOn": ["topo"], - "outputs": ["node_modules/.cache/.eslintcache"] + "dependsOn": [ + "topo" + ], + "outputs": [ + "node_modules/.cache/.eslintcache" + ] }, "typecheck": { - "dependsOn": ["topo"], - "outputs": ["node_modules/.cache/tsbuildinfo.json"] + "dependsOn": [ + "topo" + ], + "outputs": [ + "node_modules/.cache/tsbuildinfo.json" + ] }, "test": { - "dependsOn": ["topo", "@ufb/shared#build"] + "dependsOn": [ + "topo", + "@ufb/shared#build" + ] }, "clean": { "cache": false @@ -67,4 +95,4 @@ "BASE_URL", "NEXT_PUBLIC_MAX_DAYS" ] -} +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 4917a8d79..31d293f75 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2107,6 +2107,14 @@ path-to-regexp "3.2.0" tslib "2.6.2" +"@nestjs/schedule@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@nestjs/schedule/-/schedule-4.0.0.tgz#522fa0c79a2b44a66aab16a46bdf4c11ae73f3c3" + integrity sha512-zz4h54m/F/1qyQKvMJCRphmuwGqJltDAkFxUXCVqJBXEs5kbPt93Pza3heCQOcMH22MZNhGlc9DmDMLXVHmgVQ== + dependencies: + cron "3.1.3" + uuid "9.0.1" + "@nestjs/schematics@^10.0.1", "@nestjs/schematics@^10.0.2": version "10.0.2" resolved "https://registry.yarnpkg.com/@nestjs/schematics/-/schematics-10.0.2.tgz#d782ac1a6e9372d2f7ede1e2077c3a7484714923" @@ -3159,6 +3167,11 @@ "@types/koa-compose" "*" "@types/node" "*" +"@types/luxon@~3.3.0": + version "3.3.5" + resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-3.3.5.tgz#ffdcec196994998dbef6284523b3ac88a9e6c45f" + integrity sha512-1cyf6Ge/94zlaWIZA2ei1pE6SZ8xpad2hXaYa5JEFiaUH0YS494CZwyi4MXNpXD9oEuv6ZH0Bmh0e7F9sPhmZA== + "@types/mime@*": version "3.0.2" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.2.tgz#c1ae807f13d308ee7511a5b81c74f327028e66e8" @@ -5093,6 +5106,14 @@ create-require@^1.1.0: resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== +cron@3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/cron/-/cron-3.1.3.tgz#4eac8f6691ce7e24c8e89b5317b8097d6f2d0053" + integrity sha512-KVxeKTKYj2eNzN4ElnT6nRSbjbfhyxR92O/Jdp6SH3pc05CDJws59jBrZWEMQlxevCiE6QUTrXy+Im3vC3oD3A== + dependencies: + "@types/luxon" "~3.3.0" + luxon "~3.4.0" + cross-spawn@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" @@ -8954,6 +8975,11 @@ lru-cache@^8.0.0: resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.0.1.tgz#0a3be479df549cca0e5d693ac402ff19537a6b7a" integrity sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g== +luxon@~3.4.0: + version "3.4.4" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.4.4.tgz#cf20dc27dc532ba41a169c43fdcc0063601577af" + integrity sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA== + lz-string@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" @@ -13229,16 +13255,16 @@ uuid@9.0.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5" integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg== +uuid@9.0.1, uuid@^9.0.0: + version "9.0.1" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" + integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== + uuid@^8.3.0: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== -uuid@^9.0.0: - version "9.0.1" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" - integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== - v8-compile-cache-lib@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" From 09f5dc47747604686ca60702c62b225475e6963b Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 30 Nov 2023 10:34:21 +0900 Subject: [PATCH 3/5] feat: issue & feedback-issue statistics (#87) * feat: issue statistics - count - count-by-date * feat: issue statistics count-by-status * feat: feedback issue statsitics * feat: feedback issue statistics migration * feat: update stats count on feedback creation * feat: update stats count on issue creation * feat: update stats count on adding issue on feedback --- apps/api/src/app.module.ts | 4 + .../1701090850194-issue-statistics.ts | 39 +++ ...1701234953280-feedback-issue-statistics.ts | 41 +++ .../src/domains/feedback/feedback.module.ts | 4 + .../domains/feedback/feedback.service.spec.ts | 33 ++ .../src/domains/feedback/feedback.service.ts | 17 + .../domains/migration/migration.controller.ts | 24 ++ .../src/domains/migration/migration.module.ts | 4 + .../src/domains/project/issue/issue.entity.ts | 7 + .../src/domains/project/issue/issue.module.ts | 3 +- .../project/issue/issue.service.spec.ts | 11 +- .../domains/project/issue/issue.service.ts | 13 +- .../domains/project/project/project.entity.ts | 6 + .../domains/project/project/project.module.ts | 4 + .../project/project/project.service.ts | 8 + .../dtos/get-count-by-date-by-issue.dto.ts | 21 ++ .../statistics/feedback-issue/dtos/index.ts | 17 + ...ind-count-by-date-by-issue-response.dto.ts | 33 ++ .../feedback-issue/dtos/responses/index.ts | 17 + .../feedback-issue/dtos/update-count.dto.ts | 20 ++ ...edback-issue-statistics.controller.spec.ts | 67 ++++ .../feedback-issue-statistics.controller.ts | 46 +++ .../feedback-issue-statistics.entity.ts | 62 ++++ .../feedback-issue-statistics.module.ts | 54 +++ .../feedback-issue-statistics.service.spec.ts | 317 ++++++++++++++++++ .../feedback-issue-statistics.service.ts | 211 ++++++++++++ .../domains/statistics/feedback/dtos/index.ts | 1 + .../feedback/dtos/update-count.dto.ts | 20 ++ .../feedback/feedback-statistics.entity.ts | 2 +- .../feedback-statistics.service.spec.ts | 50 ++- .../feedback/feedback-statistics.service.ts | 32 ++ .../dtos/get-count-by-date-by-channel.dto.ts | 21 ++ .../statistics/issue/dtos/get-count.dto.ts | 20 ++ .../domains/statistics/issue/dtos/index.ts | 18 + .../find-count-by-date-response.dto.ts | 32 ++ .../find-count-by-status-response.dto.ts | 32 ++ .../dtos/responses/find-count-response.dto.ts | 29 ++ .../statistics/issue/dtos/responses/index.ts | 19 ++ .../statistics/issue/dtos/update-count.dto.ts | 20 ++ .../issue/issue-statistics.controller.spec.ts | 72 ++++ .../issue/issue-statistics.controller.ts | 81 +++++ .../issue/issue-statistics.entity.ts | 62 ++++ .../issue/issue-statistics.module.ts | 56 ++++ .../issue/issue-statistics.service.spec.ts | 299 +++++++++++++++++ .../issue/issue-statistics.service.ts | 211 ++++++++++++ ...back-issue-statistics.service.providers.ts | 45 +++ .../providers/feedback.service.providers.ts | 4 + .../issue-statistics.service.providers.ts | 40 +++ .../providers/issue.service.providers.ts | 2 + .../providers/project.service.providers.ts | 4 + 50 files changed, 2250 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/configs/modules/typeorm-config/migrations/1701090850194-issue-statistics.ts create mode 100644 apps/api/src/configs/modules/typeorm-config/migrations/1701234953280-feedback-issue-statistics.ts create mode 100644 apps/api/src/domains/statistics/feedback-issue/dtos/get-count-by-date-by-issue.dto.ts create mode 100644 apps/api/src/domains/statistics/feedback-issue/dtos/index.ts create mode 100644 apps/api/src/domains/statistics/feedback-issue/dtos/responses/find-count-by-date-by-issue-response.dto.ts create mode 100644 apps/api/src/domains/statistics/feedback-issue/dtos/responses/index.ts create mode 100644 apps/api/src/domains/statistics/feedback-issue/dtos/update-count.dto.ts create mode 100644 apps/api/src/domains/statistics/feedback-issue/feedback-issue-statistics.controller.spec.ts create mode 100644 apps/api/src/domains/statistics/feedback-issue/feedback-issue-statistics.controller.ts create mode 100644 apps/api/src/domains/statistics/feedback-issue/feedback-issue-statistics.entity.ts create mode 100644 apps/api/src/domains/statistics/feedback-issue/feedback-issue-statistics.module.ts create mode 100644 apps/api/src/domains/statistics/feedback-issue/feedback-issue-statistics.service.spec.ts create mode 100644 apps/api/src/domains/statistics/feedback-issue/feedback-issue-statistics.service.ts create mode 100644 apps/api/src/domains/statistics/feedback/dtos/update-count.dto.ts create mode 100644 apps/api/src/domains/statistics/issue/dtos/get-count-by-date-by-channel.dto.ts create mode 100644 apps/api/src/domains/statistics/issue/dtos/get-count.dto.ts create mode 100644 apps/api/src/domains/statistics/issue/dtos/index.ts create mode 100644 apps/api/src/domains/statistics/issue/dtos/responses/find-count-by-date-response.dto.ts create mode 100644 apps/api/src/domains/statistics/issue/dtos/responses/find-count-by-status-response.dto.ts create mode 100644 apps/api/src/domains/statistics/issue/dtos/responses/find-count-response.dto.ts create mode 100644 apps/api/src/domains/statistics/issue/dtos/responses/index.ts create mode 100644 apps/api/src/domains/statistics/issue/dtos/update-count.dto.ts create mode 100644 apps/api/src/domains/statistics/issue/issue-statistics.controller.spec.ts create mode 100644 apps/api/src/domains/statistics/issue/issue-statistics.controller.ts create mode 100644 apps/api/src/domains/statistics/issue/issue-statistics.entity.ts create mode 100644 apps/api/src/domains/statistics/issue/issue-statistics.module.ts create mode 100644 apps/api/src/domains/statistics/issue/issue-statistics.service.spec.ts create mode 100644 apps/api/src/domains/statistics/issue/issue-statistics.service.ts create mode 100644 apps/api/src/test-utils/providers/feedback-issue-statistics.service.providers.ts create mode 100644 apps/api/src/test-utils/providers/issue-statistics.service.providers.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index f6efbd53e..164f905bc 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -47,7 +47,9 @@ import { IssueModule } from './domains/project/issue/issue.module'; import { MemberModule } from './domains/project/member/member.module'; import { ProjectModule } from './domains/project/project/project.module'; import { RoleModule } from './domains/project/role/role.module'; +import { FeedbackIssueStatisticsModule } from './domains/statistics/feedback-issue/feedback-issue-statistics.module'; import { FeedbackStatisticsModule } from './domains/statistics/feedback/feedback-statistics.module'; +import { IssueStatisticsModule } from './domains/statistics/issue/issue-statistics.module'; import { TenantModule } from './domains/tenant/tenant.module'; import { UserModule } from './domains/user/user.module'; @@ -69,6 +71,8 @@ const domainModules = [ MemberModule, HistoryModule, FeedbackStatisticsModule, + IssueStatisticsModule, + FeedbackIssueStatisticsModule, ]; @Module({ diff --git a/apps/api/src/configs/modules/typeorm-config/migrations/1701090850194-issue-statistics.ts b/apps/api/src/configs/modules/typeorm-config/migrations/1701090850194-issue-statistics.ts new file mode 100644 index 000000000..2f9db3372 --- /dev/null +++ b/apps/api/src/configs/modules/typeorm-config/migrations/1701090850194-issue-statistics.ts @@ -0,0 +1,39 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class IssueStatistics1701090850194 implements MigrationInterface { + name = 'IssueStatistics1701090850194'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE \`issue_statistics\` (\`id\` int NOT NULL AUTO_INCREMENT, \`date\` date NOT NULL, \`count\` int NOT NULL DEFAULT '0', \`project_id\` int NULL, UNIQUE INDEX \`project-date-unique\` (\`project_id\`, \`date\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`, + ); + await queryRunner.query( + `ALTER TABLE \`issue_statistics\` ADD CONSTRAINT \`FK_86e6ee861d8895004659b4fe076\` FOREIGN KEY (\`project_id\`) REFERENCES \`projects\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`issue_statistics\` DROP FOREIGN KEY \`FK_86e6ee861d8895004659b4fe076\``, + ); + await queryRunner.query( + `DROP INDEX \`project-date-unique\` ON \`issue_statistics\``, + ); + await queryRunner.query(`DROP TABLE \`issue_statistics\``); + } +} diff --git a/apps/api/src/configs/modules/typeorm-config/migrations/1701234953280-feedback-issue-statistics.ts b/apps/api/src/configs/modules/typeorm-config/migrations/1701234953280-feedback-issue-statistics.ts new file mode 100644 index 000000000..5b84f6b98 --- /dev/null +++ b/apps/api/src/configs/modules/typeorm-config/migrations/1701234953280-feedback-issue-statistics.ts @@ -0,0 +1,41 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class FeedbackIssueStatistics1701234953280 + implements MigrationInterface +{ + name = 'FeedbackIssueStatistics1701234953280'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE \`feedback_issue_statistics\` (\`id\` int NOT NULL AUTO_INCREMENT, \`date\` date NOT NULL, \`feedback_count\` int NOT NULL DEFAULT '0', \`issue_id\` int NULL, UNIQUE INDEX \`issue-date-unique\` (\`issue_id\`, \`date\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`, + ); + await queryRunner.query( + `ALTER TABLE \`feedback_issue_statistics\` ADD CONSTRAINT \`FK_f90e8299de4ac2a05d3b6cbb2a6\` FOREIGN KEY (\`issue_id\`) REFERENCES \`issues\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`feedback_issue_statistics\` DROP FOREIGN KEY \`FK_f90e8299de4ac2a05d3b6cbb2a6\``, + ); + await queryRunner.query( + `DROP INDEX \`issue-date-unique\` ON \`feedback_issue_statistics\``, + ); + await queryRunner.query(`DROP TABLE \`feedback_issue_statistics\``); + } +} diff --git a/apps/api/src/domains/feedback/feedback.module.ts b/apps/api/src/domains/feedback/feedback.module.ts index f953b6bbe..7a767295b 100644 --- a/apps/api/src/domains/feedback/feedback.module.ts +++ b/apps/api/src/domains/feedback/feedback.module.ts @@ -29,6 +29,8 @@ import { IssueEntity } from '../project/issue/issue.entity'; import { IssueModule } from '../project/issue/issue.module'; import { ProjectEntity } from '../project/project/project.entity'; import { ProjectModule } from '../project/project/project.module'; +import { FeedbackIssueStatisticsModule } from '../statistics/feedback-issue/feedback-issue-statistics.module'; +import { FeedbackStatisticsModule } from '../statistics/feedback/feedback-statistics.module'; import { FeedbackController } from './feedback.controller'; import { FeedbackEntity } from './feedback.entity'; import { FeedbackMySQLService } from './feedback.mysql.service'; @@ -52,6 +54,8 @@ import { FeedbackService } from './feedback.service'; IssueModule, forwardRef(() => ProjectModule), HistoryModule, + FeedbackStatisticsModule, + FeedbackIssueStatisticsModule, ], providers: [ FeedbackService, diff --git a/apps/api/src/domains/feedback/feedback.service.spec.ts b/apps/api/src/domains/feedback/feedback.service.spec.ts index 5bffadac9..c08e8da4e 100644 --- a/apps/api/src/domains/feedback/feedback.service.spec.ts +++ b/apps/api/src/domains/feedback/feedback.service.spec.ts @@ -28,6 +28,7 @@ import { import { OpensearchRepository } from '@/common/repositories'; import { createFieldDto, getRandomValue } from '@/test-utils/fixtures'; import { + createQueryBuilder, MockOpensearchRepository, TestConfig, } from '@/test-utils/util-functions'; @@ -36,6 +37,8 @@ import { ChannelEntity } from '../channel/channel/channel.entity'; import { RESERVED_FIELD_KEYS } from '../channel/field/field.constants'; import { FieldEntity } from '../channel/field/field.entity'; import { IssueEntity } from '../project/issue/issue.entity'; +import { FeedbackStatisticsEntity } from '../statistics/feedback/feedback-statistics.entity'; +import { IssueStatisticsEntity } from '../statistics/issue/issue-statistics.entity'; import { CreateFeedbackDto, FindFeedbacksByChannelIdDto } from './dtos'; import { FeedbackEntity } from './feedback.entity'; import { FeedbackService } from './feedback.service'; @@ -70,6 +73,8 @@ describe('FeedbackService Test Suite', () => { let issueRepo: Repository; let channelRepo: Repository; let osRepo: OpensearchRepository; + let feedbackStatsRepo: Repository; + let issueStatsRepo: Repository; beforeEach(async () => { const module = await Test.createTestingModule({ imports: [TestConfig], @@ -83,6 +88,10 @@ describe('FeedbackService Test Suite', () => { issueRepo = module.get(getRepositoryToken(IssueEntity)); channelRepo = module.get(getRepositoryToken(ChannelEntity)); osRepo = module.get(OpensearchRepository); + feedbackStatsRepo = module.get( + getRepositoryToken(FeedbackStatisticsEntity), + ); + issueStatsRepo = module.get(getRepositoryToken(IssueStatisticsEntity)); }); describe('create', () => { @@ -95,6 +104,9 @@ describe('FeedbackService Test Suite', () => { id: faker.number.int(), rawData: dto.data, } as FeedbackEntity); + jest + .spyOn(feedbackStatsRepo, 'findOne') + .mockResolvedValue({ count: 1 } as FeedbackStatisticsEntity); await feedbackService.create(dto); @@ -315,11 +327,20 @@ describe('FeedbackService Test Suite', () => { jest.spyOn(issueRepo, 'findOneBy').mockResolvedValueOnce(null); jest.spyOn(issueRepo, 'save').mockResolvedValue({ id: faker.number.int(), + project: { + id: faker.number.int(), + }, } as IssueEntity); jest.spyOn(feedbackRepo, 'findOne').mockResolvedValue({ id: feedbackId, issues: [], } as FeedbackEntity); + jest + .spyOn(feedbackStatsRepo, 'findOne') + .mockResolvedValue({ count: 1 } as FeedbackStatisticsEntity); + jest + .spyOn(issueStatsRepo, 'createQueryBuilder') + .mockImplementation(() => createQueryBuilder); clsService.set = jest.fn(); await feedbackService.create(dto); @@ -354,6 +375,9 @@ describe('FeedbackService Test Suite', () => { id: feedbackId, issues: [], } as FeedbackEntity); + jest + .spyOn(feedbackStatsRepo, 'findOne') + .mockResolvedValue({ count: 1 } as FeedbackStatisticsEntity); clsService.set = jest.fn(); await feedbackService.create(dto); @@ -385,11 +409,20 @@ describe('FeedbackService Test Suite', () => { jest.spyOn(issueRepo, 'findOneBy').mockResolvedValueOnce(null); jest.spyOn(issueRepo, 'save').mockResolvedValue({ id: faker.number.int(), + project: { + id: faker.number.int(), + }, } as IssueEntity); jest.spyOn(feedbackRepo, 'findOne').mockResolvedValue({ id: feedbackId, issues: [], } as FeedbackEntity); + jest + .spyOn(feedbackStatsRepo, 'findOne') + .mockResolvedValue({ count: 1 } as FeedbackStatisticsEntity); + jest + .spyOn(issueStatsRepo, 'createQueryBuilder') + .mockImplementation(() => createQueryBuilder); clsService.set = jest.fn(); await feedbackService.create(dto); diff --git a/apps/api/src/domains/feedback/feedback.service.ts b/apps/api/src/domains/feedback/feedback.service.ts index 974d59399..b23955641 100644 --- a/apps/api/src/domains/feedback/feedback.service.ts +++ b/apps/api/src/domains/feedback/feedback.service.ts @@ -26,6 +26,7 @@ import { ConfigService } from '@nestjs/config'; import dayjs from 'dayjs'; import * as ExcelJS from 'exceljs'; import * as fastcsv from 'fast-csv'; +import { DateTime } from 'luxon'; import type { IPaginationMeta, Pagination } from 'nestjs-typeorm-paginate'; import { Transactional } from 'typeorm-transactional'; @@ -40,6 +41,8 @@ import type { FieldEntity } from '../channel/field/field.entity'; import { FieldService } from '../channel/field/field.service'; import { OptionService } from '../channel/option/option.service'; import { IssueService } from '../project/issue/issue.service'; +import { FeedbackIssueStatisticsService } from '../statistics/feedback-issue/feedback-issue-statistics.service'; +import { FeedbackStatisticsService } from '../statistics/feedback/feedback-statistics.service'; import type { CountByProjectIdDto, FindFeedbacksByChannelIdDto, @@ -67,6 +70,8 @@ export class FeedbackService { private readonly optionService: OptionService, private readonly channelService: ChannelService, private readonly configService: ConfigService, + private readonly feedbackStatisticsService: FeedbackStatisticsService, + private readonly feedbackIssueStatisticsService: FeedbackIssueStatisticsService, ) {} private validateQuery( @@ -328,6 +333,12 @@ export class FeedbackService { data: feedbackData, }); + await this.feedbackStatisticsService.updateCount({ + channelId, + date: DateTime.utc().toJSDate(), + count: 1, + }); + if (issueNames) { for (const issueName of issueNames) { let issue = await this.issueService.findByName({ name: issueName }); @@ -463,6 +474,12 @@ export class FeedbackService { async addIssue(dto: AddIssueDto) { await this.feedbackMySQLService.addIssue(dto); + await this.feedbackIssueStatisticsService.updateFeedbackCount({ + issueId: dto.issueId, + date: DateTime.utc().toJSDate(), + feedbackCount: 1, + }); + if (this.configService.get('opensearch.use')) { await this.feedbackOSService.upsertFeedbackItem({ channelId: dto.channelId, diff --git a/apps/api/src/domains/migration/migration.controller.ts b/apps/api/src/domains/migration/migration.controller.ts index 7f9c15718..a321760c9 100644 --- a/apps/api/src/domains/migration/migration.controller.ts +++ b/apps/api/src/domains/migration/migration.controller.ts @@ -15,7 +15,9 @@ */ import { Body, Controller, Param, ParseIntPipe, Post } from '@nestjs/common'; +import { FeedbackIssueStatisticsService } from '../statistics/feedback-issue/feedback-issue-statistics.service'; import { FeedbackStatisticsService } from '../statistics/feedback/feedback-statistics.service'; +import { IssueStatisticsService } from '../statistics/issue/issue-statistics.service'; import { MigrationService } from './migration.service'; @Controller('/migration') @@ -23,6 +25,8 @@ export class MigrationController { constructor( private readonly migrationService: MigrationService, private readonly feedbackStatisticsService: FeedbackStatisticsService, + private readonly issueStatisticsService: IssueStatisticsService, + private readonly feedbackIssueStatisticsService: FeedbackIssueStatisticsService, ) {} @Post('/channels/:channelId') @@ -39,4 +43,24 @@ export class MigrationController { body.day, ); } + + @Post('/statistics/issue') + async migrateIssueStatistics( + @Body() body: { projectId: number; day: number }, + ) { + await this.issueStatisticsService.createIssueStatistics( + body.projectId, + body.day, + ); + } + + @Post('/statistics/feedback-issue') + async migrateFeedbackIssueStatistics( + @Body() body: { projectId: number; day: number }, + ) { + await this.feedbackIssueStatisticsService.createFeedbackIssueStatistics( + body.projectId, + body.day, + ); + } } diff --git a/apps/api/src/domains/migration/migration.module.ts b/apps/api/src/domains/migration/migration.module.ts index 7f0dbb7c1..28f567949 100644 --- a/apps/api/src/domains/migration/migration.module.ts +++ b/apps/api/src/domains/migration/migration.module.ts @@ -25,7 +25,9 @@ import { FieldModule } from '../channel/field/field.module'; import { OptionEntity } from '../channel/option/option.entity'; import { OptionModule } from '../channel/option/option.module'; import { FeedbackEntity } from '../feedback/feedback.entity'; +import { FeedbackIssueStatisticsModule } from '../statistics/feedback-issue/feedback-issue-statistics.module'; import { FeedbackStatisticsModule } from '../statistics/feedback/feedback-statistics.module'; +import { IssueStatisticsModule } from '../statistics/issue/issue-statistics.module'; import { MigrationController } from './migration.controller'; import { MigrationService } from './migration.service'; @@ -40,6 +42,8 @@ import { MigrationService } from './migration.service'; FieldModule, OptionModule, FeedbackStatisticsModule, + IssueStatisticsModule, + FeedbackIssueStatisticsModule, ], providers: [MigrationService, OpensearchRepository], controllers: [MigrationController], diff --git a/apps/api/src/domains/project/issue/issue.entity.ts b/apps/api/src/domains/project/issue/issue.entity.ts index 722a19773..5144cff40 100644 --- a/apps/api/src/domains/project/issue/issue.entity.ts +++ b/apps/api/src/domains/project/issue/issue.entity.ts @@ -19,12 +19,14 @@ import { Index, ManyToMany, ManyToOne, + OneToMany, Relation, Unique, } from 'typeorm'; import { CommonEntity } from '@/common/entities'; import { IssueStatusEnum } from '@/common/enums'; +import { FeedbackIssueStatisticsEntity } from '@/domains/statistics/feedback-issue/feedback-issue-statistics.entity'; import { FeedbackEntity } from '../../feedback/feedback.entity'; import { ProjectEntity } from '../project/project.entity'; @@ -59,6 +61,11 @@ export class IssueEntity extends CommonEntity { }) project: Relation; + @OneToMany(() => FeedbackIssueStatisticsEntity, (stats) => stats.issue, { + cascade: true, + }) + stats: Relation[]; + static from({ name, projectId }: { name: string; projectId: number }) { const issue = new IssueEntity(); issue.name = name; diff --git a/apps/api/src/domains/project/issue/issue.module.ts b/apps/api/src/domains/project/issue/issue.module.ts index 1ea85d963..63106b362 100644 --- a/apps/api/src/domains/project/issue/issue.module.ts +++ b/apps/api/src/domains/project/issue/issue.module.ts @@ -16,12 +16,13 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { IssueStatisticsModule } from '@/domains/statistics/issue/issue-statistics.module'; import { IssueController } from './issue.controller'; import { IssueEntity } from './issue.entity'; import { IssueService } from './issue.service'; @Module({ - imports: [TypeOrmModule.forFeature([IssueEntity])], + imports: [TypeOrmModule.forFeature([IssueEntity]), IssueStatisticsModule], providers: [IssueService], controllers: [IssueController], exports: [IssueService], diff --git a/apps/api/src/domains/project/issue/issue.service.spec.ts b/apps/api/src/domains/project/issue/issue.service.spec.ts index 5d6fca6c1..4c9b9afe5 100644 --- a/apps/api/src/domains/project/issue/issue.service.spec.ts +++ b/apps/api/src/domains/project/issue/issue.service.spec.ts @@ -20,6 +20,7 @@ import type { Repository } from 'typeorm'; import { Like } from 'typeorm'; import type { TimeRange } from '@/common/dtos'; +import { IssueStatisticsEntity } from '@/domains/statistics/issue/issue-statistics.entity'; import { createQueryBuilder, TestConfig } from '@/test-utils/util-functions'; import { IssueServiceProviders } from '../../../test-utils/providers/issue.service.providers'; import { @@ -37,6 +38,7 @@ import { IssueService } from './issue.service'; describe('IssueService test suite', () => { let issueService: IssueService; let issueRepo: Repository; + let issueStatsRepo: Repository; beforeEach(async () => { const module = await Test.createTestingModule({ @@ -46,6 +48,7 @@ describe('IssueService test suite', () => { issueService = module.get(IssueService); issueRepo = module.get(getRepositoryToken(IssueEntity)); + issueStatsRepo = module.get(getRepositoryToken(IssueStatisticsEntity)); }); describe('create', () => { @@ -59,7 +62,13 @@ describe('IssueService test suite', () => { it('creating an issue succeeds with valid inputs', async () => { dto.name = faker.string.sample(); jest.spyOn(issueRepo, 'findOneBy').mockResolvedValue(null as IssueEntity); - jest.spyOn(issueRepo, 'save').mockResolvedValue({} as IssueEntity); + jest.spyOn(issueRepo, 'save').mockResolvedValue({ + id: faker.number.int(), + project: { id: faker.number.int() }, + } as IssueEntity); + jest + .spyOn(issueStatsRepo, 'createQueryBuilder') + .mockImplementation(() => createQueryBuilder); await issueService.create(dto); diff --git a/apps/api/src/domains/project/issue/issue.service.ts b/apps/api/src/domains/project/issue/issue.service.ts index 72ad081e9..a14c9cdd4 100644 --- a/apps/api/src/domains/project/issue/issue.service.ts +++ b/apps/api/src/domains/project/issue/issue.service.ts @@ -15,6 +15,7 @@ */ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { DateTime } from 'luxon'; import { paginate } from 'nestjs-typeorm-paginate'; import type { FindManyOptions, FindOptionsWhere } from 'typeorm'; import { In, Like, Not, Raw, Repository } from 'typeorm'; @@ -22,6 +23,7 @@ import { Transactional } from 'typeorm-transactional'; import type { TimeRange } from '@/common/dtos'; import type { CountByProjectIdDto } from '@/domains/feedback/dtos'; +import { IssueStatisticsService } from '@/domains/statistics/issue/issue-statistics.service'; import type { FindByIssueIdDto, FindIssuesByProjectIdDto } from './dtos'; import { CreateIssueDto, UpdateIssueDto } from './dtos'; import { @@ -36,6 +38,7 @@ export class IssueService { constructor( @InjectRepository(IssueEntity) private readonly repository: Repository, + private readonly issueStatisticsService: IssueStatisticsService, ) {} @Transactional() @@ -49,7 +52,15 @@ export class IssueService { if (duplicateIssue) throw new IssueNameDuplicatedException(); - return await this.repository.save(issue); + const savedIssue = await this.repository.save(issue); + + await this.issueStatisticsService.updateCount({ + projectId: savedIssue.project.id, + date: DateTime.utc().toJSDate(), + count: 1, + }); + + return savedIssue; } async findIssuesByProjectId(dto: FindIssuesByProjectIdDto) { diff --git a/apps/api/src/domains/project/project/project.entity.ts b/apps/api/src/domains/project/project/project.entity.ts index 8c74adea1..ec3cc8b25 100644 --- a/apps/api/src/domains/project/project/project.entity.ts +++ b/apps/api/src/domains/project/project/project.entity.ts @@ -27,6 +27,7 @@ import { TimezoneOffset } from '@ufb/shared'; import { CommonEntity } from '@/common/entities'; import type { ApiKeyEntity } from '@/domains/project/api-key/api-key.entity'; import type { IssueTrackerEntity } from '@/domains/project/issue-tracker/issue-tracker.entity'; +import { IssueStatisticsEntity } from '@/domains/statistics/issue/issue-statistics.entity'; import { TenantEntity } from '@/domains/tenant/tenant.entity'; import { ChannelEntity } from '../../channel/channel/channel.entity'; import { IssueEntity } from '../issue/issue.entity'; @@ -73,6 +74,11 @@ export class ProjectEntity extends CommonEntity { }) tenant: Relation; + @OneToMany(() => IssueStatisticsEntity, (stats) => stats.project, { + cascade: true, + }) + stats: Relation[]; + static from({ tenantId, name, diff --git a/apps/api/src/domains/project/project/project.module.ts b/apps/api/src/domains/project/project/project.module.ts index 99e6cd4e5..99a45fe0b 100644 --- a/apps/api/src/domains/project/project/project.module.ts +++ b/apps/api/src/domains/project/project/project.module.ts @@ -24,7 +24,9 @@ import { OptionEntity } from '@/domains/channel/option/option.entity'; import { OptionModule } from '@/domains/channel/option/option.module'; import { FeedbackEntity } from '@/domains/feedback/feedback.entity'; import { FeedbackModule } from '@/domains/feedback/feedback.module'; +import { FeedbackIssueStatisticsModule } from '@/domains/statistics/feedback-issue/feedback-issue-statistics.module'; import { FeedbackStatisticsModule } from '@/domains/statistics/feedback/feedback-statistics.module'; +import { IssueStatisticsModule } from '@/domains/statistics/issue/issue-statistics.module'; import { TenantModule } from '@/domains/tenant/tenant.module'; import { ApiKeyModule } from '../api-key/api-key.module'; import { IssueTrackerModule } from '../issue-tracker/issue-tracker.module'; @@ -56,6 +58,8 @@ import { ProjectService } from './project.service'; MemberModule, IssueTrackerModule, FeedbackStatisticsModule, + IssueStatisticsModule, + FeedbackIssueStatisticsModule, ], providers: [ProjectService, OpensearchRepository], controllers: [ProjectController], diff --git a/apps/api/src/domains/project/project/project.service.ts b/apps/api/src/domains/project/project/project.service.ts index 7266b74b7..0448ae0a8 100644 --- a/apps/api/src/domains/project/project/project.service.ts +++ b/apps/api/src/domains/project/project/project.service.ts @@ -21,7 +21,9 @@ import { Like, Not, Repository } from 'typeorm'; import { Transactional } from 'typeorm-transactional'; import { OpensearchRepository } from '@/common/repositories'; +import { FeedbackIssueStatisticsService } from '@/domains/statistics/feedback-issue/feedback-issue-statistics.service'; import { FeedbackStatisticsService } from '@/domains/statistics/feedback/feedback-statistics.service'; +import { IssueStatisticsService } from '@/domains/statistics/issue/issue-statistics.service'; import { TenantService } from '@/domains/tenant/tenant.service'; import { UserTypeEnum } from '@/domains/user/entities/enums'; import { ChannelEntity } from '../../channel/channel/channel.entity'; @@ -55,6 +57,8 @@ export class ProjectService { private readonly issueTrackerService: IssueTrackerService, private readonly configService: ConfigService, private readonly feedbackStatisticsService: FeedbackStatisticsService, + private readonly issueStatisticsService: IssueStatisticsService, + private readonly feedbackIssueStatisticsService: FeedbackIssueStatisticsService, ) {} async checkName(name: string) { @@ -144,6 +148,10 @@ export class ProjectService { } await this.feedbackStatisticsService.addCronJobByProjectId(savedProject.id); + await this.issueStatisticsService.addCronJobByProjectId(savedProject.id); + await this.feedbackIssueStatisticsService.addCronJobByProjectId( + savedProject.id, + ); return savedProject; } diff --git a/apps/api/src/domains/statistics/feedback-issue/dtos/get-count-by-date-by-issue.dto.ts b/apps/api/src/domains/statistics/feedback-issue/dtos/get-count-by-date-by-issue.dto.ts new file mode 100644 index 000000000..612b19e78 --- /dev/null +++ b/apps/api/src/domains/statistics/feedback-issue/dtos/get-count-by-date-by-issue.dto.ts @@ -0,0 +1,21 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +export class GetCountByDateByIssueDto { + from: Date; + to: Date; + interval: 'day' | 'week' | 'month'; + issueIds: number[]; +} diff --git a/apps/api/src/domains/statistics/feedback-issue/dtos/index.ts b/apps/api/src/domains/statistics/feedback-issue/dtos/index.ts new file mode 100644 index 000000000..22d744a0c --- /dev/null +++ b/apps/api/src/domains/statistics/feedback-issue/dtos/index.ts @@ -0,0 +1,17 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +export { GetCountByDateByIssueDto } from './get-count-by-date-by-issue.dto'; +export { UpdateFeedbackCountDto } from './update-count.dto'; diff --git a/apps/api/src/domains/statistics/feedback-issue/dtos/responses/find-count-by-date-by-issue-response.dto.ts b/apps/api/src/domains/statistics/feedback-issue/dtos/responses/find-count-by-date-by-issue-response.dto.ts new file mode 100644 index 000000000..a7a19d96d --- /dev/null +++ b/apps/api/src/domains/statistics/feedback-issue/dtos/responses/find-count-by-date-by-issue-response.dto.ts @@ -0,0 +1,33 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, plainToInstance } from 'class-transformer'; + +export class FindCountByDateByIssueResponseDto { + @ApiProperty() + @Expose() + channels: { + id: number; + name: string; + statistics: { date: Date; count: number }; + }[]; + + public static transform(params: any): FindCountByDateByIssueResponseDto { + return plainToInstance(FindCountByDateByIssueResponseDto, params, { + excludeExtraneousValues: true, + }); + } +} diff --git a/apps/api/src/domains/statistics/feedback-issue/dtos/responses/index.ts b/apps/api/src/domains/statistics/feedback-issue/dtos/responses/index.ts new file mode 100644 index 000000000..62f8b0dc0 --- /dev/null +++ b/apps/api/src/domains/statistics/feedback-issue/dtos/responses/index.ts @@ -0,0 +1,17 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +export { FindCountByDateByIssueResponseDto } from './find-count-by-date-by-issue-response.dto'; diff --git a/apps/api/src/domains/statistics/feedback-issue/dtos/update-count.dto.ts b/apps/api/src/domains/statistics/feedback-issue/dtos/update-count.dto.ts new file mode 100644 index 000000000..05f1b6b06 --- /dev/null +++ b/apps/api/src/domains/statistics/feedback-issue/dtos/update-count.dto.ts @@ -0,0 +1,20 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +export class UpdateFeedbackCountDto { + issueId: number; + date: Date; + feedbackCount?: number; +} diff --git a/apps/api/src/domains/statistics/feedback-issue/feedback-issue-statistics.controller.spec.ts b/apps/api/src/domains/statistics/feedback-issue/feedback-issue-statistics.controller.spec.ts new file mode 100644 index 000000000..8cbab32fe --- /dev/null +++ b/apps/api/src/domains/statistics/feedback-issue/feedback-issue-statistics.controller.spec.ts @@ -0,0 +1,67 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { faker } from '@faker-js/faker'; +import { Test } from '@nestjs/testing'; + +import { getMockProvider } from '@/test-utils/util-functions'; +import { FeedbackIssueStatisticsController } from './feedback-issue-statistics.controller'; +import { FeedbackIssueStatisticsService } from './feedback-issue-statistics.service'; + +const MockFeedbackIssueStatisticsService = { + getCountByDateByIssue: jest.fn(), +}; + +describe('FeedbackIssue Statistics Controller', () => { + let feedbackIssueStatisticsController: FeedbackIssueStatisticsController; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + controllers: [FeedbackIssueStatisticsController], + providers: [ + getMockProvider( + FeedbackIssueStatisticsService, + MockFeedbackIssueStatisticsService, + ), + ], + }).compile(); + + feedbackIssueStatisticsController = + module.get( + FeedbackIssueStatisticsController, + ); + }); + + it('getCountByDateByIssue', async () => { + jest.spyOn(MockFeedbackIssueStatisticsService, 'getCountByDateByIssue'); + const from = faker.date.past(); + const to = faker.date.future(); + const interval = ['day', 'week', 'month'][ + faker.number.int({ min: 0, max: 2 }) + ] as 'day' | 'week' | 'month'; + const issueIds = [faker.number.int(), faker.number.int()]; + + await feedbackIssueStatisticsController.getCountByDateByIssue( + from, + to, + interval, + issueIds.join(','), + ); + + expect( + MockFeedbackIssueStatisticsService.getCountByDateByIssue, + ).toBeCalledTimes(1); + }); +}); diff --git a/apps/api/src/domains/statistics/feedback-issue/feedback-issue-statistics.controller.ts b/apps/api/src/domains/statistics/feedback-issue/feedback-issue-statistics.controller.ts new file mode 100644 index 000000000..fd3d7ec5c --- /dev/null +++ b/apps/api/src/domains/statistics/feedback-issue/feedback-issue-statistics.controller.ts @@ -0,0 +1,46 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiOkResponse } from '@nestjs/swagger'; + +import { FindCountByDateByIssueResponseDto } from './dtos/responses'; +import { FeedbackIssueStatisticsService } from './feedback-issue-statistics.service'; + +@Controller('/statistics/feedback-issue') +export class FeedbackIssueStatisticsController { + constructor( + private readonly feedbackIssueStatisticsService: FeedbackIssueStatisticsService, + ) {} + + @ApiOkResponse({ type: [FindCountByDateByIssueResponseDto] }) + @Get() + async getCountByDateByIssue( + @Query('from') from: Date, + @Query('to') to: Date, + @Query('interval') interval: 'day' | 'week' | 'month', + @Query('issueIds') issueIds: string, + ) { + const issueIdsArray = issueIds.split(',').map((v) => parseInt(v, 10)); + return FindCountByDateByIssueResponseDto.transform( + await this.feedbackIssueStatisticsService.getCountByDateByIssue({ + from, + to, + interval, + issueIds: issueIdsArray, + }), + ); + } +} diff --git a/apps/api/src/domains/statistics/feedback-issue/feedback-issue-statistics.entity.ts b/apps/api/src/domains/statistics/feedback-issue/feedback-issue-statistics.entity.ts new file mode 100644 index 000000000..400959529 --- /dev/null +++ b/apps/api/src/domains/statistics/feedback-issue/feedback-issue-statistics.entity.ts @@ -0,0 +1,62 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { + Column, + Entity, + ManyToOne, + PrimaryGeneratedColumn, + Relation, + Unique, +} from 'typeorm'; + +import { IssueEntity } from '@/domains/project/issue/issue.entity'; + +@Entity('feedback_issue_statistics') +@Unique('issue-date-unique', ['issue', 'date']) +export class FeedbackIssueStatisticsEntity { + @PrimaryGeneratedColumn('increment') + id: number; + + @Column('date') + date: Date; + + @Column('int', { default: 0 }) + feedbackCount: number; + + @ManyToOne(() => IssueEntity, (issue) => issue.stats, { + onDelete: 'CASCADE', + orphanedRowAction: 'delete', + }) + issue: Relation; + + static from({ + date, + feedbackCount, + issueId, + }: { + date: Date; + feedbackCount: number; + issueId: number; + }) { + const feedbackIssueStats = new FeedbackIssueStatisticsEntity(); + feedbackIssueStats.issue = new IssueEntity(); + feedbackIssueStats.issue.id = issueId; + feedbackIssueStats.date = date; + feedbackIssueStats.feedbackCount = feedbackCount; + + return feedbackIssueStats; + } +} diff --git a/apps/api/src/domains/statistics/feedback-issue/feedback-issue-statistics.module.ts b/apps/api/src/domains/statistics/feedback-issue/feedback-issue-statistics.module.ts new file mode 100644 index 000000000..be8909e39 --- /dev/null +++ b/apps/api/src/domains/statistics/feedback-issue/feedback-issue-statistics.module.ts @@ -0,0 +1,54 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { Module } from '@nestjs/common'; +import { InjectRepository, TypeOrmModule } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { FeedbackEntity } from '@/domains/feedback/feedback.entity'; +import { IssueEntity } from '@/domains/project/issue/issue.entity'; +import { ProjectEntity } from '@/domains/project/project/project.entity'; +import { FeedbackIssueStatisticsController } from './feedback-issue-statistics.controller'; +import { FeedbackIssueStatisticsEntity } from './feedback-issue-statistics.entity'; +import { FeedbackIssueStatisticsService } from './feedback-issue-statistics.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + FeedbackIssueStatisticsEntity, + FeedbackEntity, + IssueEntity, + ProjectEntity, + ]), + ], + exports: [FeedbackIssueStatisticsService], + providers: [FeedbackIssueStatisticsService], + controllers: [FeedbackIssueStatisticsController], +}) +export class FeedbackIssueStatisticsModule { + constructor( + private readonly service: FeedbackIssueStatisticsService, + @InjectRepository(ProjectEntity) + private readonly projectRepo: Repository, + ) {} + async onModuleInit() { + const projects = await this.projectRepo.find({ + select: ['id'], + }); + for (const { id } of projects) { + await this.service.addCronJobByProjectId(id); + } + } +} diff --git a/apps/api/src/domains/statistics/feedback-issue/feedback-issue-statistics.service.spec.ts b/apps/api/src/domains/statistics/feedback-issue/feedback-issue-statistics.service.spec.ts new file mode 100644 index 000000000..5591eaa94 --- /dev/null +++ b/apps/api/src/domains/statistics/feedback-issue/feedback-issue-statistics.service.spec.ts @@ -0,0 +1,317 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { faker } from '@faker-js/faker'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { Test } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import type { Repository } from 'typeorm'; + +import { FeedbackEntity } from '@/domains/feedback/feedback.entity'; +import { IssueEntity } from '@/domains/project/issue/issue.entity'; +import { ProjectEntity } from '@/domains/project/project/project.entity'; +import { FeedbackIssueStatisticsServiceProviders } from '@/test-utils/providers/feedback-issue-statistics.service.providers'; +import { createQueryBuilder, TestConfig } from '@/test-utils/util-functions'; +import { GetCountByDateByIssueDto } from './dtos'; +import { FeedbackIssueStatisticsEntity } from './feedback-issue-statistics.entity'; +import { FeedbackIssueStatisticsService } from './feedback-issue-statistics.service'; + +const feedbackIssueStatsFixture = [ + { + id: 1, + date: new Date('2023-01-01'), + feedbackCount: 1, + issue: { + id: 1, + name: 'issue1', + }, + }, + { + id: 2, + date: new Date('2023-01-02'), + feedbackCount: 2, + issue: { + id: 1, + name: 'issue1', + }, + }, + { + id: 3, + date: new Date('2023-01-08'), + feedbackCount: 3, + issue: { + id: 1, + name: 'issue1', + }, + }, + { + id: 4, + date: new Date('2023-02-01'), + feedbackCount: 4, + issue: { + id: 1, + name: 'issue1', + }, + }, +] as FeedbackIssueStatisticsEntity[]; + +describe('FeedbackIssueStatisticsService suite', () => { + let feedbackIssueStatsService: FeedbackIssueStatisticsService; + let feedbackIssueStatsRepo: Repository; + let feedbackRepo: Repository; + let issueRepo: Repository; + let projectRepo: Repository; + let schedulerRegistry: SchedulerRegistry; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + imports: [TestConfig], + providers: FeedbackIssueStatisticsServiceProviders, + }).compile(); + + feedbackIssueStatsService = module.get( + FeedbackIssueStatisticsService, + ); + feedbackIssueStatsRepo = module.get( + getRepositoryToken(FeedbackIssueStatisticsEntity), + ); + feedbackRepo = module.get(getRepositoryToken(FeedbackEntity)); + issueRepo = module.get(getRepositoryToken(IssueEntity)); + projectRepo = module.get(getRepositoryToken(ProjectEntity)); + schedulerRegistry = module.get(SchedulerRegistry); + }); + + describe('getCountByDateByissue', () => { + it('getting counts by day by issue succeeds with valid inputs', async () => { + const from = faker.date.past(); + const to = faker.date.future(); + const interval = 'day'; + const issueIds = [faker.number.int(), faker.number.int()]; + const dto = new GetCountByDateByIssueDto(); + dto.from = from; + dto.to = to; + dto.interval = interval; + dto.issueIds = issueIds; + jest + .spyOn(feedbackIssueStatsRepo, 'find') + .mockResolvedValue(feedbackIssueStatsFixture); + + const countByDateByissue = + await feedbackIssueStatsService.getCountByDateByIssue(dto); + + expect(feedbackIssueStatsRepo.find).toBeCalledTimes(1); + expect(countByDateByissue).toEqual({ + issues: [ + { + id: 1, + name: 'issue1', + statistics: [ + { + feedbackCount: 1, + date: '2023-01-01', + }, + { + feedbackCount: 2, + date: '2023-01-02', + }, + { + feedbackCount: 3, + date: '2023-01-08', + }, + { + feedbackCount: 4, + date: '2023-02-01', + }, + ], + }, + ], + }); + }); + it('getting counts by week by issue succeeds with valid inputs', async () => { + const from = faker.date.past(); + const to = faker.date.future(); + const interval = 'week'; + const issueIds = [faker.number.int(), faker.number.int()]; + const dto = new GetCountByDateByIssueDto(); + dto.from = from; + dto.to = to; + dto.interval = interval; + dto.issueIds = issueIds; + jest + .spyOn(feedbackIssueStatsRepo, 'find') + .mockResolvedValue(feedbackIssueStatsFixture); + + const countByDateByIssue = + await feedbackIssueStatsService.getCountByDateByIssue(dto); + + expect(feedbackIssueStatsRepo.find).toBeCalledTimes(1); + expect(countByDateByIssue).toEqual({ + issues: [ + { + id: 1, + name: 'issue1', + statistics: [ + { + feedbackCount: 3, + date: '2023-01-07', + }, + { + feedbackCount: 3, + date: '2023-01-14', + }, + { + feedbackCount: 4, + date: '2023-02-04', + }, + ], + }, + ], + }); + }); + it('getting counts by month by issue succeeds with valid inputs', async () => { + const from = faker.date.past(); + const to = faker.date.future(); + const interval = 'month'; + const issueIds = [faker.number.int(), faker.number.int()]; + const dto = new GetCountByDateByIssueDto(); + dto.from = from; + dto.to = to; + dto.interval = interval; + dto.issueIds = issueIds; + jest + .spyOn(feedbackIssueStatsRepo, 'find') + .mockResolvedValue(feedbackIssueStatsFixture); + + const countByDateByIssue = + await feedbackIssueStatsService.getCountByDateByIssue(dto); + + expect(feedbackIssueStatsRepo.find).toBeCalledTimes(1); + expect(countByDateByIssue).toEqual({ + issues: [ + { + id: 1, + name: 'issue1', + statistics: [ + { + feedbackCount: 6, + date: '2023-01-31', + }, + { + feedbackCount: 4, + date: '2023-02-28', + }, + ], + }, + ], + }); + }); + }); + + describe('addCronJobByProjectId', () => { + it('adding a cron job succeeds with valid input', async () => { + const projectId = faker.number.int(); + jest.spyOn(projectRepo, 'findOne').mockResolvedValue({ + timezoneOffset: '+09:00', + } as ProjectEntity); + jest.spyOn(schedulerRegistry, 'addCronJob'); + + await feedbackIssueStatsService.addCronJobByProjectId(projectId); + + expect(schedulerRegistry.addCronJob).toBeCalledTimes(1); + expect(schedulerRegistry.addCronJob).toBeCalledWith( + `feedback-issue-statistics-${projectId}`, + expect.anything(), + ); + }); + }); + + describe('createFeedbackIssueStatistics', () => { + it('creating feedback issue statistics data succeeds with valid inputs', async () => { + const projectId = faker.number.int(); + const dayToCreate = faker.number.int({ min: 2, max: 10 }); + const issueCount = faker.number.int({ min: 2, max: 10 }); + const issues = Array.from({ length: issueCount }).map(() => ({ + id: faker.number.int(), + })); + jest.spyOn(projectRepo, 'findOne').mockResolvedValue({ + timezoneOffset: '+09:00', + } as ProjectEntity); + jest.spyOn(issueRepo, 'find').mockResolvedValue(issues as IssueEntity[]); + jest.spyOn(feedbackRepo, 'count').mockResolvedValueOnce(0); + jest.spyOn(feedbackRepo, 'count').mockResolvedValue(1); + jest + .spyOn(feedbackIssueStatsRepo, 'createQueryBuilder') + .mockImplementation(() => createQueryBuilder); + + await feedbackIssueStatsService.createFeedbackIssueStatistics( + projectId, + dayToCreate, + ); + + expect(feedbackRepo.count).toBeCalledTimes(dayToCreate * issueCount); + expect(feedbackIssueStatsRepo.createQueryBuilder).toBeCalledTimes( + dayToCreate * issueCount - 1, + ); + }); + }); + + describe('updateFeedbackCount', () => { + it('updating feedback count succeeds with valid inputs and existent date', async () => { + const issueId = faker.number.int(); + const date = faker.date.past(); + const feedbackCount = faker.number.int({ min: 1, max: 10 }); + jest.spyOn(feedbackIssueStatsRepo, 'findOne').mockResolvedValue({ + feedbackCount: 1, + } as FeedbackIssueStatisticsEntity); + + await feedbackIssueStatsService.updateFeedbackCount({ + issueId, + date, + feedbackCount, + }); + + expect(feedbackIssueStatsRepo.findOne).toBeCalledTimes(1); + expect(feedbackIssueStatsRepo.save).toBeCalledTimes(1); + expect(feedbackIssueStatsRepo.save).toBeCalledWith({ + feedbackCount: 1 + feedbackCount, + }); + }); + it('updating feedback count succeeds with valid inputs and nonexistent date', async () => { + const issueId = faker.number.int(); + const date = faker.date.past(); + const feedbackCount = faker.number.int({ min: 1, max: 10 }); + jest.spyOn(feedbackIssueStatsRepo, 'findOne').mockResolvedValue(null); + jest + .spyOn(feedbackIssueStatsRepo, 'createQueryBuilder') + .mockImplementation(() => createQueryBuilder); + jest.spyOn(createQueryBuilder, 'values'); + + await feedbackIssueStatsService.updateFeedbackCount({ + issueId, + date, + feedbackCount, + }); + + expect(feedbackIssueStatsRepo.findOne).toBeCalledTimes(1); + expect(feedbackIssueStatsRepo.createQueryBuilder).toBeCalledTimes(1); + expect(createQueryBuilder.values).toBeCalledTimes(1); + expect(createQueryBuilder.values).toBeCalledWith({ + date: new Date(date.toISOString().split('T')[0] + 'T00:00:00'), + feedbackCount, + issue: { id: issueId }, + }); + }); + }); +}); diff --git a/apps/api/src/domains/statistics/feedback-issue/feedback-issue-statistics.service.ts b/apps/api/src/domains/statistics/feedback-issue/feedback-issue-statistics.service.ts new file mode 100644 index 000000000..f239435f7 --- /dev/null +++ b/apps/api/src/domains/statistics/feedback-issue/feedback-issue-statistics.service.ts @@ -0,0 +1,211 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { Injectable, Logger } from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { CronJob } from 'cron'; +import dayjs from 'dayjs'; +import dotenv from 'dotenv'; +import { Between, In, Repository } from 'typeorm'; +import { Transactional } from 'typeorm-transactional'; + +import { FeedbackEntity } from '@/domains/feedback/feedback.entity'; +import { IssueEntity } from '@/domains/project/issue/issue.entity'; +import { ProjectEntity } from '@/domains/project/project/project.entity'; +import { UpdateFeedbackCountDto } from './dtos'; +import type { GetCountByDateByIssueDto } from './dtos'; +import { FeedbackIssueStatisticsEntity } from './feedback-issue-statistics.entity'; + +dotenv.config(); + +@Injectable() +export class FeedbackIssueStatisticsService { + private logger = new Logger(FeedbackIssueStatisticsService.name); + + constructor( + @InjectRepository(FeedbackIssueStatisticsEntity) + private readonly repository: Repository, + @InjectRepository(FeedbackEntity) + private readonly feedbackRepository: Repository, + @InjectRepository(IssueEntity) + private readonly issueRepository: Repository, + @InjectRepository(ProjectEntity) + private readonly projectRepository: Repository, + private readonly schedulerRegistry: SchedulerRegistry, + ) {} + + async getCountByDateByIssue(dto: GetCountByDateByIssueDto) { + const { from, to, interval, issueIds } = dto; + + const feedbackIssueStatistics = await this.repository.find({ + where: { + issue: { id: In(issueIds) }, + date: Between(from, to), + }, + relations: { issue: true }, + order: { issue: { id: 'ASC' }, date: 'ASC' }, + }); + + return { + issues: feedbackIssueStatistics.reduce( + (acc, curr) => { + let issue = acc.find((ch) => ch.id === curr.issue.id); + if (!issue) { + issue = { + id: curr.issue.id, + name: curr.issue.name, + statistics: [], + }; + acc.push(issue); + } + + let endDate: dayjs.Dayjs; + switch (interval) { + case 'week': + endDate = dayjs(curr.date).endOf('week'); + break; + case 'month': + endDate = dayjs(curr.date).endOf('month'); + break; + default: + endDate = dayjs(curr.date); + } + + let statistic = issue.statistics.find( + (stat) => stat.date === endDate.format('YYYY-MM-DD'), + ); + if (!statistic) { + statistic = { + date: endDate.format('YYYY-MM-DD'), + feedbackCount: 0, + }; + issue.statistics.push(statistic); + } + statistic.feedbackCount += curr.feedbackCount; + + return acc; + }, + [] as { + id: number; + name: string; + statistics: { date: string; feedbackCount: number }[]; + }[], + ), + }; + } + + async addCronJobByProjectId(projectId: number) { + const { timezoneOffset } = await this.projectRepository.findOne({ + where: { id: projectId }, + }); + + const cronHour = (24 - Number(timezoneOffset.split(':')[0])) % 24; + + const job = new CronJob(`0 ${cronHour} * * *`, async () => { + await this.createFeedbackIssueStatistics(projectId); + }); + this.schedulerRegistry.addCronJob( + `feedback-issue-statistics-${projectId}`, + job, + ); + job.start(); + + this.logger.log(`feedback-issue-statistics-${projectId} cron job started`); + } + + @Transactional() + async createFeedbackIssueStatistics( + projectId: number, + dayToCreate: number = 1, + ) { + const { timezoneOffset } = await this.projectRepository.findOne({ + where: { id: projectId }, + }); + const [hours, minutes] = timezoneOffset.split(':'); + const offset = Number(hours) + Number(minutes) / 60; + + const issues = await this.issueRepository.find({ + where: { project: { id: projectId } }, + }); + + for (let day = 1; day <= dayToCreate; day++) { + for (const issue of issues) { + const feedbackCount = await this.feedbackRepository.count({ + where: { + issues: { id: issue.id }, + createdAt: Between( + dayjs() + .subtract(day, 'day') + .startOf('day') + .subtract(offset, 'hour') + .toDate(), + dayjs() + .subtract(day, 'day') + .endOf('day') + .subtract(offset, 'hour') + .toDate(), + ), + }, + }); + + if (feedbackCount === 0) continue; + + await this.repository + .createQueryBuilder() + .insert() + .values({ + date: dayjs().subtract(day, 'day').toDate(), + issue: { id: issue.id }, + feedbackCount, + }) + .orUpdate(['feedback_count'], ['date', 'issue']) + .updateEntity(false) + .execute(); + } + } + } + + @Transactional() + async updateFeedbackCount(dto: UpdateFeedbackCountDto) { + if (dto.feedbackCount === 0) return; + if (!dto.feedbackCount) dto.feedbackCount = 1; + + const stats = await this.repository.findOne({ + where: { + date: new Date(dto.date.toISOString().split('T')[0] + 'T00:00:00'), + issue: { id: dto.issueId }, + }, + }); + + if (stats) { + stats.feedbackCount += dto.feedbackCount; + await this.repository.save(stats); + return; + } else { + await this.repository + .createQueryBuilder() + .insert() + .values({ + date: new Date(dto.date.toISOString().split('T')[0] + 'T00:00:00'), + feedbackCount: dto.feedbackCount, + issue: { id: dto.issueId }, + }) + .orUpdate(['feedback_count'], ['date', 'issue']) + .updateEntity(false) + .execute(); + } + } +} diff --git a/apps/api/src/domains/statistics/feedback/dtos/index.ts b/apps/api/src/domains/statistics/feedback/dtos/index.ts index b198881d9..a61af4d62 100644 --- a/apps/api/src/domains/statistics/feedback/dtos/index.ts +++ b/apps/api/src/domains/statistics/feedback/dtos/index.ts @@ -16,3 +16,4 @@ export { GetCountByDateByChannelDto } from './get-count-by-date-by-channel.dto'; export { GetCountDto } from './get-count.dto'; export { GetIssuedRateDto } from './get-issued-rate.dto'; +export { UpdateCountDto } from './update-count.dto'; diff --git a/apps/api/src/domains/statistics/feedback/dtos/update-count.dto.ts b/apps/api/src/domains/statistics/feedback/dtos/update-count.dto.ts new file mode 100644 index 000000000..a0bb7b390 --- /dev/null +++ b/apps/api/src/domains/statistics/feedback/dtos/update-count.dto.ts @@ -0,0 +1,20 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +export class UpdateCountDto { + channelId: number; + date: Date; + count?: number; +} diff --git a/apps/api/src/domains/statistics/feedback/feedback-statistics.entity.ts b/apps/api/src/domains/statistics/feedback/feedback-statistics.entity.ts index 2fd7dd609..01a8052b9 100644 --- a/apps/api/src/domains/statistics/feedback/feedback-statistics.entity.ts +++ b/apps/api/src/domains/statistics/feedback/feedback-statistics.entity.ts @@ -36,7 +36,7 @@ export class FeedbackStatisticsEntity { @Column('int', { default: 0 }) count: number; - @ManyToOne(() => ChannelEntity, (channel) => channel.fields, { + @ManyToOne(() => ChannelEntity, (channel) => channel.feedbackStats, { onDelete: 'CASCADE', orphanedRowAction: 'delete', }) diff --git a/apps/api/src/domains/statistics/feedback/feedback-statistics.service.spec.ts b/apps/api/src/domains/statistics/feedback/feedback-statistics.service.spec.ts index 3186caf3e..2f17bc067 100644 --- a/apps/api/src/domains/statistics/feedback/feedback-statistics.service.spec.ts +++ b/apps/api/src/domains/statistics/feedback/feedback-statistics.service.spec.ts @@ -68,7 +68,7 @@ const feedbackStatsFixture = [ }, ] as FeedbackStatisticsEntity[]; -describe('FieldService suite', () => { +describe('FeedbackStatisticsService suite', () => { let feedbackStatsService: FeedbackStatisticsService; let feedbackStatsRepo: Repository; let feedbackRepo: Repository; @@ -322,4 +322,52 @@ describe('FieldService suite', () => { ); }); }); + + describe('updateCount', () => { + it('updating count succeeds with valid inputs and existent date', async () => { + const channelId = faker.number.int(); + const date = faker.date.past(); + const count = faker.number.int({ min: 1, max: 10 }); + jest.spyOn(feedbackStatsRepo, 'findOne').mockResolvedValue({ + count: 1, + } as FeedbackStatisticsEntity); + + await feedbackStatsService.updateCount({ + channelId, + date, + count, + }); + + expect(feedbackStatsRepo.findOne).toBeCalledTimes(1); + expect(feedbackStatsRepo.save).toBeCalledTimes(1); + expect(feedbackStatsRepo.save).toBeCalledWith({ + count: 1 + count, + }); + }); + it('updating count succeeds with valid inputs and nonexistent date', async () => { + const channelId = faker.number.int(); + const date = faker.date.past(); + const count = faker.number.int({ min: 1, max: 10 }); + jest.spyOn(feedbackStatsRepo, 'findOne').mockResolvedValue(null); + jest + .spyOn(feedbackStatsRepo, 'createQueryBuilder') + .mockImplementation(() => createQueryBuilder); + jest.spyOn(createQueryBuilder, 'values'); + + await feedbackStatsService.updateCount({ + channelId, + date, + count, + }); + + expect(feedbackStatsRepo.findOne).toBeCalledTimes(1); + expect(feedbackStatsRepo.createQueryBuilder).toBeCalledTimes(1); + expect(createQueryBuilder.values).toBeCalledTimes(1); + expect(createQueryBuilder.values).toBeCalledWith({ + date: new Date(date.toISOString().split('T')[0] + 'T00:00:00'), + count, + channel: { id: channelId }, + }); + }); + }); }); diff --git a/apps/api/src/domains/statistics/feedback/feedback-statistics.service.ts b/apps/api/src/domains/statistics/feedback/feedback-statistics.service.ts index ae9addfe1..9e4be3b23 100644 --- a/apps/api/src/domains/statistics/feedback/feedback-statistics.service.ts +++ b/apps/api/src/domains/statistics/feedback/feedback-statistics.service.ts @@ -26,6 +26,7 @@ import { ChannelEntity } from '@/domains/channel/channel/channel.entity'; import { FeedbackEntity } from '@/domains/feedback/feedback.entity'; import { IssueEntity } from '@/domains/project/issue/issue.entity'; import { ProjectEntity } from '@/domains/project/project/project.entity'; +import { UpdateCountDto } from './dtos'; import type { GetCountByDateByChannelDto, GetCountDto, @@ -217,4 +218,35 @@ export class FeedbackStatisticsService { } } } + + @Transactional() + async updateCount(dto: UpdateCountDto) { + if (dto.count === 0) return; + if (!dto.count) dto.count = 1; + + const stats = await this.repository.findOne({ + where: { + date: new Date(dto.date.toISOString().split('T')[0] + 'T00:00:00'), + channel: { id: dto.channelId }, + }, + }); + + if (stats) { + stats.count += dto.count; + await this.repository.save(stats); + return; + } else { + await this.repository + .createQueryBuilder() + .insert() + .values({ + date: new Date(dto.date.toISOString().split('T')[0] + 'T00:00:00'), + count: dto.count, + channel: { id: dto.channelId }, + }) + .orUpdate(['count'], ['date', 'channel']) + .updateEntity(false) + .execute(); + } + } } diff --git a/apps/api/src/domains/statistics/issue/dtos/get-count-by-date-by-channel.dto.ts b/apps/api/src/domains/statistics/issue/dtos/get-count-by-date-by-channel.dto.ts new file mode 100644 index 000000000..f53d196ee --- /dev/null +++ b/apps/api/src/domains/statistics/issue/dtos/get-count-by-date-by-channel.dto.ts @@ -0,0 +1,21 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +export class GetCountByDateDto { + from: Date; + to: Date; + interval: 'day' | 'week' | 'month'; + projectId: number; +} diff --git a/apps/api/src/domains/statistics/issue/dtos/get-count.dto.ts b/apps/api/src/domains/statistics/issue/dtos/get-count.dto.ts new file mode 100644 index 000000000..1c8dd9680 --- /dev/null +++ b/apps/api/src/domains/statistics/issue/dtos/get-count.dto.ts @@ -0,0 +1,20 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +export class GetCountDto { + from: Date; + to: Date; + projectId: number; +} diff --git a/apps/api/src/domains/statistics/issue/dtos/index.ts b/apps/api/src/domains/statistics/issue/dtos/index.ts new file mode 100644 index 000000000..9f1582284 --- /dev/null +++ b/apps/api/src/domains/statistics/issue/dtos/index.ts @@ -0,0 +1,18 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +export { GetCountByDateDto } from './get-count-by-date-by-channel.dto'; +export { GetCountDto } from './get-count.dto'; +export { UpdateCountDto } from './update-count.dto'; diff --git a/apps/api/src/domains/statistics/issue/dtos/responses/find-count-by-date-response.dto.ts b/apps/api/src/domains/statistics/issue/dtos/responses/find-count-by-date-response.dto.ts new file mode 100644 index 000000000..477ed5e1e --- /dev/null +++ b/apps/api/src/domains/statistics/issue/dtos/responses/find-count-by-date-response.dto.ts @@ -0,0 +1,32 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, plainToInstance } from 'class-transformer'; + +export class FindCountByDateResponseDto { + @ApiProperty() + @Expose() + statistics: { + date: string; + count: number; + }[]; + + public static transform(params: any): FindCountByDateResponseDto { + return plainToInstance(FindCountByDateResponseDto, params, { + excludeExtraneousValues: true, + }); + } +} diff --git a/apps/api/src/domains/statistics/issue/dtos/responses/find-count-by-status-response.dto.ts b/apps/api/src/domains/statistics/issue/dtos/responses/find-count-by-status-response.dto.ts new file mode 100644 index 000000000..acef5e0ff --- /dev/null +++ b/apps/api/src/domains/statistics/issue/dtos/responses/find-count-by-status-response.dto.ts @@ -0,0 +1,32 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, plainToInstance } from 'class-transformer'; + +export class FindCountByStatusResponseDto { + @ApiProperty() + @Expose() + statistics: { + status: string; + count: number; + }[]; + + public static transform(params: any): FindCountByStatusResponseDto { + return plainToInstance(FindCountByStatusResponseDto, params, { + excludeExtraneousValues: true, + }); + } +} diff --git a/apps/api/src/domains/statistics/issue/dtos/responses/find-count-response.dto.ts b/apps/api/src/domains/statistics/issue/dtos/responses/find-count-response.dto.ts new file mode 100644 index 000000000..5ed6d75ae --- /dev/null +++ b/apps/api/src/domains/statistics/issue/dtos/responses/find-count-response.dto.ts @@ -0,0 +1,29 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, plainToInstance } from 'class-transformer'; + +export class FindCountResponseDto { + @ApiProperty() + @Expose() + count: number; + + public static transform(params: any): FindCountResponseDto { + return plainToInstance(FindCountResponseDto, params, { + excludeExtraneousValues: true, + }); + } +} diff --git a/apps/api/src/domains/statistics/issue/dtos/responses/index.ts b/apps/api/src/domains/statistics/issue/dtos/responses/index.ts new file mode 100644 index 000000000..81a616d67 --- /dev/null +++ b/apps/api/src/domains/statistics/issue/dtos/responses/index.ts @@ -0,0 +1,19 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +export { FindCountByDateResponseDto } from './find-count-by-date-response.dto'; +export { FindCountByStatusResponseDto } from './find-count-by-status-response.dto'; +export { FindCountResponseDto } from './find-count-response.dto'; diff --git a/apps/api/src/domains/statistics/issue/dtos/update-count.dto.ts b/apps/api/src/domains/statistics/issue/dtos/update-count.dto.ts new file mode 100644 index 000000000..c75afd222 --- /dev/null +++ b/apps/api/src/domains/statistics/issue/dtos/update-count.dto.ts @@ -0,0 +1,20 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +export class UpdateCountDto { + projectId: number; + date: Date; + count?: number; +} diff --git a/apps/api/src/domains/statistics/issue/issue-statistics.controller.spec.ts b/apps/api/src/domains/statistics/issue/issue-statistics.controller.spec.ts new file mode 100644 index 000000000..94dc6b7c1 --- /dev/null +++ b/apps/api/src/domains/statistics/issue/issue-statistics.controller.spec.ts @@ -0,0 +1,72 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { faker } from '@faker-js/faker'; +import { Test } from '@nestjs/testing'; + +import { getMockProvider } from '@/test-utils/util-functions'; +import { IssueStatisticsController } from './issue-statistics.controller'; +import { IssueStatisticsService } from './issue-statistics.service'; + +const MockIssueStatisticsService = { + getCountByDate: jest.fn(), + getCount: jest.fn(), + getIssuedRatio: jest.fn(), +}; + +describe('Issue Statistics Controller', () => { + let issueStatisticsController: IssueStatisticsController; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + controllers: [IssueStatisticsController], + providers: [ + getMockProvider(IssueStatisticsService, MockIssueStatisticsService), + ], + }).compile(); + + issueStatisticsController = module.get( + IssueStatisticsController, + ); + }); + + it('getCountByDate', async () => { + jest.spyOn(MockIssueStatisticsService, 'getCountByDate'); + const from = faker.date.past(); + const to = faker.date.future(); + const interval = ['day', 'week', 'month'][ + faker.number.int({ min: 0, max: 2 }) + ] as 'day' | 'week' | 'month'; + const projectId = faker.number.int(); + + await issueStatisticsController.getCountByDate( + from, + to, + interval, + projectId, + ); + + expect(MockIssueStatisticsService.getCountByDate).toBeCalledTimes(1); + }); + + it('getCount', async () => { + jest.spyOn(MockIssueStatisticsService, 'getCountByDate'); + const from = faker.date.past(); + const to = faker.date.future(); + const projectId = faker.number.int(); + await issueStatisticsController.getCount(from, to, projectId); + expect(MockIssueStatisticsService.getCount).toBeCalledTimes(1); + }); +}); diff --git a/apps/api/src/domains/statistics/issue/issue-statistics.controller.ts b/apps/api/src/domains/statistics/issue/issue-statistics.controller.ts new file mode 100644 index 000000000..5231f25e3 --- /dev/null +++ b/apps/api/src/domains/statistics/issue/issue-statistics.controller.ts @@ -0,0 +1,81 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiOkResponse } from '@nestjs/swagger'; + +import { + FindCountByDateResponseDto, + FindCountByStatusResponseDto, + FindCountResponseDto, +} from './dtos/responses'; +import { IssueStatisticsService } from './issue-statistics.service'; + +@Controller('/statistics/issue') +export class IssueStatisticsController { + constructor( + private readonly issueStatisticsService: IssueStatisticsService, + ) {} + + @ApiOkResponse({ type: [FindCountResponseDto] }) + @Get('/count') + async getCount( + @Query('from') from: Date, + @Query('to') to: Date, + @Query('projectId') projectId: number, + ) { + return FindCountResponseDto.transform( + await this.issueStatisticsService.getCount({ + from, + to, + projectId, + }), + ); + } + + @ApiOkResponse({ type: [FindCountByDateResponseDto] }) + @Get('/count-by-date') + async getCountByDate( + @Query('from') from: Date, + @Query('to') to: Date, + @Query('interval') interval: 'day' | 'week' | 'month', + @Query('projectId') projectId: number, + ) { + return FindCountByDateResponseDto.transform( + await this.issueStatisticsService.getCountByDate({ + from, + to, + interval, + projectId, + }), + ); + } + + @ApiOkResponse({ type: [FindCountByStatusResponseDto] }) + @Get('/count-by-status') + async getCountByStatus( + @Query('from') from: Date, + @Query('to') to: Date, + @Query('projectId') projectId: number, + ) { + return FindCountByStatusResponseDto.transform( + await this.issueStatisticsService.getCountByStatus({ + from, + to, + projectId, + }), + ); + } +} diff --git a/apps/api/src/domains/statistics/issue/issue-statistics.entity.ts b/apps/api/src/domains/statistics/issue/issue-statistics.entity.ts new file mode 100644 index 000000000..ddef4e595 --- /dev/null +++ b/apps/api/src/domains/statistics/issue/issue-statistics.entity.ts @@ -0,0 +1,62 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { + Column, + Entity, + ManyToOne, + PrimaryGeneratedColumn, + Relation, + Unique, +} from 'typeorm'; + +import { ProjectEntity } from '@/domains/project/project/project.entity'; + +@Entity('issue_statistics') +@Unique('project-date-unique', ['project', 'date']) +export class IssueStatisticsEntity { + @PrimaryGeneratedColumn('increment') + id: number; + + @Column('date') + date: Date; + + @Column('int', { default: 0 }) + count: number; + + @ManyToOne(() => ProjectEntity, (project) => project.stats, { + onDelete: 'CASCADE', + orphanedRowAction: 'delete', + }) + project: Relation; + + static from({ + date, + count, + projectId, + }: { + date: Date; + count: number; + projectId: number; + }) { + const issueStats = new IssueStatisticsEntity(); + issueStats.project = new ProjectEntity(); + issueStats.project.id = projectId; + issueStats.date = date; + issueStats.count = count; + + return issueStats; + } +} diff --git a/apps/api/src/domains/statistics/issue/issue-statistics.module.ts b/apps/api/src/domains/statistics/issue/issue-statistics.module.ts new file mode 100644 index 000000000..6671d519b --- /dev/null +++ b/apps/api/src/domains/statistics/issue/issue-statistics.module.ts @@ -0,0 +1,56 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { Module } from '@nestjs/common'; +import { InjectRepository, TypeOrmModule } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { ChannelEntity } from '@/domains/channel/channel/channel.entity'; +import { FeedbackEntity } from '@/domains/feedback/feedback.entity'; +import { IssueEntity } from '@/domains/project/issue/issue.entity'; +import { ProjectEntity } from '@/domains/project/project/project.entity'; +import { IssueStatisticsController } from './issue-statistics.controller'; +import { IssueStatisticsEntity } from './issue-statistics.entity'; +import { IssueStatisticsService } from './issue-statistics.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + IssueStatisticsEntity, + FeedbackEntity, + IssueEntity, + ChannelEntity, + ProjectEntity, + ]), + ], + exports: [IssueStatisticsService], + providers: [IssueStatisticsService], + controllers: [IssueStatisticsController], +}) +export class IssueStatisticsModule { + constructor( + private readonly service: IssueStatisticsService, + @InjectRepository(ProjectEntity) + private readonly projectRepo: Repository, + ) {} + async onModuleInit() { + const projects = await this.projectRepo.find({ + select: ['id'], + }); + for (const { id } of projects) { + await this.service.addCronJobByProjectId(id); + } + } +} diff --git a/apps/api/src/domains/statistics/issue/issue-statistics.service.spec.ts b/apps/api/src/domains/statistics/issue/issue-statistics.service.spec.ts new file mode 100644 index 000000000..ee58e83a7 --- /dev/null +++ b/apps/api/src/domains/statistics/issue/issue-statistics.service.spec.ts @@ -0,0 +1,299 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { faker } from '@faker-js/faker'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { Test } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import type { Repository } from 'typeorm'; + +import { IssueEntity } from '@/domains/project/issue/issue.entity'; +import { ProjectEntity } from '@/domains/project/project/project.entity'; +import { IssueStatisticsServiceProviders } from '@/test-utils/providers/issue-statistics.service.providers'; +import { createQueryBuilder, TestConfig } from '@/test-utils/util-functions'; +import { GetCountByDateDto, GetCountDto } from './dtos'; +import { IssueStatisticsEntity } from './issue-statistics.entity'; +import { IssueStatisticsService } from './issue-statistics.service'; + +const issueStatsFixture = [ + { + id: 1, + date: new Date('2023-01-01'), + count: 1, + project: { + id: 1, + name: 'project1', + }, + }, + { + id: 2, + date: new Date('2023-01-02'), + count: 2, + project: { + id: 1, + name: 'project1', + }, + }, + { + id: 3, + date: new Date('2023-01-08'), + count: 3, + project: { + id: 1, + name: 'project1', + }, + }, + { + id: 4, + date: new Date('2023-02-01'), + count: 4, + project: { + id: 1, + name: 'project1', + }, + }, +] as IssueStatisticsEntity[]; + +describe('IssueStatisticsService suite', () => { + let issueStatsService: IssueStatisticsService; + let issueStatsRepo: Repository; + let issueRepo: Repository; + let projectRepo: Repository; + let schedulerRegistry: SchedulerRegistry; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + imports: [TestConfig], + providers: IssueStatisticsServiceProviders, + }).compile(); + + issueStatsService = module.get( + IssueStatisticsService, + ); + issueStatsRepo = module.get(getRepositoryToken(IssueStatisticsEntity)); + issueRepo = module.get(getRepositoryToken(IssueEntity)); + projectRepo = module.get(getRepositoryToken(ProjectEntity)); + schedulerRegistry = module.get(SchedulerRegistry); + }); + + describe('getCountByDate', () => { + it('getting counts by date succeeds with valid inputs', async () => { + const from = faker.date.past(); + const to = faker.date.future(); + const interval = 'day'; + const projectId = faker.number.int(); + const dto = new GetCountByDateDto(); + dto.from = from; + dto.to = to; + dto.interval = interval; + dto.projectId = projectId; + jest.spyOn(issueStatsRepo, 'find').mockResolvedValue(issueStatsFixture); + + const countByDateByChannel = await issueStatsService.getCountByDate(dto); + + expect(issueStatsRepo.find).toBeCalledTimes(1); + expect(countByDateByChannel).toEqual({ + statistics: [ + { + count: 1, + date: '2023-01-01', + }, + { + count: 2, + date: '2023-01-02', + }, + { + count: 3, + date: '2023-01-08', + }, + { + count: 4, + date: '2023-02-01', + }, + ], + }); + }); + it('getting counts by week by channel succeeds with valid inputs', async () => { + const from = faker.date.past(); + const to = faker.date.future(); + const interval = 'week'; + const projectId = faker.number.int(); + const dto = new GetCountByDateDto(); + dto.from = from; + dto.to = to; + dto.interval = interval; + dto.projectId = projectId; + jest.spyOn(issueStatsRepo, 'find').mockResolvedValue(issueStatsFixture); + + const countByDateByChannel = await issueStatsService.getCountByDate(dto); + + expect(issueStatsRepo.find).toBeCalledTimes(1); + expect(countByDateByChannel).toEqual({ + statistics: [ + { + count: 3, + date: '2023-01-07', + }, + { + count: 3, + date: '2023-01-14', + }, + { + count: 4, + date: '2023-02-04', + }, + ], + }); + }); + it('getting counts by month by channel succeeds with valid inputs', async () => { + const from = faker.date.past(); + const to = faker.date.future(); + const interval = 'month'; + const projectId = faker.number.int(); + const dto = new GetCountByDateDto(); + dto.from = from; + dto.to = to; + dto.interval = interval; + dto.projectId = projectId; + jest.spyOn(issueStatsRepo, 'find').mockResolvedValue(issueStatsFixture); + + const countByDateByChannel = await issueStatsService.getCountByDate(dto); + + expect(issueStatsRepo.find).toBeCalledTimes(1); + expect(countByDateByChannel).toEqual({ + statistics: [ + { + count: 6, + date: '2023-01-31', + }, + { + count: 4, + date: '2023-02-28', + }, + ], + }); + }); + }); + + describe('getCount', () => { + it('getting count succeeds with valid inputs', async () => { + const from = faker.date.past(); + const to = faker.date.future(); + const projectId = faker.number.int(); + const dto = new GetCountDto(); + dto.from = from; + dto.to = to; + dto.projectId = projectId; + jest + .spyOn(issueRepo, 'count') + .mockResolvedValue(issueStatsFixture.length); + + const countByDateByChannel = await issueStatsService.getCount(dto); + + expect(issueRepo.count).toBeCalledTimes(1); + expect(countByDateByChannel).toEqual({ + count: issueStatsFixture.length, + }); + }); + }); + + describe('addCronJobByProjectId', () => { + it('adding a cron job succeeds with valid input', async () => { + const projectId = faker.number.int(); + jest.spyOn(projectRepo, 'findOne').mockResolvedValue({ + timezoneOffset: '+09:00', + } as ProjectEntity); + jest.spyOn(schedulerRegistry, 'addCronJob'); + + await issueStatsService.addCronJobByProjectId(projectId); + + expect(schedulerRegistry.addCronJob).toBeCalledTimes(1); + expect(schedulerRegistry.addCronJob).toBeCalledWith( + `issue-statistics-${projectId}`, + expect.anything(), + ); + }); + }); + + describe('createIssueStatistics', () => { + it('creating issue statistics data succeeds with valid inputs', async () => { + const projectId = faker.number.int(); + const dayToCreate = faker.number.int({ min: 2, max: 10 }); + jest.spyOn(projectRepo, 'findOne').mockResolvedValue({ + timezoneOffset: '+09:00', + } as ProjectEntity); + jest.spyOn(issueRepo, 'count').mockResolvedValueOnce(0); + jest.spyOn(issueRepo, 'count').mockResolvedValue(1); + jest + .spyOn(issueStatsRepo, 'createQueryBuilder') + .mockImplementation(() => createQueryBuilder); + + await issueStatsService.createIssueStatistics(projectId, dayToCreate); + + expect(issueRepo.count).toBeCalledTimes(dayToCreate); + expect(issueStatsRepo.createQueryBuilder).toBeCalledTimes( + dayToCreate - 1, + ); + }); + }); + + describe('updateCount', () => { + it('updating count succeeds with valid inputs and existent date', async () => { + const projectId = faker.number.int(); + const date = faker.date.past(); + const count = faker.number.int({ min: 1, max: 10 }); + jest.spyOn(issueStatsRepo, 'findOne').mockResolvedValue({ + count: 1, + } as IssueStatisticsEntity); + + await issueStatsService.updateCount({ + projectId, + date, + count, + }); + + expect(issueStatsRepo.findOne).toBeCalledTimes(1); + expect(issueStatsRepo.save).toBeCalledTimes(1); + expect(issueStatsRepo.save).toBeCalledWith({ + count: 1 + count, + }); + }); + it('updating count succeeds with valid inputs and nonexistent date', async () => { + const projectId = faker.number.int(); + const date = faker.date.past(); + const count = faker.number.int({ min: 1, max: 10 }); + jest.spyOn(issueStatsRepo, 'findOne').mockResolvedValue(null); + jest + .spyOn(issueStatsRepo, 'createQueryBuilder') + .mockImplementation(() => createQueryBuilder); + jest.spyOn(createQueryBuilder, 'values'); + + await issueStatsService.updateCount({ + projectId, + date, + count, + }); + + expect(issueStatsRepo.findOne).toBeCalledTimes(1); + expect(issueStatsRepo.createQueryBuilder).toBeCalledTimes(1); + expect(createQueryBuilder.values).toBeCalledTimes(1); + expect(createQueryBuilder.values).toBeCalledWith({ + date: new Date(date.toISOString().split('T')[0] + 'T00:00:00'), + count, + project: { id: projectId }, + }); + }); + }); +}); diff --git a/apps/api/src/domains/statistics/issue/issue-statistics.service.ts b/apps/api/src/domains/statistics/issue/issue-statistics.service.ts new file mode 100644 index 000000000..6bfc9ed1a --- /dev/null +++ b/apps/api/src/domains/statistics/issue/issue-statistics.service.ts @@ -0,0 +1,211 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { Injectable, Logger } from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { CronJob } from 'cron'; +import dayjs from 'dayjs'; +import dotenv from 'dotenv'; +import { Between, Repository } from 'typeorm'; +import { Transactional } from 'typeorm-transactional'; + +import { IssueEntity } from '@/domains/project/issue/issue.entity'; +import { ProjectEntity } from '@/domains/project/project/project.entity'; +import { UpdateCountDto } from './dtos'; +import type { GetCountByDateDto, GetCountDto } from './dtos'; +import { IssueStatisticsEntity } from './issue-statistics.entity'; + +dotenv.config(); + +@Injectable() +export class IssueStatisticsService { + private logger = new Logger(IssueStatisticsService.name); + + constructor( + @InjectRepository(IssueStatisticsEntity) + private readonly repository: Repository, + @InjectRepository(IssueEntity) + private readonly issueRepository: Repository, + @InjectRepository(ProjectEntity) + private readonly projectRepository: Repository, + private readonly schedulerRegistry: SchedulerRegistry, + ) {} + + async getCount(dto: GetCountDto) { + return { + count: await this.issueRepository.count({ + where: { + createdAt: Between(dto.from, dto.to), + project: { id: dto.projectId }, + }, + }), + }; + } + + async getCountByDate(dto: GetCountByDateDto) { + const { from, to, interval } = dto; + + const issueStatistics = await this.repository.find({ + where: { + date: Between(from, to), + project: { id: dto.projectId }, + }, + order: { date: 'ASC' }, + }); + + return { + statistics: issueStatistics.reduce( + (acc, curr) => { + let endDate: dayjs.Dayjs; + switch (interval) { + case 'week': + endDate = dayjs(curr.date).endOf('week'); + break; + case 'month': + endDate = dayjs(curr.date).endOf('month'); + break; + default: + endDate = dayjs(curr.date); + } + + let statistic = acc.find( + (stat) => stat.date === endDate.format('YYYY-MM-DD'), + ); + if (!statistic) { + statistic = { + date: endDate.format('YYYY-MM-DD'), + count: 0, + }; + acc.push(statistic); + } + statistic.count += curr.count; + + return acc; + }, + [] as { date: string; count: number }[], + ), + }; + } + + async getCountByStatus(dto: GetCountDto) { + return { + statistics: await this.issueRepository + .createQueryBuilder('issue') + .select('issue.status', 'status') + .addSelect('COUNT(issue.id)', 'count') + .where('issue.project_id = :projectId', { projectId: dto.projectId }) + .andWhere('issue.createdAt BETWEEN :from AND :to', { + from: dto.from, + to: dto.to, + }) + .groupBy('issue.status') + .getRawMany() + .then((res) => + res.map((stat) => ({ status: stat.status, count: stat.count })), + ), + }; + } + + async addCronJobByProjectId(projectId: number) { + const { timezoneOffset } = await this.projectRepository.findOne({ + where: { id: projectId }, + }); + + const cronHour = (24 - Number(timezoneOffset.split(':')[0])) % 24; + + const job = new CronJob(`0 ${cronHour} * * *`, async () => { + await this.createIssueStatistics(projectId); + }); + this.schedulerRegistry.addCronJob(`issue-statistics-${projectId}`, job); + job.start(); + + this.logger.log(`issue-statistics-${projectId} cron job started`); + } + + @Transactional() + async createIssueStatistics(projectId: number, dayToCreate: number = 1) { + const { timezoneOffset, id } = await this.projectRepository.findOne({ + where: { id: projectId }, + }); + const [hours, minutes] = timezoneOffset.split(':'); + const offset = Number(hours) + Number(minutes) / 60; + + for (let day = 1; day <= dayToCreate; day++) { + const issueCount = await this.issueRepository.count({ + where: { + project: { id }, + createdAt: Between( + dayjs() + .subtract(day, 'day') + .startOf('day') + .subtract(offset, 'hour') + .toDate(), + dayjs() + .subtract(day, 'day') + .endOf('day') + .subtract(offset, 'hour') + .toDate(), + ), + }, + }); + + if (issueCount === 0) continue; + + await this.repository + .createQueryBuilder() + .insert() + .values({ + date: dayjs().subtract(day, 'day').toDate(), + count: issueCount, + project: { id }, + }) + .orUpdate(['count'], ['date', 'project']) + .updateEntity(false) + .execute(); + } + } + + @Transactional() + async updateCount(dto: UpdateCountDto) { + if (dto.count === 0) return; + if (!dto.count) dto.count = 1; + + const stats = await this.repository.findOne({ + where: { + date: new Date(dto.date.toISOString().split('T')[0] + 'T00:00:00'), + project: { id: dto.projectId }, + }, + }); + + if (stats) { + stats.count += dto.count; + await this.repository.save(stats); + return; + } else { + await this.repository + .createQueryBuilder() + .insert() + .values({ + date: new Date(dto.date.toISOString().split('T')[0] + 'T00:00:00'), + count: dto.count, + project: { id: dto.projectId }, + }) + .orUpdate(['count'], ['date', 'project']) + .updateEntity(false) + .execute(); + } + } +} diff --git a/apps/api/src/test-utils/providers/feedback-issue-statistics.service.providers.ts b/apps/api/src/test-utils/providers/feedback-issue-statistics.service.providers.ts new file mode 100644 index 000000000..8a7598e9b --- /dev/null +++ b/apps/api/src/test-utils/providers/feedback-issue-statistics.service.providers.ts @@ -0,0 +1,45 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { SchedulerRegistry } from '@nestjs/schedule'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { FeedbackEntity } from '@/domains/feedback/feedback.entity'; +import { IssueEntity } from '@/domains/project/issue/issue.entity'; +import { ProjectEntity } from '@/domains/project/project/project.entity'; +import { FeedbackIssueStatisticsEntity } from '@/domains/statistics/feedback-issue/feedback-issue-statistics.entity'; +import { FeedbackIssueStatisticsService } from '@/domains/statistics/feedback-issue/feedback-issue-statistics.service'; +import { mockRepository } from '@/test-utils/util-functions'; + +export const FeedbackIssueStatisticsServiceProviders = [ + FeedbackIssueStatisticsService, + { + provide: getRepositoryToken(FeedbackIssueStatisticsEntity), + useValue: mockRepository(), + }, + { + provide: getRepositoryToken(FeedbackEntity), + useValue: mockRepository(), + }, + { + provide: getRepositoryToken(IssueEntity), + useValue: mockRepository(), + }, + { + provide: getRepositoryToken(ProjectEntity), + useValue: mockRepository(), + }, + SchedulerRegistry, +]; diff --git a/apps/api/src/test-utils/providers/feedback.service.providers.ts b/apps/api/src/test-utils/providers/feedback.service.providers.ts index 96eee876d..3658900a5 100644 --- a/apps/api/src/test-utils/providers/feedback.service.providers.ts +++ b/apps/api/src/test-utils/providers/feedback.service.providers.ts @@ -27,6 +27,8 @@ import { FeedbackMySQLService } from '../../domains/feedback/feedback.mysql.serv import { FeedbackOSService } from '../../domains/feedback/feedback.os.service'; import { FeedbackService } from '../../domains/feedback/feedback.service'; import { ChannelServiceProviders } from './channel.service.providers'; +import { FeedbackIssueStatisticsServiceProviders } from './feedback-issue-statistics.service.providers'; +import { FeedbackStatisticsServiceProviders } from './feedback-statistics.service.providers'; import { FieldServiceProviders } from './field.service.providers'; import { IssueServiceProviders } from './issue.service.providers'; import { OptionServiceProviders } from './option.service.providers'; @@ -43,6 +45,8 @@ export const FeedbackServiceProviders = [ ...IssueServiceProviders, ...OptionServiceProviders, ...ChannelServiceProviders, + ...FeedbackStatisticsServiceProviders, + ...FeedbackIssueStatisticsServiceProviders, getMockProvider(OpensearchRepository, MockOpensearchRepository), FeedbackOSService, ]; diff --git a/apps/api/src/test-utils/providers/issue-statistics.service.providers.ts b/apps/api/src/test-utils/providers/issue-statistics.service.providers.ts new file mode 100644 index 000000000..05ff34ce8 --- /dev/null +++ b/apps/api/src/test-utils/providers/issue-statistics.service.providers.ts @@ -0,0 +1,40 @@ +/** + * Copyright 2023 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +import { SchedulerRegistry } from '@nestjs/schedule'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { IssueEntity } from '@/domains/project/issue/issue.entity'; +import { ProjectEntity } from '@/domains/project/project/project.entity'; +import { IssueStatisticsEntity } from '@/domains/statistics/issue/issue-statistics.entity'; +import { IssueStatisticsService } from '@/domains/statistics/issue/issue-statistics.service'; +import { mockRepository } from '@/test-utils/util-functions'; + +export const IssueStatisticsServiceProviders = [ + IssueStatisticsService, + { + provide: getRepositoryToken(IssueStatisticsEntity), + useValue: mockRepository(), + }, + { + provide: getRepositoryToken(IssueEntity), + useValue: mockRepository(), + }, + { + provide: getRepositoryToken(ProjectEntity), + useValue: mockRepository(), + }, + SchedulerRegistry, +]; diff --git a/apps/api/src/test-utils/providers/issue.service.providers.ts b/apps/api/src/test-utils/providers/issue.service.providers.ts index 955749d06..e986465c0 100644 --- a/apps/api/src/test-utils/providers/issue.service.providers.ts +++ b/apps/api/src/test-utils/providers/issue.service.providers.ts @@ -18,6 +18,7 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { mockRepository } from '@/test-utils/util-functions'; import { IssueEntity } from '../../domains/project/issue/issue.entity'; import { IssueService } from '../../domains/project/issue/issue.service'; +import { IssueStatisticsServiceProviders } from './issue-statistics.service.providers'; export const IssueServiceProviders = [ IssueService, @@ -25,4 +26,5 @@ export const IssueServiceProviders = [ provide: getRepositoryToken(IssueEntity), useValue: mockRepository(), }, + ...IssueStatisticsServiceProviders, ]; diff --git a/apps/api/src/test-utils/providers/project.service.providers.ts b/apps/api/src/test-utils/providers/project.service.providers.ts index 371181327..1850592f3 100644 --- a/apps/api/src/test-utils/providers/project.service.providers.ts +++ b/apps/api/src/test-utils/providers/project.service.providers.ts @@ -26,7 +26,9 @@ import { import { ProjectEntity } from '../../domains/project/project/project.entity'; import { ProjectService } from '../../domains/project/project/project.service'; import { ApiKeyServiceProviders } from './api-key.service.providers'; +import { FeedbackIssueStatisticsServiceProviders } from './feedback-issue-statistics.service.providers'; import { FeedbackStatisticsServiceProviders } from './feedback-statistics.service.providers'; +import { IssueStatisticsServiceProviders } from './issue-statistics.service.providers'; import { IssueTrackerServiceProviders } from './issue-tracker.service.provider'; import { MemberServiceProviders } from './member.service.providers'; import { RoleServiceProviders } from './role.service.providers'; @@ -48,4 +50,6 @@ export const ProjectServiceProviders = [ ...ApiKeyServiceProviders, ...IssueTrackerServiceProviders, ...FeedbackStatisticsServiceProviders, + ...IssueStatisticsServiceProviders, + ...FeedbackIssueStatisticsServiceProviders, ]; From 8233f10c9efc8522dee0acfe84161b3b7f24fe8f Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 1 Dec 2023 17:43:08 +0900 Subject: [PATCH 4/5] impl: excel download with specific fields (#88) --- .../channel/field/field.mysql.service.ts | 6 +- .../domains/channel/field/field.service.ts | 4 ++ .../feedback/dtos/generate-excel.dto.ts | 1 + .../requests/export-feedbacks-request.dto.ts | 9 ++- .../feedback/feedback.controller.spec.ts | 8 +-- .../domains/feedback/feedback.controller.ts | 23 +++--- .../src/domains/feedback/feedback.service.ts | 70 ++++++++++++++++--- 7 files changed, 92 insertions(+), 29 deletions(-) diff --git a/apps/api/src/domains/channel/field/field.mysql.service.ts b/apps/api/src/domains/channel/field/field.mysql.service.ts index 41feabdfc..6639ebb52 100644 --- a/apps/api/src/domains/channel/field/field.mysql.service.ts +++ b/apps/api/src/domains/channel/field/field.mysql.service.ts @@ -15,7 +15,7 @@ */ import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { Transactional } from 'typeorm-transactional'; import { @@ -213,4 +213,8 @@ export class FieldMySQLService { return createdFields; } + + async findByIds(ids: number[]) { + return await this.repository.findBy({ id: In(ids) }); + } } diff --git a/apps/api/src/domains/channel/field/field.service.ts b/apps/api/src/domains/channel/field/field.service.ts index 5ba87f23a..8bc242cd0 100644 --- a/apps/api/src/domains/channel/field/field.service.ts +++ b/apps/api/src/domains/channel/field/field.service.ts @@ -90,4 +90,8 @@ export class FieldService { }); } } + + async findByIds(ids: number[]) { + return this.fieldMySQLService.findByIds(ids); + } } diff --git a/apps/api/src/domains/feedback/dtos/generate-excel.dto.ts b/apps/api/src/domains/feedback/dtos/generate-excel.dto.ts index 866bb7727..74e347566 100644 --- a/apps/api/src/domains/feedback/dtos/generate-excel.dto.ts +++ b/apps/api/src/domains/feedback/dtos/generate-excel.dto.ts @@ -29,4 +29,5 @@ export class GenerateExcelDto { [key: string]: SortMethodEnum; }; type: 'xlsx' | 'csv'; + fieldIds?: number[]; } diff --git a/apps/api/src/domains/feedback/dtos/requests/export-feedbacks-request.dto.ts b/apps/api/src/domains/feedback/dtos/requests/export-feedbacks-request.dto.ts index c448ceebd..0a29bd6b6 100644 --- a/apps/api/src/domains/feedback/dtos/requests/export-feedbacks-request.dto.ts +++ b/apps/api/src/domains/feedback/dtos/requests/export-feedbacks-request.dto.ts @@ -14,10 +14,17 @@ * under the License. */ import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, IsOptional, IsString } from 'class-validator'; import { FindFeedbacksByChannelIdRequestDto } from './find-feedbacks-by-channel-id-request.dto'; export class ExportFeedbacksRequestDto extends FindFeedbacksByChannelIdRequestDto { + @ApiProperty() + @IsString() + type: 'xlsx' | 'csv'; + @ApiProperty({ required: false }) - type: string; + @IsOptional() + @IsArray() + fieldIds?: number[]; } diff --git a/apps/api/src/domains/feedback/feedback.controller.spec.ts b/apps/api/src/domains/feedback/feedback.controller.spec.ts index f4a043e20..c02f58706 100644 --- a/apps/api/src/domains/feedback/feedback.controller.spec.ts +++ b/apps/api/src/domains/feedback/feedback.controller.spec.ts @@ -114,13 +114,7 @@ describe('FeedbackController', () => { project: { name: faker.string.sample() }, } as ChannelEntity); - await feedbackController.exportFeedbacks( - channelId, - dto, - 'csv', - response, - userDto, - ); + await feedbackController.exportFeedbacks(channelId, dto, response, userDto); expect(MockFeedbackService.generateFile).toBeCalledTimes(1); }); diff --git a/apps/api/src/domains/feedback/feedback.controller.ts b/apps/api/src/domains/feedback/feedback.controller.ts index 752efbb70..c766e6f60 100644 --- a/apps/api/src/domains/feedback/feedback.controller.ts +++ b/apps/api/src/domains/feedback/feedback.controller.ts @@ -125,14 +125,24 @@ export class FeedbackController { async exportFeedbacks( @Param('channelId', ParseIntPipe) channelId: number, @Body() body: ExportFeedbacksRequestDto, - @Body('type') type: 'xlsx' | 'csv', @Res() res: FastifyReply, @CurrentUser() user: UserDto, ) { - const { query, sort } = body; + const { query, sort, type, fieldIds } = body; const channel = await this.channelService.findById({ channelId }); const projectName = channel.project.name; const channelName = channel.name; + + const { streamableFile, feedbackIds } = + await this.feedbackService.generateFile({ + channelId, + query, + sort, + type, + fieldIds, + }); + const stream = streamableFile.getStream(); + const filename = `UFB_${projectName}_${channelName}_Feedback_${dayjs().format( 'YYYY-MM-DD', )}.${type}`; @@ -146,15 +156,6 @@ export class FeedbackController { res.type('text/csv'); } - const { streamableFile, feedbackIds } = - await this.feedbackService.generateFile({ - channelId, - query, - sort, - type, - }); - const stream = streamableFile.getStream(); - res.send(stream); this.historyService.createHistory({ diff --git a/apps/api/src/domains/feedback/feedback.service.ts b/apps/api/src/domains/feedback/feedback.service.ts index b23955641..00e44640d 100644 --- a/apps/api/src/domains/feedback/feedback.service.ts +++ b/apps/api/src/domains/feedback/feedback.service.ts @@ -144,6 +144,7 @@ export class FeedbackService { private convertFeedback( feedback: any, fieldsByKey: Record, + fieldsToExport: FieldEntity[], ) { const convertedFeedback: Record = {}; for (const key of Object.keys(feedback)) { @@ -154,10 +155,22 @@ export class FeedbackService { : feedback[key]; } - return convertedFeedback; + return Object.keys(convertedFeedback) + .filter((key) => fieldsToExport.find((field) => field.key === key)) + .reduce((obj, key) => { + obj[key] = feedback[key]; + return obj; + }, {}); } - private async generateXLSXFile(channelId, query, sort, fields, fieldsByKey) { + private async generateXLSXFile({ + channelId, + query, + sort, + fields, + fieldsByKey, + fieldsToExport, + }) { if (!existsSync('/tmp')) { await fs.mkdir('/tmp'); } @@ -167,7 +180,7 @@ export class FeedbackService { }); const worksheet = workbook.addWorksheet('Sheet 1'); - const headers = fields.map((field) => ({ + const headers = fieldsToExport.map((field) => ({ header: field.name, key: field.name, })); @@ -210,7 +223,11 @@ export class FeedbackService { for (const feedback of feedbacks) { feedback.issues = issuesByFeedbackIds[feedback.id]; - const convertedFeedback = this.convertFeedback(feedback, fieldsByKey); + const convertedFeedback = this.convertFeedback( + feedback, + fieldsByKey, + fieldsToExport, + ); worksheet.addRow(convertedFeedback).commit(); feedbackIds.push(feedback.id); } @@ -227,7 +244,14 @@ export class FeedbackService { return { streamableFile: new StreamableFile(fileStream), feedbackIds }; } - private async generateCSVFile(channelId, query, sort, fields, fieldsByKey) { + private async generateCSVFile({ + channelId, + query, + sort, + fields, + fieldsByKey, + fieldsToExport, + }) { const stream = new PassThrough(); const csvStream = fastcsv.format({ headers: true }); @@ -270,7 +294,11 @@ export class FeedbackService { for (const feedback of feedbacks) { feedback.issues = issuesByFeedbackIds[feedback.id]; - const convertedFeedback = this.convertFeedback(feedback, fieldsByKey); + const convertedFeedback = this.convertFeedback( + feedback, + fieldsByKey, + fieldsToExport, + ); csvStream.write(convertedFeedback); feedbackIds.push(feedback.id); } @@ -519,11 +547,21 @@ export class FeedbackService { streamableFile: StreamableFile; feedbackIds: number[]; }> { - const { channelId, query, sort, type } = dto; + const { channelId, query, sort, type, fieldIds } = dto; + const fields = await this.fieldService.findByChannelId({ channelId: channelId, }); if (fields.length === 0) throw new BadRequestException('invalid channel'); + + let fieldsToExport = fields; + if (fieldIds) { + fieldsToExport = await this.fieldService.findByIds(fieldIds); + if (fields.length === 0) { + throw new BadRequestException('invalid fieldIds'); + } + } + this.validateQuery(query, fields); const fieldsByKey = fields.reduce( (prev: Record, field) => { @@ -534,9 +572,23 @@ export class FeedbackService { ); if (type === 'xlsx') { - return this.generateXLSXFile(channelId, query, sort, fields, fieldsByKey); + return this.generateXLSXFile({ + channelId, + query, + sort, + fields, + fieldsByKey, + fieldsToExport, + }); } else if (type === 'csv') { - return this.generateCSVFile(channelId, query, sort, fields, fieldsByKey); + return this.generateCSVFile({ + channelId, + query, + sort, + fields, + fieldsByKey, + fieldsToExport, + }); } } } From 4bd157bf4c37751cb168ab6935b53ea37162b122 Mon Sep 17 00:00:00 2001 From: Chiyoung Jeong Date: Mon, 4 Dec 2023 16:33:50 +0900 Subject: [PATCH 5/5] fix: web minor bugs (#89) * fix: input member tooltip * fix: input member tooltip i18n * feat: feedback detail i18n * feat: feedback download * fix: feedback download button --- .../requests/export-feedbacks-request.dto.ts | 2 +- .../src/domains/feedback/feedback.service.ts | 5 +- apps/web/.prettierignore | 1 + apps/web/public/locales/en/common.json | 4 +- apps/web/public/locales/ja/common.json | 4 +- apps/web/public/locales/ko/common.json | 4 +- .../etc/CheckedTableHead/CheckedTableHead.tsx | 2 + .../DescriptionTooltip/DescriptionTooltip.tsx | 12 +- .../create-project/InputIssueTracker.tsx | 1 + .../containers/create-project/InputMember.tsx | 11 +- .../DownloadButton/DownloadButton.tsx | 12 +- .../FeedbackDetail/FeedbackDetail.tsx | 6 +- .../tables/FeedbackTable/FeedbackTable.tsx | 22 +- .../FeedbackTableBar/FeedbackTableBar.tsx | 57 +- .../FeedbackTable/feedback-table-columns.tsx | 1 - apps/web/src/types/api.type.ts | 903 +++++++++--------- 16 files changed, 571 insertions(+), 476 deletions(-) create mode 100644 apps/web/.prettierignore diff --git a/apps/api/src/domains/feedback/dtos/requests/export-feedbacks-request.dto.ts b/apps/api/src/domains/feedback/dtos/requests/export-feedbacks-request.dto.ts index 0a29bd6b6..76cebfb6b 100644 --- a/apps/api/src/domains/feedback/dtos/requests/export-feedbacks-request.dto.ts +++ b/apps/api/src/domains/feedback/dtos/requests/export-feedbacks-request.dto.ts @@ -23,7 +23,7 @@ export class ExportFeedbacksRequestDto extends FindFeedbacksByChannelIdRequestDt @IsString() type: 'xlsx' | 'csv'; - @ApiProperty({ required: false }) + @ApiProperty({ required: false, type: [Number] }) @IsOptional() @IsArray() fieldIds?: number[]; diff --git a/apps/api/src/domains/feedback/feedback.service.ts b/apps/api/src/domains/feedback/feedback.service.ts index 00e44640d..321bedf1c 100644 --- a/apps/api/src/domains/feedback/feedback.service.ts +++ b/apps/api/src/domains/feedback/feedback.service.ts @@ -156,9 +156,9 @@ export class FeedbackService { } return Object.keys(convertedFeedback) - .filter((key) => fieldsToExport.find((field) => field.key === key)) + .filter((key) => fieldsToExport.find((field) => field.name === key)) .reduce((obj, key) => { - obj[key] = feedback[key]; + obj[key] = convertedFeedback[key]; return obj; }, {}); } @@ -228,6 +228,7 @@ export class FeedbackService { fieldsByKey, fieldsToExport, ); + worksheet.addRow(convertedFeedback).commit(); feedbackIds.push(feedback.id); } diff --git a/apps/web/.prettierignore b/apps/web/.prettierignore new file mode 100644 index 000000000..963bf1ba5 --- /dev/null +++ b/apps/web/.prettierignore @@ -0,0 +1 @@ +src/types/api.type.ts \ No newline at end of file diff --git a/apps/web/public/locales/en/common.json b/apps/web/public/locales/en/common.json index 09b452264..d7a899da9 100644 --- a/apps/web/public/locales/en/common.json +++ b/apps/web/public/locales/en/common.json @@ -220,6 +220,7 @@ "apiKeys": "Manage API Key information in the feedback collection API. If you collect feedback using API, please generate Key information.", "issueTracker": "UserFeedback feedback and Issue Tracking System can be linked and managed. Please enter your Issue Tracking System information." }, + "error-member": "User information that does not currently exist.", "continue-channel-creation": "UserFeedback must be created up to the Channel before UserFeedback is available.\nWould you like to continue creating a channel?", "guide": { "invalid-member": "Invalid Member List exists.", @@ -311,7 +312,8 @@ "no-channel": "There is no registered channel.", "guide": "Guide", "more": "more", - "shrink": "shrink" + "shrink": "shrink", + "feedback-detail": "Feedback Detail" }, "toast": { "sign-in": "Login Successful", diff --git a/apps/web/public/locales/ja/common.json b/apps/web/public/locales/ja/common.json index c80828f8d..74d5f6aae 100644 --- a/apps/web/public/locales/ja/common.json +++ b/apps/web/public/locales/ja/common.json @@ -220,6 +220,7 @@ "apiKeys": "フィードバック収集APIのAPI Key情報を管理します。 APIを活用してフィードバックを収集するなら、Key情報を生成してください。", "issueTracker": "UserFeedbackフィードバックとIssue Tracking Systemを接続して管理することができます。 使用中のIssue Tracking System情報を入力してください。" }, + "error-member": "現在存在しないユーザ情報です。", "continue-channel-creation": "Channelまで生成しないとUser Feedbackを使用できません。\nChannel生成を続けますか?", "guide": { "invalid-member": "無効なMemberリストが存在します。", @@ -311,7 +312,8 @@ "no-channel": "登録されたチャンネルがありません。", "guide": "案内", "more": "もっと", - "shrink": "縮む" + "shrink": "縮む", + "feedback-detail": "フィードバック詳細です" }, "toast": { "sign-in": "ログイン成功", diff --git a/apps/web/public/locales/ko/common.json b/apps/web/public/locales/ko/common.json index 31d8c1a3f..c4c8bf789 100644 --- a/apps/web/public/locales/ko/common.json +++ b/apps/web/public/locales/ko/common.json @@ -221,6 +221,7 @@ "apiKeys": "피드백 수집 API의 API Key 정보를 관리합니다. API를 활용해 피드백을 수집한다면 Key 정보를 생성해 주세요.", "issueTracker": "UserFeedback 피드백과 Issue Tracking System을 연결해서 관리할 수 있습니다. 사용 중인 Issue Tracking System 정보를 입력해 주세요." }, + "error-member": "현재 존재하지 않는 User 정보 입니다.", "continue-channel-creation": "Channel까지 생성해야 UserFeedback을 사용할 수 있습니다.\nChannel 생성을 이어서 하시겠어요?", "guide": { "invalid-member": "유효하지 않은 Member 목록이 존재합니다.", @@ -312,7 +313,8 @@ "no-channel": "등록된 Channel이 없습니다.", "guide": "안내", "more": "더보기", - "shrink": "줄이기" + "shrink": "줄이기", + "feedback-detail": "피드백 상세" }, "toast": { "sign-in": "로그인 성공", diff --git a/apps/web/src/components/etc/CheckedTableHead/CheckedTableHead.tsx b/apps/web/src/components/etc/CheckedTableHead/CheckedTableHead.tsx index 99c860220..2108f830b 100644 --- a/apps/web/src/components/etc/CheckedTableHead/CheckedTableHead.tsx +++ b/apps/web/src/components/etc/CheckedTableHead/CheckedTableHead.tsx @@ -29,6 +29,7 @@ interface IProps { projectId: number; channelId: number; ids: number[]; + fieldIds: number[]; }; disabled?: boolean; } @@ -73,6 +74,7 @@ const CheckedTableHead: React.FC = (props) => {
diff --git a/apps/web/src/components/etc/DescriptionTooltip/DescriptionTooltip.tsx b/apps/web/src/components/etc/DescriptionTooltip/DescriptionTooltip.tsx index 59b9b8492..e6d4a9159 100644 --- a/apps/web/src/components/etc/DescriptionTooltip/DescriptionTooltip.tsx +++ b/apps/web/src/components/etc/DescriptionTooltip/DescriptionTooltip.tsx @@ -19,18 +19,26 @@ import type { Placement } from '@floating-ui/react'; import { Icon, Tooltip, TooltipContent, TooltipTrigger } from '@ufb/ui'; export interface ITooltipProps { - description?: string; + description: string; placement?: Placement; + color?: 'red'; } const DescriptionTooltip: React.FC = ({ description, placement, + color, }) => { return ( - + {description} diff --git a/apps/web/src/containers/create-project/InputIssueTracker.tsx b/apps/web/src/containers/create-project/InputIssueTracker.tsx index 533db8580..5c4b9e901 100644 --- a/apps/web/src/containers/create-project/InputIssueTracker.tsx +++ b/apps/web/src/containers/create-project/InputIssueTracker.tsx @@ -107,6 +107,7 @@ const InputIssueTracker: React.FC = () => { userId: member.user.id, })), roles: input.roles, + timezoneOffset: '+90:00', }); }; diff --git a/apps/web/src/containers/create-project/InputMember.tsx b/apps/web/src/containers/create-project/InputMember.tsx index 6ea0e4edb..d25380d52 100644 --- a/apps/web/src/containers/create-project/InputMember.tsx +++ b/apps/web/src/containers/create-project/InputMember.tsx @@ -30,7 +30,7 @@ import { PopoverTrigger, } from '@ufb/ui'; -import { SelectBox, TableSortIcon } from '@/components'; +import { DescriptionTooltip, SelectBox, TableSortIcon } from '@/components'; import { useCreateProject } from '@/contexts/create-project.context'; import { useUserSearch } from '@/hooks'; import type { InputMemberType } from '@/types/member.type'; @@ -45,6 +45,8 @@ const columns = (deleteMember: (index: number) => void, users: UserType[]) => [ header: 'Email', enableSorting: false, cell: ({ getValue }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const { t } = useTranslation(); return ( <> {users.some((v) => v.email === getValue()) ? ( @@ -52,10 +54,9 @@ const columns = (deleteMember: (index: number) => void, users: UserType[]) => [ ) : (
{getValue()} -
)} diff --git a/apps/web/src/containers/tables/FeedbackTable/DownloadButton/DownloadButton.tsx b/apps/web/src/containers/tables/FeedbackTable/DownloadButton/DownloadButton.tsx index a2edc3fcb..2f371d9f8 100644 --- a/apps/web/src/containers/tables/FeedbackTable/DownloadButton/DownloadButton.tsx +++ b/apps/web/src/containers/tables/FeedbackTable/DownloadButton/DownloadButton.tsx @@ -27,13 +27,15 @@ import useFeedbackTable from '../feedback-table.context'; export interface IDownloadButtonProps { query: any; + fieldIds: number[]; count?: number; isHead?: boolean; } const DownloadButton: React.FC = ({ - count, query, + fieldIds, + count, isHead = false, }) => { const { channelId, projectId, createdAtRange } = useFeedbackTable(); @@ -49,10 +51,7 @@ const DownloadButton: React.FC = ({ }, [query]); const { mutateAsync } = useDownload({ - params: { - channelId, - projectId, - }, + params: { channelId, projectId }, options: { onSuccess: async () => { setIsClicked(false); @@ -71,8 +70,7 @@ const DownloadButton: React.FC = ({ toast.promise( mutateAsync({ type, - limit: count, - page: 1, + fieldIds, query: { ...query, createdAt: { diff --git a/apps/web/src/containers/tables/FeedbackTable/FeedbackDetail/FeedbackDetail.tsx b/apps/web/src/containers/tables/FeedbackTable/FeedbackDetail/FeedbackDetail.tsx index ac31cfb03..c1083c7ec 100644 --- a/apps/web/src/containers/tables/FeedbackTable/FeedbackDetail/FeedbackDetail.tsx +++ b/apps/web/src/containers/tables/FeedbackTable/FeedbackDetail/FeedbackDetail.tsx @@ -25,6 +25,7 @@ import { useRole, } from '@floating-ui/react'; import dayjs from 'dayjs'; +import { useTranslation } from 'react-i18next'; import { Badge, Icon } from '@ufb/ui'; @@ -44,6 +45,7 @@ interface IProps { } const FeedbackDetail: React.FC = (props) => { + const { t } = useTranslation(); const { channelId, id, projectId, onOpenChange, open } = props; const { data } = useFeedbackSearch(projectId, channelId, { query: { ids: [id] }, @@ -83,7 +85,9 @@ const FeedbackDetail: React.FC = (props) => { >
-

피드백 상세

+

+ {t('text.feedback-detail')} +