Initial commit: Meshkee CMS API

NestJS backend with Prisma, Docker Compose for Postgres/Redis, and deploy docs for the production VM.
This commit is contained in:
Ali Reza
2026-07-21 17:52:36 +03:30
commit bb59d5e9ba
254 changed files with 37031 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { CitiesService } from './cities.service';
import { ListCitiesDto } from './dto/list-cities.dto';
@Controller('cities')
export class CitiesController {
constructor(private readonly service: CitiesService) {}
@Get()
list(@Query() query: ListCitiesDto) {
return this.service.list(query);
}
@Get(':cityId')
getOne(@Param('cityId') cityId: string) {
return this.service.getOne(cityId);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CitiesController } from './cities.controller';
import { CitiesService } from './cities.service';
@Module({
controllers: [CitiesController],
providers: [CitiesService],
exports: [CitiesService],
})
export class CitiesModule {}
+100
View File
@@ -0,0 +1,100 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { CityLevel, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { ListCitiesDto } from './dto/list-cities.dto';
type CityRecord = Prisma.CityGetPayload<{
select: {
id: true;
parentId: true;
level: true;
nameFa: true;
nameEn: true;
landlineCode: true;
slug: true;
sortOrder: true;
};
}>;
@Injectable()
export class CitiesService {
constructor(private readonly prisma: PrismaService) {}
async list(query: ListCitiesDto) {
const where: Prisma.CityWhereInput = {
isActive: true,
...(query.level ? { level: query.level } : {}),
};
if (query.parentId) {
where.parentId = BigInt(query.parentId);
} else if (query.parentSlug) {
const parent = await this.prisma.city.findFirst({
where: { slug: query.parentSlug, isActive: true },
select: { id: true },
});
if (!parent) {
return { items: [] };
}
where.parentId = parent.id;
} else if (query.level === CityLevel.province || query.level === CityLevel.city) {
throw new BadRequestException('parentId or parentSlug is required for this level');
} else {
where.level = CityLevel.country;
}
const items = await this.prisma.city.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { nameEn: 'asc' }],
select: {
id: true,
parentId: true,
level: true,
nameFa: true,
nameEn: true,
landlineCode: true,
slug: true,
sortOrder: true,
},
});
return { items: items.map((item) => this.serialize(item)) };
}
async getOne(cityIdRaw: string) {
const city = await this.prisma.city.findFirst({
where: { id: BigInt(cityIdRaw), isActive: true },
select: {
id: true,
parentId: true,
level: true,
nameFa: true,
nameEn: true,
landlineCode: true,
slug: true,
sortOrder: true,
},
});
if (!city) {
throw new NotFoundException('City not found');
}
return { city: this.serialize(city) };
}
private serialize(city: CityRecord) {
return {
id: city.id.toString(),
parentId: city.parentId?.toString() ?? null,
level: city.level,
nameFa: city.nameFa,
nameEn: city.nameEn,
landlineCode: city.landlineCode,
slug: city.slug,
sortOrder: city.sortOrder,
};
}
}
+19
View File
@@ -0,0 +1,19 @@
import { CityLevel } from '@prisma/client';
import { IsEnum, IsOptional, IsString, Matches } from 'class-validator';
export class ListCitiesDto {
@IsOptional()
@IsEnum(CityLevel)
level?: CityLevel;
@IsOptional()
@IsString()
parentId?: string;
@IsOptional()
@IsString()
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
message: 'parentSlug must be lowercase letters, numbers, and hyphens',
})
parentSlug?: string;
}