module provinces

This commit is contained in:
2026-07-07 19:44:31 +03:30
parent e2d9b5ef99
commit 45ab6a8dda
27 changed files with 3848 additions and 6 deletions
+2
View File
@@ -16,6 +16,7 @@ import { StylistsModule } from './stylists/stylists.module';
import { SubscriptionsModule } from './subscriptions/subscriptions.module'; import { SubscriptionsModule } from './subscriptions/subscriptions.module';
import { UsersModule } from './users/users.module'; import { UsersModule } from './users/users.module';
import { PaymentsModule } from './payments/payments.module'; import { PaymentsModule } from './payments/payments.module';
import { ProvincesModule } from './provinces/provinces.module';
import { UploaderModule } from './uploader/uploader.module'; import { UploaderModule } from './uploader/uploader.module';
@Module({ @Module({
@@ -38,6 +39,7 @@ import { UploaderModule } from './uploader/uploader.module';
SentMessagesModule, SentMessagesModule,
SubscriptionsModule, SubscriptionsModule,
PaymentsModule, PaymentsModule,
ProvincesModule,
UploaderModule, UploaderModule,
], ],
controllers: [], controllers: [],
+2
View File
@@ -7,6 +7,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthorizationModule } from '../authorization/authorization.module'; import { AuthorizationModule } from '../authorization/authorization.module';
import { Customer } from '../customers/entities/customer.entity'; import { Customer } from '../customers/entities/customer.entity';
import { Salon } from '../salons/entities/salon.entity'; import { Salon } from '../salons/entities/salon.entity';
import { ProvincesModule } from '../provinces/provinces.module';
import { SmsModule } from '../sms/sms.module'; import { SmsModule } from '../sms/sms.module';
import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
@@ -36,6 +37,7 @@ import { JwtStrategy } from './strategies/jwt.strategy';
SmsModule, SmsModule,
SubscriptionsModule, SubscriptionsModule,
AuthorizationModule, AuthorizationModule,
ProvincesModule,
], ],
controllers: [AuthController], controllers: [AuthController],
providers: [ providers: [
+18
View File
@@ -14,6 +14,7 @@ import * as bcrypt from 'bcrypt';
import { Repository, IsNull } from 'typeorm'; import { Repository, IsNull } from 'typeorm';
import { Customer } from '../customers/entities/customer.entity'; import { Customer } from '../customers/entities/customer.entity';
import { Salon } from '../salons/entities/salon.entity'; import { Salon } from '../salons/entities/salon.entity';
import { ProvincesService } from '../provinces/provinces.service';
import { SmsService } from '../sms/sms.service'; import { SmsService } from '../sms/sms.service';
import { SalonSubscription } from '../subscriptions/entities/salon-subscription.entity'; import { SalonSubscription } from '../subscriptions/entities/salon-subscription.entity';
import { SalonSubscriptionsService } from '../subscriptions/salon-subscriptions.service'; import { SalonSubscriptionsService } from '../subscriptions/salon-subscriptions.service';
@@ -69,6 +70,7 @@ export class AuthService {
private readonly customersRepository: Repository<Customer>, private readonly customersRepository: Repository<Customer>,
@InjectRepository(Salon) @InjectRepository(Salon)
private readonly salonsRepository: Repository<Salon>, private readonly salonsRepository: Repository<Salon>,
private readonly provincesService: ProvincesService,
private readonly usersService: UsersService, private readonly usersService: UsersService,
private readonly salonSubscriptionsService: SalonSubscriptionsService, private readonly salonSubscriptionsService: SalonSubscriptionsService,
private readonly smsService: SmsService, private readonly smsService: SmsService,
@@ -274,6 +276,7 @@ export class AuthService {
dto: VerifySalonOtpDto, dto: VerifySalonOtpDto,
): Promise<SalonAuthResponse> { ): Promise<SalonAuthResponse> {
this.validateSalonSignupFields(dto); this.validateSalonSignupFields(dto);
await this.validateSalonLocation(dto.salonProvinceId!, dto.salonCityId!);
const user = await this.usersService.create({ const user = await this.usersService.create({
firstName: dto.firstName!, firstName: dto.firstName!,
@@ -287,6 +290,8 @@ export class AuthService {
description: dto.salonDescription ?? null, description: dto.salonDescription ?? null,
address: dto.salonAddress!, address: dto.salonAddress!,
phone: dto.salonPhone!, phone: dto.salonPhone!,
provinceId: dto.salonProvinceId!,
cityId: dto.salonCityId!,
ownerId: user.id, ownerId: user.id,
}); });
@@ -301,6 +306,8 @@ export class AuthService {
'salonName', 'salonName',
'salonAddress', 'salonAddress',
'salonPhone', 'salonPhone',
'salonProvinceId',
'salonCityId',
]; ];
const missingFields = requiredFields.filter((field) => !dto[field]); const missingFields = requiredFields.filter((field) => !dto[field]);
@@ -312,6 +319,16 @@ export class AuthService {
} }
} }
private async validateSalonLocation(
provinceId: string,
cityId: string,
): Promise<void> {
const city = await this.provincesService.findCity(provinceId, cityId);
if (!city) {
throw new BadRequestException('شهر انتخاب‌شده با استان مطابقت ندارد');
}
}
async verifyOtp(dto: VerifyOtpDto): Promise<AuthTokensResponse> { async verifyOtp(dto: VerifyOtpDto): Promise<AuthTokensResponse> {
await this.consumeOtp(dto.mobile, dto.code); await this.consumeOtp(dto.mobile, dto.code);
@@ -390,6 +407,7 @@ export class AuthService {
if (user.role === UserRole.SALON) { if (user.role === UserRole.SALON) {
response.salons = await this.salonsRepository.find({ response.salons = await this.salonsRepository.find({
where: { ownerId: user.id }, where: { ownerId: user.id },
relations: { province: true, city: true },
order: { createdAt: 'ASC' }, order: { createdAt: 'ASC' },
}); });
response.activeSubscription = response.activeSubscription =
+9
View File
@@ -1,6 +1,7 @@
import { import {
IsOptional, IsOptional,
IsString, IsString,
IsUUID,
Length, Length,
Matches, Matches,
} from 'class-validator'; } from 'class-validator';
@@ -40,4 +41,12 @@ export class VerifySalonOtpDto {
@IsOptional() @IsOptional()
@Matches(/^09\d{9}$/) @Matches(/^09\d{9}$/)
salonPhone?: string; salonPhone?: string;
@IsOptional()
@IsUUID()
salonProvinceId?: string;
@IsOptional()
@IsUUID()
salonCityId?: string;
} }
@@ -5,6 +5,7 @@ import { AuthorizationService } from './authorization.service';
import { Permission } from './entities/permission.entity'; import { Permission } from './entities/permission.entity';
import { UserPermission } from './entities/user-permission.entity'; import { UserPermission } from './entities/user-permission.entity';
import { CustomerPolicy } from './policies/customer.policy'; import { CustomerPolicy } from './policies/customer.policy';
import { ProvincePolicy } from './policies/province.policy';
import { ReservePolicy } from './policies/reserve.policy'; import { ReservePolicy } from './policies/reserve.policy';
import { SalonPolicy } from './policies/salon.policy'; import { SalonPolicy } from './policies/salon.policy';
import { ServicePolicy } from './policies/service.policy'; import { ServicePolicy } from './policies/service.policy';
@@ -17,6 +18,7 @@ import { SubscriptionPolicy } from './policies/subscription.policy';
AuthorizationService, AuthorizationService,
SalonPolicy, SalonPolicy,
ServicePolicy, ServicePolicy,
ProvincePolicy,
CustomerPolicy, CustomerPolicy,
StylistPolicy, StylistPolicy,
ReservePolicy, ReservePolicy,
@@ -26,6 +28,7 @@ import { SubscriptionPolicy } from './policies/subscription.policy';
AuthorizationService, AuthorizationService,
SalonPolicy, SalonPolicy,
ServicePolicy, ServicePolicy,
ProvincePolicy,
CustomerPolicy, CustomerPolicy,
StylistPolicy, StylistPolicy,
ReservePolicy, ReservePolicy,
@@ -16,6 +16,9 @@ export enum PermissionCode {
SERVICES_READ = 'services:read', SERVICES_READ = 'services:read',
SERVICES_WRITE = 'services:write', SERVICES_WRITE = 'services:write',
SERVICES_DELETE = 'services:delete', SERVICES_DELETE = 'services:delete',
PROVINCES_READ = 'provinces:read',
PROVINCES_WRITE = 'provinces:write',
PROVINCES_DELETE = 'provinces:delete',
RESERVES_READ = 'reserves:read', RESERVES_READ = 'reserves:read',
RESERVES_WRITE = 'reserves:write', RESERVES_WRITE = 'reserves:write',
SUBSCRIPTIONS_READ = 'subscriptions:read', SUBSCRIPTIONS_READ = 'subscriptions:read',
@@ -0,0 +1,12 @@
import { Injectable } from '@nestjs/common';
import { AuthUser } from '../../auth/interfaces/auth-user.interface';
import { AuthorizationService } from '../authorization.service';
@Injectable()
export class ProvincePolicy {
constructor(private readonly authorizationService: AuthorizationService) {}
canManage(user: AuthUser): boolean {
return this.authorizationService.isAdmin(user);
}
}
+3 -1
View File
@@ -1,11 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { Customer } from '../customers/entities/customer.entity'; import { Customer } from '../customers/entities/customer.entity';
import { City } from '../provinces/entities/city.entity';
import { Province } from '../provinces/entities/province.entity';
import { User } from '../users/entities/user.entity'; import { User } from '../users/entities/user.entity';
import { SeedService } from './seed.service'; import { SeedService } from './seed.service';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([User, Customer])], imports: [TypeOrmModule.forFeature([User, Customer, Province, City])],
providers: [SeedService], providers: [SeedService],
}) })
export class DatabaseModule {} export class DatabaseModule {}
@@ -0,0 +1,111 @@
import {
MigrationInterface,
QueryRunner,
Table,
TableForeignKey,
} from 'typeorm';
export class AddProvincesAndCities1721000000000 implements MigrationInterface {
name = 'AddProvincesAndCities1721000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'provinces',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
generationStrategy: 'uuid',
default: 'gen_random_uuid()',
},
{ name: 'name', type: 'varchar', length: '100', isUnique: true },
{ name: 'slug', type: 'varchar', length: '100', isUnique: true },
{ name: 'createdAt', type: 'timestamp', default: 'now()' },
{ name: 'updatedAt', type: 'timestamp', default: 'now()' },
],
}),
true,
);
await queryRunner.createTable(
new Table({
name: 'cities',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
generationStrategy: 'uuid',
default: 'gen_random_uuid()',
},
{ name: 'provinceId', type: 'uuid' },
{ name: 'name', type: 'varchar', length: '100' },
{ name: 'slug', type: 'varchar', length: '100', isUnique: true },
{ name: 'createdAt', type: 'timestamp', default: 'now()' },
{ name: 'updatedAt', type: 'timestamp', default: 'now()' },
],
}),
true,
);
await queryRunner.createForeignKey(
'cities',
new TableForeignKey({
columnNames: ['provinceId'],
referencedTableName: 'provinces',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
await queryRunner.query(`
ALTER TABLE "salons"
ADD COLUMN "provinceId" uuid,
ADD COLUMN "cityId" uuid
`);
await queryRunner.createForeignKey(
'salons',
new TableForeignKey({
columnNames: ['provinceId'],
referencedTableName: 'provinces',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
}),
);
await queryRunner.createForeignKey(
'salons',
new TableForeignKey({
columnNames: ['cityId'],
referencedTableName: 'cities',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const salonsTable = await queryRunner.getTable('salons');
if (salonsTable) {
for (const fk of salonsTable.foreignKeys.filter(
(key) =>
key.columnNames.includes('provinceId') ||
key.columnNames.includes('cityId'),
)) {
await queryRunner.dropForeignKey('salons', fk);
}
}
await queryRunner.query(`
ALTER TABLE "salons"
DROP COLUMN IF EXISTS "provinceId",
DROP COLUMN IF EXISTS "cityId"
`);
await queryRunner.dropTable('cities', true);
await queryRunner.dropTable('provinces', true);
}
}
+70
View File
@@ -1,10 +1,34 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { City } from '../provinces/entities/city.entity';
import { Province } from '../provinces/entities/province.entity';
import { Customer } from '../customers/entities/customer.entity'; import { Customer } from '../customers/entities/customer.entity';
import { User } from '../users/entities/user.entity'; import { User } from '../users/entities/user.entity';
import { UserRole } from '../users/enums/user-role.enum'; import { UserRole } from '../users/enums/user-role.enum';
import { ADMIN_USERS_SEED } from './seeds/admin-users.seed'; import { ADMIN_USERS_SEED } from './seeds/admin-users.seed';
import iranCitiesSeed from './seeds/data/iran-cities.json';
import iranProvincesSeed from './seeds/data/iran-provinces.json';
type IranProvinceSeed = {
id: number;
title: string;
slug: string;
};
type IranCitySeed = {
id: number;
title: string;
slug: string;
province_id: number;
};
const slugify = (value: string) =>
value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
@Injectable() @Injectable()
export class SeedService implements OnModuleInit { export class SeedService implements OnModuleInit {
@@ -15,10 +39,15 @@ export class SeedService implements OnModuleInit {
private readonly usersRepository: Repository<User>, private readonly usersRepository: Repository<User>,
@InjectRepository(Customer) @InjectRepository(Customer)
private readonly customersRepository: Repository<Customer>, private readonly customersRepository: Repository<Customer>,
@InjectRepository(Province)
private readonly provincesRepository: Repository<Province>,
@InjectRepository(City)
private readonly citiesRepository: Repository<City>,
) {} ) {}
async onModuleInit(): Promise<void> { async onModuleInit(): Promise<void> {
await this.seedAdminUsers(); await this.seedAdminUsers();
await this.seedIranLocations();
} }
private async seedAdminUsers(): Promise<void> { private async seedAdminUsers(): Promise<void> {
@@ -55,4 +84,45 @@ export class SeedService implements OnModuleInit {
} }
} }
} }
private async seedIranLocations(): Promise<void> {
const existingCount = await this.provincesRepository.count();
if (existingCount > 0) {
return;
}
const provinceIdMap = new Map<number, string>();
const usedCitySlugs = new Set<string>();
for (const provinceSeed of iranProvincesSeed as IranProvinceSeed[]) {
const province = await this.provincesRepository.save({
name: provinceSeed.title.trim(),
slug: slugify(provinceSeed.slug || provinceSeed.title),
});
provinceIdMap.set(provinceSeed.id, province.id);
}
for (const citySeed of iranCitiesSeed as IranCitySeed[]) {
const provinceId = provinceIdMap.get(citySeed.province_id);
if (!provinceId) {
continue;
}
let slug = slugify(citySeed.slug || citySeed.title);
if (usedCitySlugs.has(slug)) {
slug = `${slug}-${citySeed.id}`;
}
usedCitySlugs.add(slug);
await this.citiesRepository.save({
provinceId,
name: citySeed.title.trim(),
slug,
});
}
this.logger.log(
`Seeded ${provinceIdMap.size} provinces and ${usedCitySlugs.size} cities`,
);
}
} }
File diff suppressed because it is too large Load Diff
+219
View File
@@ -0,0 +1,219 @@
[
{
"id": 4,
"title": "آذربایجان شرقی",
"slug": "East Azarbaijan",
"latitude": 37.9035733,
"longitude": 46.2682109
},
{
"id": 5,
"title": "آذربایجان غربی",
"slug": "Western Azerbaijan",
"latitude": 37.7208556,
"longitude": 45.0126837
},
{
"id": 25,
"title": "اردبیل",
"slug": "Ardabil",
"latitude": 38.4853276,
"longitude": 47.8911209
},
{
"id": 11,
"title": "اصفهان",
"slug": "Esfahan",
"latitude": 32.6546275,
"longitude": 51.6679826
},
{
"id": 31,
"title": "البرز",
"slug": "Alborz",
"latitude": 35.9960467,
"longitude": 50.9289246
},
{
"id": 17,
"title": "ایلام",
"slug": "Ilam",
"latitude": 33.2957618,
"longitude": 46.670534
},
{
"id": 19,
"title": "بوشهر",
"slug": "Bushehr",
"latitude": 28.9233837,
"longitude": 50.820314
},
{
"id": 24,
"title": "تهران",
"slug": "Tehran",
"latitude": 35.696111,
"longitude": 51.423056
},
{
"id": 30,
"title": "خراسان جنوبی",
"slug": "South Khorasan",
"latitude": 32.5175643,
"longitude": 59.1041758
},
{
"id": 10,
"title": "خراسان رضوی",
"slug": "Khorasan Razavi",
"latitude": 35.5160063,
"longitude": 59.2230448
},
{
"id": 29,
"title": "خراسان شمالی",
"slug": "North Khorasan",
"latitude": 37.4710353,
"longitude": 57.1013188
},
{
"id": 7,
"title": "خوزستان",
"slug": "Khuzestan",
"latitude": 31.4360149,
"longitude": 49.041312
},
{
"id": 20,
"title": "زنجان",
"slug": "Zanjan",
"latitude": 36.5018185,
"longitude": 48.3988186
},
{
"id": 21,
"title": "سمنان",
"slug": "Semnan",
"latitude": 35.2255585,
"longitude": 54.4342138
},
{
"id": 12,
"title": "سیستان وبلوچستان",
"slug": "Sistan and Baluchistan",
"latitude": 27.5299906,
"longitude": 60.5820676
},
{
"id": 8,
"title": "فارس",
"slug": "Fars",
"latitude": 29.1043813,
"longitude": 53.045893
},
{
"id": 27,
"title": "قزوین",
"slug": "Qazvin",
"latitude": 36.0881317,
"longitude": 49.8547266
},
{
"id": 26,
"title": "قم",
"slug": "Qom",
"latitude": 34.6399443,
"longitude": 50.8759419
},
{
"id": 13,
"title": "کردستان",
"slug": "Kurdistan",
"latitude": 35.6680062,
"longitude": 47.0027204
},
{
"id": 9,
"title": "کرمان",
"slug": "Kerman",
"latitude": 30.2839379,
"longitude": 57.0833628
},
{
"id": 6,
"title": "کرمانشاه",
"slug": "Kermanshah",
"latitude": 34.314167,
"longitude": 47.065
},
{
"id": 18,
"title": "کهگیلویه و بویراحمد",
"slug": "Kohgiloyeh Boyerahmad",
"latitude": 30.6509479,
"longitude": 51.60525
},
{
"id": 28,
"title": "گلستان",
"slug": "Golestan",
"latitude": 37.2898123,
"longitude": 55.1375834
},
{
"id": 2,
"title": "گیلان",
"slug": "Gilan",
"latitude": 37.2809,
"longitude": 49.5924
},
{
"id": 16,
"title": "لرستان",
"slug": "Lorestan",
"latitude": 33.4529829,
"longitude": 48.306995
},
{
"id": 3,
"title": "مازندران",
"slug": "Mazandaran",
"latitude": 36.2262393,
"longitude": 52.5318604
},
{
"id": 1,
"title": "مرکزی",
"slug": "Markazi",
"latitude": 34.6123,
"longitude": 49.8547
},
{
"id": 23,
"title": "هرمزگان",
"slug": "Hormozgan",
"latitude": 27.138723,
"longitude": 55.1375834
},
{
"id": 14,
"title": "همدان",
"slug": "Hamedan",
"latitude": 34.9277212,
"longitude": 48.6655971
},
{
"id": 15,
"title": "چهارمحال و بختیاری",
"slug": "Chaharmahal and Bakhtiari ",
"latitude": 31.9614348,
"longitude": 50.8456323
},
{
"id": 22,
"title": "یزد",
"slug": "Yazd",
"latitude": 32.0025525,
"longitude": 54.687334
}
]
+13
View File
@@ -0,0 +1,13 @@
import { IsNotEmpty, IsString, Length } from 'class-validator';
export class CreateCityDto {
@IsString()
@Length(1, 100)
@IsNotEmpty()
name: string;
@IsString()
@Length(1, 100)
@IsNotEmpty()
slug: string;
}
+13
View File
@@ -0,0 +1,13 @@
import { IsNotEmpty, IsString, Length } from 'class-validator';
export class CreateProvinceDto {
@IsString()
@Length(1, 100)
@IsNotEmpty()
name: string;
@IsString()
@Length(1, 100)
@IsNotEmpty()
slug: string;
}
+13
View File
@@ -0,0 +1,13 @@
import { IsOptional, IsString, Length } from 'class-validator';
export class UpdateCityDto {
@IsOptional()
@IsString()
@Length(1, 100)
name?: string;
@IsOptional()
@IsString()
@Length(1, 100)
slug?: string;
}
+13
View File
@@ -0,0 +1,13 @@
import { IsOptional, IsString, Length } from 'class-validator';
export class UpdateProvinceDto {
@IsOptional()
@IsString()
@Length(1, 100)
name?: string;
@IsOptional()
@IsString()
@Length(1, 100)
slug?: string;
}
+37
View File
@@ -0,0 +1,37 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Province } from './province.entity';
@Entity('cities')
export class City {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
provinceId: string;
@ManyToOne(() => Province, (province) => province.cities, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'provinceId' })
province: Province;
@Column({ length: 100 })
name: string;
@Column({ length: 100, unique: true })
slug: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
+30
View File
@@ -0,0 +1,30 @@
import {
Column,
CreateDateColumn,
Entity,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { City } from './city.entity';
@Entity('provinces')
export class Province {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 100, unique: true })
name: string;
@Column({ length: 100, unique: true })
slug: string;
@OneToMany(() => City, (city) => city.province)
cities: City[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
+155
View File
@@ -0,0 +1,155 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Public } from '../auth/decorators/public.decorator';
import { Permissions } from '../auth/decorators/permissions.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import { PermissionsGuard } from '../auth/guards/permissions.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { AuthUser } from '../auth/interfaces/auth-user.interface';
import { PermissionCode } from '../authorization/enums/permission-code.enum';
import { UserRole } from '../users/enums/user-role.enum';
import { CreateCityDto } from './dto/create-city.dto';
import { CreateProvinceDto } from './dto/create-province.dto';
import { UpdateCityDto } from './dto/update-city.dto';
import { UpdateProvinceDto } from './dto/update-province.dto';
import { City } from './entities/city.entity';
import { Province } from './entities/province.entity';
import { ProvincesService } from './provinces.service';
import { ApiPublicEndpoint } from '../swagger/decorators/swagger-auth.decorator';
import { SWAGGER_JWT_AUTH } from '../swagger/swagger.setup';
import {
ApiStandardController,
ApiStandardEmptyResponse,
ApiStandardOkResponse,
} from '../swagger/decorators/swagger-response.decorator';
@ApiTags('Provinces')
@ApiBearerAuth(SWAGGER_JWT_AUTH)
@ApiStandardController()
@Controller('provinces')
@UseGuards(RolesGuard, PermissionsGuard)
export class ProvincesController {
constructor(private readonly provincesService: ProvincesService) {}
@Roles(UserRole.ADMIN)
@Permissions(PermissionCode.PROVINCES_WRITE)
@ApiStandardOkResponse(Province)
@Post()
create(
@Body() createProvinceDto: CreateProvinceDto,
@CurrentUser() user: AuthUser,
) {
return this.provincesService.create(createProvinceDto, user);
}
@Public()
@ApiPublicEndpoint('List all provinces')
@ApiStandardOkResponse(Province, { isArray: true })
@Get()
findAll() {
return this.provincesService.findAll();
}
@Public()
@ApiPublicEndpoint('Get province by ID')
@ApiStandardOkResponse(Province)
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.provincesService.findOne(id);
}
@Roles(UserRole.ADMIN)
@Permissions(PermissionCode.PROVINCES_WRITE)
@ApiStandardOkResponse(Province)
@Patch(':id')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateProvinceDto: UpdateProvinceDto,
@CurrentUser() user: AuthUser,
) {
return this.provincesService.update(id, updateProvinceDto, user);
}
@Roles(UserRole.ADMIN)
@Permissions(PermissionCode.PROVINCES_DELETE)
@ApiStandardEmptyResponse()
@Delete(':id')
remove(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUser,
) {
return this.provincesService.remove(id, user);
}
@Roles(UserRole.ADMIN)
@Permissions(PermissionCode.PROVINCES_WRITE)
@ApiStandardOkResponse(City)
@Post(':provinceId/cities')
createCity(
@Param('provinceId', ParseUUIDPipe) provinceId: string,
@Body() createCityDto: CreateCityDto,
@CurrentUser() user: AuthUser,
) {
return this.provincesService.createCity(provinceId, createCityDto, user);
}
@Public()
@ApiPublicEndpoint('List cities for a province')
@ApiStandardOkResponse(City, { isArray: true })
@Get(':provinceId/cities')
findCities(@Param('provinceId', ParseUUIDPipe) provinceId: string) {
return this.provincesService.findCities(provinceId);
}
@Public()
@ApiPublicEndpoint('Get a city by ID')
@ApiStandardOkResponse(City)
@Get(':provinceId/cities/:cityId')
findCity(
@Param('provinceId', ParseUUIDPipe) provinceId: string,
@Param('cityId', ParseUUIDPipe) cityId: string,
) {
return this.provincesService.findCity(provinceId, cityId);
}
@Roles(UserRole.ADMIN)
@Permissions(PermissionCode.PROVINCES_WRITE)
@ApiStandardOkResponse(City)
@Patch(':provinceId/cities/:cityId')
updateCity(
@Param('provinceId', ParseUUIDPipe) provinceId: string,
@Param('cityId', ParseUUIDPipe) cityId: string,
@Body() updateCityDto: UpdateCityDto,
@CurrentUser() user: AuthUser,
) {
return this.provincesService.updateCity(
provinceId,
cityId,
updateCityDto,
user,
);
}
@Roles(UserRole.ADMIN)
@Permissions(PermissionCode.PROVINCES_DELETE)
@ApiStandardEmptyResponse()
@Delete(':provinceId/cities/:cityId')
removeCity(
@Param('provinceId', ParseUUIDPipe) provinceId: string,
@Param('cityId', ParseUUIDPipe) cityId: string,
@CurrentUser() user: AuthUser,
) {
return this.provincesService.removeCity(provinceId, cityId, user);
}
}
+20
View File
@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthGuardsModule } from '../auth/auth-guards.module';
import { AuthorizationModule } from '../authorization/authorization.module';
import { City } from './entities/city.entity';
import { Province } from './entities/province.entity';
import { ProvincesController } from './provinces.controller';
import { ProvincesService } from './provinces.service';
@Module({
imports: [
TypeOrmModule.forFeature([Province, City]),
AuthorizationModule,
AuthGuardsModule,
],
controllers: [ProvincesController],
providers: [ProvincesService],
exports: [ProvincesService],
})
export class ProvincesModule {}
+146
View File
@@ -0,0 +1,146 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ProvincePolicy } from '../authorization/policies/province.policy';
import { AuthUser } from '../auth/interfaces/auth-user.interface';
import { CreateCityDto } from './dto/create-city.dto';
import { CreateProvinceDto } from './dto/create-province.dto';
import { UpdateCityDto } from './dto/update-city.dto';
import { UpdateProvinceDto } from './dto/update-province.dto';
import { City } from './entities/city.entity';
import { Province } from './entities/province.entity';
@Injectable()
export class ProvincesService {
constructor(
@InjectRepository(Province)
private readonly provincesRepository: Repository<Province>,
@InjectRepository(City)
private readonly citiesRepository: Repository<City>,
private readonly provincePolicy: ProvincePolicy,
) {}
create(
createProvinceDto: CreateProvinceDto,
user: AuthUser,
): Promise<Province> {
this.ensureCanManage(user);
const province = this.provincesRepository.create(createProvinceDto);
return this.provincesRepository
.save(province)
.then((saved) => this.findOne(saved.id));
}
findAll(): Promise<Province[]> {
return this.provincesRepository.find({
relations: { cities: true },
order: { name: 'ASC', cities: { name: 'ASC' } },
});
}
async findOne(id: string): Promise<Province> {
const province = await this.provincesRepository.findOne({
where: { id },
relations: { cities: true },
order: { cities: { name: 'ASC' } },
});
if (!province) {
throw new NotFoundException(`Province #${id} not found`);
}
return province;
}
async update(
id: string,
updateProvinceDto: UpdateProvinceDto,
user: AuthUser,
): Promise<Province> {
this.ensureCanManage(user);
const province = await this.findOne(id);
Object.assign(province, updateProvinceDto);
await this.provincesRepository.save(province);
return this.findOne(id);
}
async remove(id: string, user: AuthUser): Promise<void> {
this.ensureCanManage(user);
const province = await this.findOne(id);
await this.provincesRepository.remove(province);
}
async createCity(
provinceId: string,
createCityDto: CreateCityDto,
user: AuthUser,
): Promise<City> {
this.ensureCanManage(user);
await this.findOne(provinceId);
const city = this.citiesRepository.create({
...createCityDto,
provinceId,
});
const saved = await this.citiesRepository.save(city);
return this.findCity(provinceId, saved.id);
}
async findCities(provinceId: string): Promise<City[]> {
await this.ensureProvinceExists(provinceId);
return this.citiesRepository.find({
where: { provinceId },
order: { name: 'ASC' },
});
}
async findCity(provinceId: string, cityId: string): Promise<City> {
const city = await this.citiesRepository.findOne({
where: { id: cityId, provinceId },
relations: { province: true },
});
if (!city) {
throw new NotFoundException(
`City #${cityId} not found for province #${provinceId}`,
);
}
return city;
}
async updateCity(
provinceId: string,
cityId: string,
updateCityDto: UpdateCityDto,
user: AuthUser,
): Promise<City> {
this.ensureCanManage(user);
const city = await this.findCity(provinceId, cityId);
Object.assign(city, updateCityDto);
await this.citiesRepository.save(city);
return this.findCity(provinceId, cityId);
}
async removeCity(
provinceId: string,
cityId: string,
user: AuthUser,
): Promise<void> {
this.ensureCanManage(user);
const city = await this.findCity(provinceId, cityId);
await this.citiesRepository.remove(city);
}
private ensureCanManage(user: AuthUser): void {
if (!this.provincePolicy.canManage(user)) {
throw new ForbiddenException();
}
}
private async ensureProvinceExists(provinceId: string): Promise<void> {
const exists = await this.provincesRepository.existsBy({ id: provinceId });
if (!exists) {
throw new NotFoundException(`Province #${provinceId} not found`);
}
}
}
+6
View File
@@ -25,6 +25,12 @@ export class CreateSalonDto {
@Matches(/^09\d{9}$/) @Matches(/^09\d{9}$/)
phone: string; phone: string;
@IsUUID()
provinceId: string;
@IsUUID()
cityId: string;
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
ownerId?: string; ownerId?: string;
+9 -1
View File
@@ -1,4 +1,4 @@
import { IsOptional, IsString, Length, Matches } from 'class-validator'; import { IsOptional, IsString, IsUUID, Length, Matches } from 'class-validator';
export class UpdateSalonDto { export class UpdateSalonDto {
@IsOptional() @IsOptional()
@@ -18,4 +18,12 @@ export class UpdateSalonDto {
@IsOptional() @IsOptional()
@Matches(/^09\d{9}$/) @Matches(/^09\d{9}$/)
phone?: string; phone?: string;
@IsOptional()
@IsUUID()
provinceId?: string;
@IsOptional()
@IsUUID()
cityId?: string;
} }
+16
View File
@@ -7,6 +7,8 @@ import {
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
import { City } from '../../provinces/entities/city.entity';
import { Province } from '../../provinces/entities/province.entity';
import { User } from '../../users/entities/user.entity'; import { User } from '../../users/entities/user.entity';
@Entity('salons') @Entity('salons')
@@ -33,6 +35,20 @@ export class Salon {
@JoinColumn({ name: 'ownerId' }) @JoinColumn({ name: 'ownerId' })
owner: User; owner: User;
@Column({ nullable: true })
provinceId: string | null;
@ManyToOne(() => Province, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'provinceId' })
province: Province | null;
@Column({ nullable: true })
cityId: string | null;
@ManyToOne(() => City, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'cityId' })
city: City | null;
@CreateDateColumn() @CreateDateColumn()
createdAt: Date; createdAt: Date;
+2
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthGuardsModule } from '../auth/auth-guards.module'; import { AuthGuardsModule } from '../auth/auth-guards.module';
import { AuthorizationModule } from '../authorization/authorization.module'; import { AuthorizationModule } from '../authorization/authorization.module';
import { ProvincesModule } from '../provinces/provinces.module';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
import { Salon } from './entities/salon.entity'; import { Salon } from './entities/salon.entity';
import { SalonsController } from './salons.controller'; import { SalonsController } from './salons.controller';
@@ -11,6 +12,7 @@ import { SalonsService } from './salons.service';
imports: [ imports: [
TypeOrmModule.forFeature([Salon]), TypeOrmModule.forFeature([Salon]),
UsersModule, UsersModule,
ProvincesModule,
AuthorizationModule, AuthorizationModule,
AuthGuardsModule, AuthGuardsModule,
], ],
+23 -3
View File
@@ -7,6 +7,7 @@ import {
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { SalonPolicy } from '../authorization/policies/salon.policy'; import { SalonPolicy } from '../authorization/policies/salon.policy';
import { ProvincesService } from '../provinces/provinces.service';
import { AuthUser } from '../auth/interfaces/auth-user.interface'; import { AuthUser } from '../auth/interfaces/auth-user.interface';
import { UserRole } from '../users/enums/user-role.enum'; import { UserRole } from '../users/enums/user-role.enum';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
@@ -21,11 +22,16 @@ export class SalonsService {
private readonly salonsRepository: Repository<Salon>, private readonly salonsRepository: Repository<Salon>,
private readonly usersService: UsersService, private readonly usersService: UsersService,
private readonly salonPolicy: SalonPolicy, private readonly salonPolicy: SalonPolicy,
private readonly provincesService: ProvincesService,
) {} ) {}
async create(createSalonDto: CreateSalonDto, user: AuthUser): Promise<Salon> { async create(createSalonDto: CreateSalonDto, user: AuthUser): Promise<Salon> {
const ownerId = this.resolveOwnerId(createSalonDto, user); const ownerId = this.resolveOwnerId(createSalonDto, user);
await this.ensureOwnerIsSalonOwner(ownerId); await this.ensureOwnerIsSalonOwner(ownerId);
await this.provincesService.findCity(
createSalonDto.provinceId,
createSalonDto.cityId,
);
const salon = this.salonsRepository.create({ const salon = this.salonsRepository.create({
...createSalonDto, ...createSalonDto,
@@ -38,17 +44,19 @@ export class SalonsService {
if (user?.role === UserRole.SALON) { if (user?.role === UserRole.SALON) {
return this.salonsRepository.find({ return this.salonsRepository.find({
where: { ownerId: user.id }, where: { ownerId: user.id },
relations: { owner: true }, relations: { owner: true, province: true, city: true },
}); });
} }
return this.salonsRepository.find({ relations: { owner: true } }); return this.salonsRepository.find({
relations: { owner: true, province: true, city: true },
});
} }
async findOne(id: string): Promise<Salon> { async findOne(id: string): Promise<Salon> {
const salon = await this.salonsRepository.findOne({ const salon = await this.salonsRepository.findOne({
where: { id }, where: { id },
relations: { owner: true }, relations: { owner: true, province: true, city: true },
}); });
if (!salon) { if (!salon) {
throw new NotFoundException(`Salon #${id} not found`); throw new NotFoundException(`Salon #${id} not found`);
@@ -65,6 +73,18 @@ export class SalonsService {
if (!this.salonPolicy.canUpdate(user, salon)) { if (!this.salonPolicy.canUpdate(user, salon)) {
throw new ForbiddenException(); throw new ForbiddenException();
} }
if (updateSalonDto.provinceId && updateSalonDto.cityId) {
await this.provincesService.findCity(
updateSalonDto.provinceId,
updateSalonDto.cityId,
);
} else if (updateSalonDto.provinceId || updateSalonDto.cityId) {
throw new BadRequestException(
'Both provinceId and cityId are required when updating location',
);
}
Object.assign(salon, updateSalonDto); Object.assign(salon, updateSalonDto);
return this.salonsRepository.save(salon); return this.salonsRepository.save(salon);
} }
+2 -1
View File
@@ -20,6 +20,7 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noImplicitAny": false, "noImplicitAny": false,
"strictBindCallApply": false, "strictBindCallApply": false,
"noFallthroughCasesInSwitch": false "noFallthroughCasesInSwitch": false,
"resolveJsonModule": true
} }
} }