Add partnership request API with media uploads and admin listing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-27 21:51:44 +03:30
co-authored by Cursor
parent b661877d77
commit 70c889ab4b
12 changed files with 403 additions and 4 deletions
@@ -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;
+38 -4
View File
@@ -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())
+2
View File
@@ -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,
],
+1
View File
@@ -8,6 +8,7 @@ export const MEDIA_KINDS = [
'store',
'avatar',
'website',
'partnership',
'temp',
] as const;
export type MediaKind = (typeof MEDIA_KINDS)[number];
+1
View File
@@ -8,5 +8,6 @@ import { MediaService } from './media.service';
imports: [ConfigModule, StorageModule],
controllers: [MediaController],
providers: [MediaService],
exports: [MediaService],
})
export class MediaModule {}
+3
View File
@@ -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:
@@ -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;
}
@@ -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;
}
@@ -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);
}
}
@@ -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 {}
@@ -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),
};
}
}
+5
View File
@@ -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}`;
}