diff --git a/.eslintrc.js b/.eslintrc.js index 24e94823..b07a6c3f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -21,5 +21,54 @@ module.exports = { '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-inferrable-types': 'off', + '@typescript-eslint/ban-types': [ + 'error', + { + types: { + String: { + message: 'Use string instead', + fixWith: 'string', + }, + Boolean: { + message: 'Use boolean instead', + fixWith: 'boolean', + }, + Number: { + message: 'Use number instead', + fixWith: 'number', + }, + Symbol: { + message: 'Use symbol instead', + fixWith: 'symbol', + }, + + Function: { + message: [ + 'The `Function` type accepts any function-like value.', + 'It provides no type safety when calling the function, which can be a common source of bugs.', + 'It also accepts things like class declarations, which will throw at runtime as they will not be called with `new`.', + 'If you are expecting the function to accept certain arguments, you should explicitly define the function shape.', + ].join('\n'), + }, + + // object typing + Object: { + message: [ + 'The `Object` type actually means "any non-nullish value", so it is marginally better than `unknown`.', + '- If you want a type meaning "any object", you probably want `Record` instead.', + '- If you want a type meaning "any value", you probably want `unknown` instead.', + ].join('\n'), + }, + '{}': { + message: [ + '`{}` actually means "any non-nullish value".', + '- If you want a type meaning "any object", you probably want `Record` instead.', + '- If you want a type meaning "any value", you probably want `unknown` instead.', + ].join('\n'), + }, + object: false, + }, + }, + ], }, }; diff --git a/src/i18n/bg/status.json b/src/i18n/bg/status.json index 0716dfec..27d38bca 100644 --- a/src/i18n/bg/status.json +++ b/src/i18n/bg/status.json @@ -13,5 +13,6 @@ "BROADCAST_PENDING": "В изчакване", "BROADCAST_PROCESSING": "Обработва се", "BROADCAST_PUBLISHED": "Публикувано", - "BROADCAST_DISCARDED": "Отхвърлено" + "BROADCAST_DISCARDED": "Отхвърлено", + "OBJECT_ACCEPTED": "Прието" } diff --git a/src/i18n/en/status.json b/src/i18n/en/status.json index e1d0a905..643b814e 100644 --- a/src/i18n/en/status.json +++ b/src/i18n/en/status.json @@ -13,5 +13,6 @@ "BROADCAST_PENDING": "Pending", "BROADCAST_PROCESSING": "Processing", "BROADCAST_PUBLISHED": "Published", - "BROADCAST_DISCARDED": "Discarded" + "BROADCAST_DISCARDED": "Discarded", + "OBJECT_ACCEPTED": "Accepted" } diff --git a/src/migrations/1624337501383-AddTownsCountryMunicipalityIndices.ts b/src/migrations/1624337501383-AddTownsCountryMunicipalityIndices.ts new file mode 100644 index 00000000..478f5b3b --- /dev/null +++ b/src/migrations/1624337501383-AddTownsCountryMunicipalityIndices.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddTownsCountryMunicipalityIndices1624337501383 + implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + create index "towns_country_id_key" on "towns" ("country_id"); + create index "towns_municipality_id_key" on "towns" ("country_id"); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + drop index "towns_municipality_id_key"; + drop index "towns_country_id_key"; + `); + } +} diff --git a/src/sections/api/town-exists.constraint.ts b/src/sections/api/town-exists.constraint.ts index 726a0ce5..9e2f72a1 100644 --- a/src/sections/api/town-exists.constraint.ts +++ b/src/sections/api/town-exists.constraint.ts @@ -34,7 +34,7 @@ export class IsTownExistsConstraint implements ValidatorConstraintInterface { } export function IsTownExists(validationOptions?: ValidationOptions) { - return function (town: TownDto, propertyName: string) { + return function (town: TownDto | object, propertyName: string) { registerDecorator({ target: town.constructor, propertyName: propertyName, diff --git a/src/sections/entities/town.entity.ts b/src/sections/entities/town.entity.ts index 7a0b357c..22a23408 100644 --- a/src/sections/entities/town.entity.ts +++ b/src/sections/entities/town.entity.ts @@ -19,7 +19,7 @@ export class Town { readonly id: number; @Column() - readonly code: number; + code: number; @Column() readonly name: string; diff --git a/src/sections/sections.module.ts b/src/sections/sections.module.ts index 2cec453c..4140e0af 100644 --- a/src/sections/sections.module.ts +++ b/src/sections/sections.module.ts @@ -54,6 +54,7 @@ import { CityRegionsRepository } from './entities/cityRegions.repository'; MunicipalitiesRepository, CountriesRepository, CityRegionsRepository, + TownsRepository, ], controllers: [ SectionsController, diff --git a/src/users/api/organization-exists.constraint.ts b/src/users/api/organization-exists.constraint.ts index 8add9631..dd65b31e 100644 --- a/src/users/api/organization-exists.constraint.ts +++ b/src/users/api/organization-exists.constraint.ts @@ -32,7 +32,7 @@ export class IsOrganizationExistsConstraint } export function IsOrganizationExists(validationOptions?: ValidationOptions) { - return function (object: OrganizationDto, propertyName: string) { + return function (object: OrganizationDto | object, propertyName: string) { registerDecorator({ target: object.constructor, propertyName: propertyName, diff --git a/src/users/api/user-exists.constraint.ts b/src/users/api/user-exists.constraint.ts index 3afbf9c0..ada33ee5 100644 --- a/src/users/api/user-exists.constraint.ts +++ b/src/users/api/user-exists.constraint.ts @@ -24,14 +24,14 @@ export class IsUserExistsConstraint implements ValidatorConstraintInterface { } defaultMessage?(): string { - return `User with $property "$value" does not exist!`; + return `User with ID "$value" does not exist!`; } } export function IsUserExists(validationOptions?: ValidationOptions) { - return function (user: UserDto, propertyName: string) { + return function (target: UserDto | object, propertyName: string) { registerDecorator({ - target: user.constructor, + target: target.constructor, propertyName: propertyName, options: validationOptions, constraints: [], diff --git a/src/utils/ulid-constraint.ts b/src/utils/ulid-constraint.ts new file mode 100644 index 00000000..f4b40f03 --- /dev/null +++ b/src/utils/ulid-constraint.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@nestjs/common'; +import { + registerDecorator, + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +@ValidatorConstraint({ async: true }) +@Injectable() +export class IsULIDConstraint implements ValidatorConstraintInterface { + async validate(id?: string): Promise { + return typeof id === 'string' && /[A-Z0-9]{26}/.test(id); + } + + defaultMessage?(context: ValidationArguments): string { + console.debug(context.object); + return `${context.property} identifier is not an ULID`; + } +} + +export function IsULID(validationOptions?: ValidationOptions) { + return function (entity: object, propertyName: string) { + registerDecorator({ + target: entity.constructor, + propertyName: propertyName, + options: validationOptions, + constraints: [], + validator: IsULIDConstraint, + }); + }; +} diff --git a/src/violations/api/violation.dto.ts b/src/violations/api/violation.dto.ts index 55db90e5..2053beef 100644 --- a/src/violations/api/violation.dto.ts +++ b/src/violations/api/violation.dto.ts @@ -46,7 +46,8 @@ export class ViolationDto { @Expose({ groups: ['read', 'create'] }) @Type(() => TownDto) @Transform( - ({ value: id }) => plainToClass(TownDto, { id }, { groups: ['create'] }), + ({ value: id }) => + plainToClass(TownDto, { code: id }, { groups: ['create'] }), { groups: ['create'] }, ) @IsNotEmpty({ groups: ['create'] }) @@ -111,6 +112,7 @@ export class ViolationDto { groups: ['create'], }, ); + violation.town.code = this.town.id; let sortPosition = 1; violation.pictures = (violation.pictures || []).map( diff --git a/src/violations/api/violations-filters.dto.ts b/src/violations/api/violations-filters.dto.ts index b100ff03..9c088fc5 100644 --- a/src/violations/api/violations-filters.dto.ts +++ b/src/violations/api/violations-filters.dto.ts @@ -1,23 +1,62 @@ -import { Optional } from '@nestjs/common'; +import { Type } from 'class-transformer'; +import { + IsBooleanString, + IsInt, + IsNumberString, + IsOptional, + Length, +} from 'class-validator'; +import { IsTownExists } from 'src/sections/api/town-exists.constraint'; +import { IsOrganizationExists } from 'src/users/api/organization-exists.constraint'; +import { IsUserExists } from 'src/users/api/user-exists.constraint'; import { PageDTO } from 'src/utils/page.dto'; +import { IsULID } from 'src/utils/ulid-constraint'; import { ViolationStatus } from '../entities/violation.entity'; export class ViolationsFilters extends PageDTO { - @Optional() + @IsOptional() + @IsULID() + @IsUserExists() assignee: string; - @Optional() + @IsOptional() + @Length(1, 9) section: string; - @Optional() + @IsOptional() status: ViolationStatus; - @Optional() - author: string; + @IsOptional() + @IsNumberString() + @Length(2, 2) + electionRegion: string; - @Optional() + @IsOptional() + @IsNumberString() + @Length(2, 2) + municipality: string; + + @IsOptional() + @IsNumberString() + @Length(2, 2) + country: string; + + @IsOptional() + @IsTownExists() town: number; - @Optional() + @IsOptional() + @IsNumberString() + @Length(2, 2) + cityRegion: string; + + @IsOptional() + @IsInt() + @Type(() => Number) + @IsOrganizationExists() organization: number; + + @IsOptional() + @IsBooleanString() + published: boolean; } diff --git a/src/violations/api/violations.controller.ts b/src/violations/api/violations.controller.ts index cf0a97d2..02cffd51 100644 --- a/src/violations/api/violations.controller.ts +++ b/src/violations/api/violations.controller.ts @@ -38,7 +38,7 @@ export class ViolationsController { @Get() @HttpCode(200) @UseGuards(PoliciesGuard) - @CheckPolicies((ability: Ability) => ability.can(Action.Read, Violation)) + @CheckPolicies((ability: Ability) => ability.can(Action.Manage, Violation)) @UsePipes(new ValidationPipe({ transform: true })) async index( @Query() query: ViolationsFilters, diff --git a/src/violations/entities/violations.repository.ts b/src/violations/entities/violations.repository.ts index 4c35b696..f00bd083 100644 --- a/src/violations/entities/violations.repository.ts +++ b/src/violations/entities/violations.repository.ts @@ -5,11 +5,13 @@ import { User } from '../../users/entities'; import { Violation } from './violation.entity'; import { ViolationUpdateType } from './violation-update.entity'; import { ViolationsFilters } from '../api/violations-filters.dto'; +import { TownsRepository } from 'src/sections/entities/towns.repository'; @Injectable() export class ViolationsRepository { constructor( @InjectRepository(Violation) private readonly repo: Repository, + private readonly townsRepo: TownsRepository, ) {} findOneOrFail(id: string): Promise { @@ -32,11 +34,14 @@ export class ViolationsRepository { ): SelectQueryBuilder { const qb = this.repo.createQueryBuilder('violation'); - qb.leftJoinAndSelect('violation.section', 'section'); qb.innerJoinAndSelect('violation.town', 'town'); - qb.innerJoinAndSelect('violation.updates', 'update'); - qb.innerJoinAndSelect('update.actor', 'actor'); - qb.innerJoinAndSelect('actor.organization', 'organization'); + qb.innerJoinAndSelect('violation.updates', 'update_send'); + qb.andWhere('update_send.type = :update', { + update: ViolationUpdateType.SEND, + }); + qb.innerJoinAndSelect('update_send.actor', 'sender'); + qb.innerJoinAndSelect('sender.organization', 'organization'); + qb.innerJoinAndSelect('violation.updates', 'updates'); qb.leftJoinAndSelect('violation.pictures', 'picture'); if (filters.assignee) { @@ -47,44 +52,62 @@ export class ViolationsRepository { } if (filters.section) { + qb.innerJoinAndSelect('violation.section', 'section'); qb.andWhere('section.id LIKE :section', { section: `${filters.section}%`, }); + } else { + qb.leftJoinAndSelect('violation.section', 'section'); } - if (filters.status) { - qb.andWhere('violation.status = :status', { status: filters.status }); - } - - if (filters.town) { - qb.andWhere('town.code = :town', { town: filters.town }); - } - - if (filters.author || filters.organization) { - qb.innerJoin('violation.updates', 'update_send'); - qb.andWhere('update_send.type = :update', { - update: ViolationUpdateType.SEND, + if (filters.electionRegion) { + qb.innerJoin('town.municipality', 'municipality'); + qb.innerJoin('municipality.electionRegions', 'electionRegions'); + qb.andWhere('electionRegions.code = :electionRegion', { + electionRegion: filters.electionRegion, }); - if (filters.author) { - qb.andWhere('update_send.actor_id = :author', { - author: filters.author, + if (filters.municipality) { + qb.andWhere('municipality.code = :municipality', { + municipality: filters.municipality, }); } - if (filters.organization) { - qb.innerJoin('update_send.actor', 'sender'); - qb.innerJoin('sender.organization', 'organization'); - qb.andWhere('organization.id = :organization', { - organization: filters.organization, + if (filters.country) { + qb.innerJoin('town.country', 'country'); + qb.andWhere('country.code = :country', { country: filters.country }); + } + + if (filters.town) { + qb.andWhere('town.code = :town', { + town: filters.town, }); } } + if (filters.status) { + qb.andWhere('violation.status = :status', { status: filters.status }); + } + + if (filters.published) { + qb.andWhere('violation.isPublished = :published', { + published: filters.published, + }); + } + + if (filters.organization) { + qb.andWhere('organization.id = :organization', { + organization: filters.organization, + }); + } + return qb; } async save(violation: Violation): Promise { + if (violation.town && !violation.town.id && violation.town.code) { + violation.town = await this.townsRepo.findOneByCode(violation.town.code); + } await this.repo.save(violation); return this.findOneOrFail(violation.id); diff --git a/src/violations/violations.module.ts b/src/violations/violations.module.ts index bebcd5fd..680ab39b 100644 --- a/src/violations/violations.module.ts +++ b/src/violations/violations.module.ts @@ -11,6 +11,7 @@ import { ViolationUpdate } from './entities/violation-update.entity'; import { Violation } from './entities/violation.entity'; import { ViolationsRepository } from './entities/violations.repository'; import { UsersModule } from 'src/users/users.module'; +import { SectionsModule } from 'src/sections/sections.module'; @Module({ imports: [ @@ -18,6 +19,7 @@ import { UsersModule } from 'src/users/users.module'; CaslModule, PicturesModule, UsersModule, + SectionsModule, ], controllers: [ ViolationsController,