Add partnership request API with media uploads and admin listing.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
b661877d77
commit
70c889ab4b
@@ -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
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -8,6 +8,7 @@ export const MEDIA_KINDS = [
|
||||
'store',
|
||||
'avatar',
|
||||
'website',
|
||||
'partnership',
|
||||
'temp',
|
||||
] as const;
|
||||
export type MediaKind = (typeof MEDIA_KINDS)[number];
|
||||
|
||||
@@ -8,5 +8,6 @@ import { MediaService } from './media.service';
|
||||
imports: [ConfigModule, StorageModule],
|
||||
controllers: [MediaController],
|
||||
providers: [MediaService],
|
||||
exports: [MediaService],
|
||||
})
|
||||
export class MediaModule {}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user