diff --git a/prisma/migrations/20260927210000_partnership_requests/migration.sql b/prisma/migrations/20260927210000_partnership_requests/migration.sql new file mode 100644 index 0000000..7a5cf0d --- /dev/null +++ b/prisma/migrations/20260927210000_partnership_requests/migration.sql @@ -0,0 +1,37 @@ +-- CreateEnum +CREATE TYPE "PartnershipRequestStatus" AS ENUM ('pending', 'reviewed', 'accepted', 'rejected'); + +-- CreateTable +CREATE TABLE "PartnershipRequest" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "businessNameFa" TEXT NOT NULL, + "businessNameEn" TEXT NOT NULL DEFAULT '', + "categoryIds" INTEGER[], + "description" TEXT NOT NULL, + "imageUrls" TEXT[], + "cellNumber" TEXT NOT NULL, + "landline" TEXT, + "instagram" TEXT, + "city" TEXT NOT NULL, + "province" TEXT NOT NULL, + "address" TEXT NOT NULL, + "postalCode" TEXT NOT NULL, + "status" "PartnershipRequestStatus" NOT NULL DEFAULT 'pending', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PartnershipRequest_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "PartnershipRequest_userId_idx" ON "PartnershipRequest"("userId"); + +-- CreateIndex +CREATE INDEX "PartnershipRequest_status_idx" ON "PartnershipRequest"("status"); + +-- CreateIndex +CREATE INDEX "PartnershipRequest_createdAt_idx" ON "PartnershipRequest"("createdAt"); + +-- AddForeignKey +ALTER TABLE "PartnershipRequest" ADD CONSTRAINT "PartnershipRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1a3dcdf..954b0c1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -45,6 +45,13 @@ enum SmsOtpPurpose { storeRegister } +enum PartnershipRequestStatus { + pending + reviewed + accepted + rejected +} + model User { id String @id @default(cuid()) firstName String @@ -61,10 +68,11 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - refreshTokens RefreshToken[] - addresses UserAddress[] - ownedStore Store? - orders Order[] @relation("CustomerOrders") + refreshTokens RefreshToken[] + addresses UserAddress[] + ownedStore Store? + orders Order[] @relation("CustomerOrders") + partnershipRequests PartnershipRequest[] @@index([role]) @@index([disabled]) @@ -324,6 +332,32 @@ model OrderStoreFulfillment { @@index([status]) } +/// Producer / seller partnership applications from the storefront. +model PartnershipRequest { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + businessNameFa String + businessNameEn String @default("") + categoryIds Int[] + description String + imageUrls String[] + cellNumber String + landline String? + instagram String? + city String + province String + address String + postalCode String + status PartnershipRequestStatus @default(pending) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([status]) + @@index([createdAt]) +} + /// Homepage / storefront image slots (hero slides, banners, category thumbs, …) model WebsiteSlotItem { id String @id @default(cuid()) diff --git a/src/app.module.ts b/src/app.module.ts index 4c1348c..d88d37e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,6 +9,7 @@ import { CategoriesModule } from './categories/categories.module'; import { CustomersModule } from './customers/customers.module'; import { MediaModule } from './media/media.module'; import { OrdersModule } from './orders/orders.module'; +import { PartnershipRequestsModule } from './partnership-requests/partnership-requests.module'; import { PrismaModule } from './prisma/prisma.module'; import { ProductsModule } from './products/products.module'; import { ProfileModule } from './profile/profile.module'; @@ -36,6 +37,7 @@ import { WebsiteSlotsModule } from './website-slots/website-slots.module'; BlogsModule, CustomersModule, OrdersModule, + PartnershipRequestsModule, ProfileModule, WebsiteSlotsModule, ], diff --git a/src/media/dto/media-upload-query.dto.ts b/src/media/dto/media-upload-query.dto.ts index 4ab6e3a..649b526 100644 --- a/src/media/dto/media-upload-query.dto.ts +++ b/src/media/dto/media-upload-query.dto.ts @@ -8,6 +8,7 @@ export const MEDIA_KINDS = [ 'store', 'avatar', 'website', + 'partnership', 'temp', ] as const; export type MediaKind = (typeof MEDIA_KINDS)[number]; diff --git a/src/media/media.module.ts b/src/media/media.module.ts index dc7392c..2084efc 100644 --- a/src/media/media.module.ts +++ b/src/media/media.module.ts @@ -8,5 +8,6 @@ import { MediaService } from './media.service'; imports: [ConfigModule, StorageModule], controllers: [MediaController], providers: [MediaService], + exports: [MediaService], }) export class MediaModule {} diff --git a/src/media/media.service.ts b/src/media/media.service.ts index b649958..e30be41 100644 --- a/src/media/media.service.ts +++ b/src/media/media.service.ts @@ -9,6 +9,7 @@ import { extname } from 'path'; import { blogMediaKey, brandMediaKey, + partnershipMediaKey, productGalleryKey, productMainKey, storeMediaKey, @@ -88,6 +89,8 @@ export class MediaService { return userMediaKey(fileName); case 'website': return websiteMediaKey(fileName); + case 'partnership': + return partnershipMediaKey(fileName); case 'temp': return tempMediaKey(fileName); default: diff --git a/src/partnership-requests/dto/create-partnership-request.dto.ts b/src/partnership-requests/dto/create-partnership-request.dto.ts new file mode 100644 index 0000000..c6c1b2f --- /dev/null +++ b/src/partnership-requests/dto/create-partnership-request.dto.ts @@ -0,0 +1,70 @@ +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsInt, + IsOptional, + IsString, + MaxLength, + MinLength, +} from 'class-validator'; + +export class CreatePartnershipRequestDto { + @IsString() + @MinLength(2, { message: 'نام کسب‌وکار فارسی الزامی است' }) + @MaxLength(120) + businessNameFa!: string; + + @IsOptional() + @IsString() + @MaxLength(120) + businessNameEn?: string; + + @IsArray() + @ArrayMinSize(1, { message: 'حداقل یک دسته‌بندی انتخاب کنید' }) + @ArrayMaxSize(20) + @IsInt({ each: true }) + @Type(() => Number) + categoryIds!: number[]; + + @IsString() + @MinLength(20, { message: 'توضیحات باید حداقل ۲۰ کاراکتر باشد' }) + @MaxLength(4000) + description!: string; + + @IsArray() + @ArrayMaxSize(8) + @IsString({ each: true }) + imageUrls!: string[]; + + @IsOptional() + @IsString() + @MaxLength(20) + landline?: string; + + @IsOptional() + @IsString() + @MaxLength(80) + instagram?: string; + + @IsString() + @MinLength(1, { message: 'شهر الزامی است' }) + @MaxLength(80) + city!: string; + + @IsString() + @MinLength(1, { message: 'استان الزامی است' }) + @MaxLength(80) + province!: string; + + @IsString() + @MinLength(5, { message: 'آدرس الزامی است' }) + @MaxLength(500) + address!: string; + + @IsString() + @MinLength(5, { message: 'کد پستی الزامی است' }) + @MaxLength(20) + postalCode!: string; +} diff --git a/src/partnership-requests/dto/list-partnership-requests.dto.ts b/src/partnership-requests/dto/list-partnership-requests.dto.ts new file mode 100644 index 0000000..d12bd1a --- /dev/null +++ b/src/partnership-requests/dto/list-partnership-requests.dto.ts @@ -0,0 +1,17 @@ +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, Max, Min } from 'class-validator'; + +export class ListPartnershipRequestsQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(50) + limit?: number = 10; +} diff --git a/src/partnership-requests/partnership-requests.controller.ts b/src/partnership-requests/partnership-requests.controller.ts new file mode 100644 index 0000000..4e051d3 --- /dev/null +++ b/src/partnership-requests/partnership-requests.controller.ts @@ -0,0 +1,65 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Query, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { UserRole } from '@prisma/client'; +import { memoryStorage } from 'multer'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import type { AuthUser } from '../auth/auth.types'; +import { MediaService } from '../media/media.service'; +import { CreatePartnershipRequestDto } from './dto/create-partnership-request.dto'; +import { ListPartnershipRequestsQueryDto } from './dto/list-partnership-requests.dto'; +import { PartnershipRequestsService } from './partnership-requests.service'; + +@Controller('partnership-requests') +@UseGuards(JwtAuthGuard, RolesGuard) +export class PartnershipRequestsController { + constructor( + private readonly partnershipRequests: PartnershipRequestsService, + private readonly media: MediaService, + ) {} + + @Post() + create( + @CurrentUser() user: AuthUser, + @Body() dto: CreatePartnershipRequestDto, + ) { + return this.partnershipRequests.create(user, dto); + } + + @Post('images') + @UseInterceptors( + FileInterceptor('file', { + storage: memoryStorage(), + }), + ) + uploadImage(@UploadedFile() file: Express.Multer.File) { + return this.media.upload('partnership', file); + } + + @Get() + @Roles(UserRole.STORE_ADMIN, UserRole.SUPER_ADMIN) + list( + @CurrentUser() user: AuthUser, + @Query() query: ListPartnershipRequestsQueryDto, + ) { + return this.partnershipRequests.list(user, query); + } + + @Get(':id') + @Roles(UserRole.STORE_ADMIN, UserRole.SUPER_ADMIN) + findOne(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.partnershipRequests.findOne(user, id); + } +} diff --git a/src/partnership-requests/partnership-requests.module.ts b/src/partnership-requests/partnership-requests.module.ts new file mode 100644 index 0000000..4da0892 --- /dev/null +++ b/src/partnership-requests/partnership-requests.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { MediaModule } from '../media/media.module'; +import { PartnershipRequestsController } from './partnership-requests.controller'; +import { PartnershipRequestsService } from './partnership-requests.service'; + +@Module({ + imports: [MediaModule], + controllers: [PartnershipRequestsController], + providers: [PartnershipRequestsService], +}) +export class PartnershipRequestsModule {} diff --git a/src/partnership-requests/partnership-requests.service.ts b/src/partnership-requests/partnership-requests.service.ts new file mode 100644 index 0000000..4221444 --- /dev/null +++ b/src/partnership-requests/partnership-requests.service.ts @@ -0,0 +1,153 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { UserRole } from '@prisma/client'; +import type { AuthUser } from '../auth/auth.types'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreatePartnershipRequestDto } from './dto/create-partnership-request.dto'; + +@Injectable() +export class PartnershipRequestsService { + constructor(private readonly prisma: PrismaService) {} + + async create(actor: AuthUser, dto: CreatePartnershipRequestDto) { + const user = await this.prisma.user.findUnique({ + where: { id: actor.id }, + select: { id: true, cellNumber: true, disabled: true }, + }); + if (!user || user.disabled) { + throw new NotFoundException('کاربر یافت نشد'); + } + + const uniqueCategoryIds = [...new Set(dto.categoryIds)]; + if (!uniqueCategoryIds.length) { + throw new BadRequestException('حداقل یک دسته‌بندی انتخاب کنید'); + } + + const categories = await this.prisma.category.findMany({ + where: { id: { in: uniqueCategoryIds } }, + select: { id: true }, + }); + if (categories.length !== uniqueCategoryIds.length) { + throw new BadRequestException('یکی از دسته‌بندی‌ها نامعتبر است'); + } + + const request = await this.prisma.partnershipRequest.create({ + data: { + userId: user.id, + businessNameFa: dto.businessNameFa.trim(), + businessNameEn: (dto.businessNameEn ?? '').trim(), + categoryIds: uniqueCategoryIds, + description: dto.description.trim(), + imageUrls: dto.imageUrls, + cellNumber: user.cellNumber, + landline: dto.landline?.trim() || null, + instagram: dto.instagram?.trim() || null, + city: dto.city.trim(), + province: dto.province.trim(), + address: dto.address.trim(), + postalCode: dto.postalCode.trim(), + }, + }); + + return request; + } + + async list( + actor: AuthUser, + query: { page?: number; limit?: number } = {}, + ) { + if ( + actor.role !== UserRole.STORE_ADMIN && + actor.role !== UserRole.SUPER_ADMIN + ) { + throw new NotFoundException(); + } + + const page = Math.max(1, query.page ?? 1); + const limit = Math.min(50, Math.max(1, query.limit ?? 10)); + const skip = (page - 1) * limit; + + const [total, items] = await Promise.all([ + this.prisma.partnershipRequest.count(), + this.prisma.partnershipRequest.findMany({ + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + cellNumber: true, + }, + }, + }, + }), + ]); + + const categoryIds = [...new Set(items.flatMap((item) => item.categoryIds))]; + const categories = categoryIds.length + ? await this.prisma.category.findMany({ + where: { id: { in: categoryIds } }, + select: { id: true, nameFa: true, nameEn: true }, + }) + : []; + const categoryMap = new Map(categories.map((c) => [c.id, c])); + + return { + items: items.map((item) => ({ + ...item, + categories: item.categoryIds + .map((id) => categoryMap.get(id)) + .filter(Boolean), + })), + total, + page, + limit, + totalPages: Math.max(1, Math.ceil(total / limit) || 1), + }; + } + + async findOne(actor: AuthUser, id: string) { + if ( + actor.role !== UserRole.STORE_ADMIN && + actor.role !== UserRole.SUPER_ADMIN + ) { + throw new NotFoundException(); + } + + const item = await this.prisma.partnershipRequest.findUnique({ + where: { id }, + include: { + user: { + select: { + id: true, + firstName: true, + lastName: true, + cellNumber: true, + }, + }, + }, + }); + if (!item) throw new NotFoundException('درخواست یافت نشد'); + + const categories = item.categoryIds.length + ? await this.prisma.category.findMany({ + where: { id: { in: item.categoryIds } }, + select: { id: true, nameFa: true, nameEn: true }, + }) + : []; + const categoryMap = new Map(categories.map((c) => [c.id, c])); + + return { + ...item, + categories: item.categoryIds + .map((cid) => categoryMap.get(cid)) + .filter(Boolean), + }; + } +} diff --git a/src/storage/storage-keys.ts b/src/storage/storage-keys.ts index 36eff0e..28d0a79 100644 --- a/src/storage/storage-keys.ts +++ b/src/storage/storage-keys.ts @@ -8,6 +8,7 @@ * brands/{uuid}{ext} * users/{uuid}{ext} * stores/{uuid}{ext} + * partnership/{uuid}{ext} * temp/{uuid}{ext} */ const ROOT = 'mana-collection'; @@ -40,6 +41,10 @@ export function websiteMediaKey(fileName: string): string { return `${ROOT}/website/${fileName}`; } +export function partnershipMediaKey(fileName: string): string { + return `${ROOT}/partnership/${fileName}`; +} + export function tempMediaKey(fileName: string): string { return `${ROOT}/temp/${fileName}`; }