diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 6cf4932..1c5f2ea 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -9,23 +9,25 @@ datasource db { } model User { - id String @id @default(uuid()) @db.Uuid - firstName String @map("first_name") @db.VarChar(100) - lastName String @map("last_name") @db.VarChar(100) - email String? @unique @db.VarChar(150) - mobile String @unique @db.VarChar(15) - password String? @db.VarChar(255) - role String @default("User_PetOwner") @db.VarChar(30) - walletBalance Decimal @default(0.00) @map("wallet_balance") @db.Decimal(15, 2) - charityDonationTotal Decimal @default(0.00) @map("charity_donation_total") @db.Decimal(15, 2) - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() - updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz() - - addresses UserAddress[] - walletTransactions WalletTransaction[] - pets Pet[] - orders Order[] - blogs Blog[] + id String @id @default(uuid()) @db.Uuid + firstName String @map("first_name") @db.VarChar(100) + lastName String @map("last_name") @db.VarChar(100) + email String? @unique @db.VarChar(150) + mobile String @unique @db.VarChar(15) + password String? @db.VarChar(255) + role String @default("User_PetOwner") @db.VarChar(30) + walletBalance Decimal @default(0.00) @map("wallet_balance") @db.Decimal(15, 2) + charityDonationTotal Decimal @default(0.00) @map("charity_donation_total") @db.Decimal(15, 2) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz() + + addresses UserAddress[] + walletTransactions WalletTransaction[] + pets Pet[] + orders Order[] + blogs Blog[] + prescriptions Prescription[] + partnerAccount PartnerAccount? @@map("users") } @@ -43,7 +45,7 @@ model UserAddress { isDefault Boolean @default(false) @map("is_default") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) @@map("user_addresses") @@ -59,7 +61,7 @@ model WalletTransaction { description String? @db.Text createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("wallet_transactions") } @@ -91,9 +93,9 @@ model Category { imageUrl String? @map("image_url") @db.Text createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() - parent Category? @relation("SubCategories", fields: [parentId], references: [id]) - children Category[] @relation("SubCategories") - products Product[] + parent Category? @relation("SubCategories", fields: [parentId], references: [id]) + children Category[] @relation("SubCategories") + products Product[] @@map("categories") } @@ -112,7 +114,7 @@ model Blog { createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz() - author User @relation(fields: [authorId], references: [id]) + author User @relation(fields: [authorId], references: [id]) @@map("blogs") } @@ -139,6 +141,7 @@ model Product { unit String @db.VarChar(50) packageSize Decimal @map("package_size") @db.Decimal(10, 2) dosageLogic String? @map("dosage_logic") @db.Text + dosageConfig Json? @map("dosage_config") suitableFor String @map("suitable_for") @db.VarChar(15) // سگ, گربه, هر دو imageUrl String @map("image_url") @db.Text images String[] @default([]) @@ -151,12 +154,12 @@ model Product { keywords String? @db.Text createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() - category Category @relation(fields: [categoryId], references: [id]) - ingredientList ProductIngredient[] - symptoms ProductSymptom[] - reminders Reminder[] - orderItems OrderItem[] - advisorRules SmartAdvisorRule[] + category Category @relation(fields: [categoryId], references: [id]) + ingredientList ProductIngredient[] + symptoms ProductSymptom[] + reminders Reminder[] + orderItems OrderItem[] + advisorRules SmartAdvisorRule[] @@index([categorySlug]) @@index([suitableFor]) @@ -197,21 +200,22 @@ model ProductSymptom { } model Pet { - id String @id @default(uuid()) @db.Uuid - userId String @map("user_id") @db.Uuid - name String @db.VarChar(100) - type String @db.VarChar(10) // سگ, گربه - breed String @db.VarChar(100) - age Int - weight Decimal @db.Decimal(5, 2) - activityLevel String @map("activity_level") @db.VarChar(15) // کم, متوسط, زیاد - imageUrl String? @map("image_url") @db.Text - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + name String @db.VarChar(100) + type String @db.VarChar(10) // سگ, گربه + breed String @db.VarChar(100) + age Int + weight Decimal @db.Decimal(5, 2) + activityLevel String @map("activity_level") @db.VarChar(15) // کم, متوسط, زیاد + imageUrl String? @map("image_url") @db.Text + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() user User @relation(fields: [userId], references: [id], onDelete: Cascade) medicalConditions PetMedicalCondition[] reminders Reminder[] healthLogs HealthLog[] + prescriptions Prescription[] @@map("pets") } @@ -226,13 +230,13 @@ model PetMedicalCondition { } model Reminder { - id String @id @default(uuid()) @db.Uuid - petId String @map("pet_id") @db.Uuid - productId String? @map("product_id") @db.Uuid - title String @db.VarChar(150) - time String @db.VarChar(5) // 08:30 - frequency String @db.VarChar(20) // روزانه, هفتگی - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() + id String @id @default(uuid()) @db.Uuid + petId String @map("pet_id") @db.Uuid + productId String? @map("product_id") @db.Uuid + title String @db.VarChar(150) + time String @db.VarChar(5) // 08:30 + frequency String @db.VarChar(20) // روزانه, هفتگی + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade) product Product? @relation(fields: [productId], references: [id], onDelete: SetNull) @@ -248,7 +252,7 @@ model ReminderCompletion { completedDate DateTime @map("completed_date") @db.Date createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() - reminder Reminder @relation(fields: [reminderId], references: [id], onDelete: Cascade) + reminder Reminder @relation(fields: [reminderId], references: [id], onDelete: Cascade) @@unique([reminderId, completedDate]) @@index([reminderId, completedDate]) @@ -256,68 +260,68 @@ model ReminderCompletion { } model HealthLog { - id String @id @default(uuid()) @db.Uuid - petId String @map("pet_id") @db.Uuid - appetite String @db.VarChar(15) - energy String @db.VarChar(15) - digestion String @db.VarChar(15) - note String? @db.Text - loggedDate DateTime @default(now()) @map("logged_date") @db.Date - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() + id String @id @default(uuid()) @db.Uuid + petId String @map("pet_id") @db.Uuid + appetite String @db.VarChar(15) + energy String @db.VarChar(15) + digestion String @db.VarChar(15) + note String? @db.Text + loggedDate DateTime @default(now()) @map("logged_date") @db.Date + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() - pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade) + pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade) @@map("health_logs") } model Coupon { - id String @id @default(uuid()) @db.Uuid - code String @unique @db.VarChar(50) - type String @default("percent") @db.VarChar(20) // fixed, percent - value Decimal @db.Decimal(15, 2) - minCartValue Decimal? @map("min_cart_value") @db.Decimal(15, 2) - maxCartValue Decimal? @map("max_cart_value") @db.Decimal(15, 2) - maxUses Int? @map("max_uses") - usedCount Int @default(0) @map("used_count") - expiresAt DateTime? @map("expires_at") @db.Timestamptz() - isActive Boolean @default(true) @map("is_active") - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() + id String @id @default(uuid()) @db.Uuid + code String @unique @db.VarChar(50) + type String @default("percent") @db.VarChar(20) // fixed, percent + value Decimal @db.Decimal(15, 2) + minCartValue Decimal? @map("min_cart_value") @db.Decimal(15, 2) + maxCartValue Decimal? @map("max_cart_value") @db.Decimal(15, 2) + maxUses Int? @map("max_uses") + usedCount Int @default(0) @map("used_count") + expiresAt DateTime? @map("expires_at") @db.Timestamptz() + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() - orders Order[] - targets CouponTarget[] + orders Order[] + targets CouponTarget[] @@map("coupons") } model CouponTarget { - id String @id @default(uuid()) @db.Uuid - couponId String @map("coupon_id") @db.Uuid - targetType String @map("target_type") @db.VarChar(50) // USER, PET, ROLE, PRODUCT, CATEGORY - targetId String @map("target_id") @db.VarChar(100) // UUID or role name - modifierType String @default("override") @map("modifier_type") @db.VarChar(20) // override, add, subtract + id String @id @default(uuid()) @db.Uuid + couponId String @map("coupon_id") @db.Uuid + targetType String @map("target_type") @db.VarChar(50) // USER, PET, ROLE, PRODUCT, CATEGORY + targetId String @map("target_id") @db.VarChar(100) // UUID or role name + modifierType String @default("override") @map("modifier_type") @db.VarChar(20) // override, add, subtract modifierValue Decimal? @map("modifier_value") @db.Decimal(15, 2) - coupon Coupon @relation(fields: [couponId], references: [id], onDelete: Cascade) + coupon Coupon @relation(fields: [couponId], references: [id], onDelete: Cascade) @@index([targetType, targetId]) @@map("coupon_targets") } model Order { - id String @id @default(uuid()) @db.Uuid - userId String @map("user_id") @db.Uuid - couponId String? @map("coupon_id") @db.Uuid - totalAmount Decimal @map("total_amount") @db.Decimal(15, 2) - charityDonation Decimal @default(0.00) @map("charity_donation") @db.Decimal(15, 2) - isRefill Boolean @default(false) @map("is_refill") - refillIntervalDays Int? @map("refill_interval_days") - status String @default("processing") @db.VarChar(30) // processing, shipped, delivered - trackingNumber String? @unique @map("tracking_number") @db.VarChar(100) - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + couponId String? @map("coupon_id") @db.Uuid + totalAmount Decimal @map("total_amount") @db.Decimal(15, 2) + charityDonation Decimal @default(0.00) @map("charity_donation") @db.Decimal(15, 2) + isRefill Boolean @default(false) @map("is_refill") + refillIntervalDays Int? @map("refill_interval_days") + status String @default("processing") @db.VarChar(30) // processing, shipped, delivered + trackingNumber String? @unique @map("tracking_number") @db.VarChar(100) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - coupon Coupon? @relation(fields: [couponId], references: [id]) - orderItems OrderItem[] + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + coupon Coupon? @relation(fields: [couponId], references: [id]) + orderItems OrderItem[] @@map("orders") } @@ -330,8 +334,8 @@ model OrderItem { doseQty Decimal? @map("dose_qty") @db.Decimal(10, 2) doseUnit String? @map("dose_unit") @db.VarChar(50) - order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) - product Product? @relation(fields: [productId], references: [id], onDelete: SetNull) + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + product Product? @relation(fields: [productId], references: [id], onDelete: SetNull) @@map("order_items") } @@ -344,8 +348,8 @@ model UiText { } model ScientificTerm { - key String @id @db.VarChar(100) - term String @db.VarChar(150) + key String @id @db.VarChar(100) + term String @db.VarChar(150) definition String @db.Text wikiId String @map("wiki_id") @db.VarChar(50) metaTitle String? @map("meta_title") @db.VarChar(200) @@ -356,42 +360,42 @@ model ScientificTerm { } model HeroBanner { - id String @id @default(uuid()) @db.Uuid - title String @db.VarChar(200) - subtitle String? @db.Text - imageUrl String @map("image_url") @db.Text - buttonText String? @map("button_text") @db.VarChar(100) - buttonLink String? @map("button_link") @db.Text - isActive Boolean @default(true) @map("is_active") - order Int @default(0) - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() + id String @id @default(uuid()) @db.Uuid + title String @db.VarChar(200) + subtitle String? @db.Text + imageUrl String @map("image_url") @db.Text + buttonText String? @map("button_text") @db.VarChar(100) + buttonLink String? @map("button_link") @db.Text + isActive Boolean @default(true) @map("is_active") + order Int @default(0) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() @@map("hero_banners") } model VetTestimonial { - id String @id @default(uuid()) @db.Uuid - vetName String @map("vet_name") @db.VarChar(150) - clinicName String? @map("clinic_name") @db.VarChar(150) - imageUrl String? @map("image_url") @db.Text - quote String @db.Text - rating Int @default(5) - isActive Boolean @default(true) @map("is_active") - order Int @default(0) - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() + id String @id @default(uuid()) @db.Uuid + vetName String @map("vet_name") @db.VarChar(150) + clinicName String? @map("clinic_name") @db.VarChar(150) + imageUrl String? @map("image_url") @db.Text + quote String @db.Text + rating Int @default(5) + isActive Boolean @default(true) @map("is_active") + order Int @default(0) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() @@map("vet_testimonials") } model SmartAdvisorRule { id String @id @default(uuid()) @db.Uuid - condition String @db.VarChar(200) - targetPetType String? @map("target_pet_type") @db.VarChar(50) + condition String @db.VarChar(200) + targetPetType String? @map("target_pet_type") @db.VarChar(50) recommendedProduct String @map("recommended_product_id") @db.Uuid reason String @db.Text createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() - product Product @relation(fields: [recommendedProduct], references: [id], onDelete: Cascade) + product Product @relation(fields: [recommendedProduct], references: [id], onDelete: Cascade) @@map("smart_advisor_rules") } @@ -427,14 +431,122 @@ model ContactSubmission { } model ContactInfo { - id String @id @default(uuid()) @db.Uuid - key String @unique @db.VarChar(100) - title String @db.VarChar(200) - value String @db.Text - icon String? @db.VarChar(100) - order Int @default(0) - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz() + id String @id @default(uuid()) @db.Uuid + key String @unique @db.VarChar(100) + title String @db.VarChar(200) + value String @db.Text + icon String? @db.VarChar(100) + order Int @default(0) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz() @@map("contact_info") } +model Banner { + id String @id @default(uuid()) @db.Uuid + title String @db.VarChar(200) + subtitle String? @db.Text + imageUrl String @map("image_url") @db.Text + linkUrl String? @map("link_url") @db.Text + position String @default("home_hero") @db.VarChar(50) + isActive Boolean @default(true) @map("is_active") + order Int @default(0) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz() + + @@map("banners") +} + +model Testimonial { + id String @id @default(uuid()) @db.Uuid + authorName String @map("author_name") @db.VarChar(150) + roleTitle String? @map("role_title") @db.VarChar(150) + avatarUrl String? @map("avatar_url") @db.Text + content String @db.Text + rating Int @default(5) + isFeatured Boolean @default(false) @map("is_featured") + isActive Boolean @default(true) @map("is_active") + order Int @default(0) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz() + + @@map("testimonials") +} + +model Ingredient { + id String @id @default(uuid()) @db.Uuid + nameFa String @map("name_fa") @db.VarChar(150) + nameEn String @map("name_en") @db.VarChar(150) + slug String @unique @db.VarChar(150) + description String? @db.Text + scientificName String? @map("scientific_name") @db.VarChar(200) + imageUrl String? @map("image_url") @db.Text + benefits String[] @default([]) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz() + + @@map("ingredients") +} + +model Prescription { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + petId String? @map("pet_id") @db.Uuid + fileUrl String @map("file_url") @db.Text + status String @default("PENDING") @db.VarChar(30) + notes String? @db.Text + adminNotes String? @map("admin_notes") @db.Text + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz() + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + pet Pet? @relation(fields: [petId], references: [id], onDelete: SetNull) + + @@index([userId]) + @@map("prescriptions") +} + +model B2BInquiry { + id String @id @default(uuid()) @db.Uuid + companyName String @map("company_name") @db.VarChar(200) + contactName String @map("contact_name") @db.VarChar(150) + email String @db.VarChar(150) + phone String @db.VarChar(20) + businessType String @map("business_type") @db.VarChar(50) + estimatedVolume String? @map("estimated_volume") @db.VarChar(100) + message String @db.Text + status String @default("PENDING") @db.VarChar(30) + adminNotes String? @map("admin_notes") @db.Text + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz() + + @@map("b2b_inquiries") +} + +model PartnerAccount { + id String @id @default(uuid()) @db.Uuid + userId String @unique @map("user_id") @db.Uuid + companyName String @map("company_name") @db.VarChar(200) + taxId String? @map("tax_id") @db.VarChar(50) + creditLimit Decimal @default(0.00) @map("credit_limit") @db.Decimal(15, 2) + discountTier String @default("STANDARD") @map("discount_tier") @db.VarChar(30) + status String @default("ACTIVE") @db.VarChar(30) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz() + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("partner_accounts") +} + +model Setting { + id String @id @default(uuid()) @db.Uuid + category String @db.VarChar(50) + key String @unique @db.VarChar(100) + value Json + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz() + + @@index([category]) + @@map("settings") +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 6dbb08a..48e4112 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -21,6 +21,12 @@ import { WholesaleModule } from './wholesale/wholesale.module'; import { VideosModule } from './videos/videos.module'; import { SmsModule } from './common/sms.module'; import { ContactModule } from './contact/contact.module'; +import { BannersModule } from './banners/banners.module'; +import { SmartAdvisorModule } from './smart-advisor/smart-advisor.module'; +import { TestimonialsModule } from './testimonials/testimonials.module'; +import { IngredientsModule } from './ingredients/ingredients.module'; +import { PrescriptionsModule } from './prescriptions/prescriptions.module'; +import { B2BModule } from './b2b/b2b.module'; @Module({ imports: [ @@ -48,6 +54,12 @@ import { ContactModule } from './contact/contact.module'; CmsModule, WholesaleModule, VideosModule, + BannersModule, + SmartAdvisorModule, + TestimonialsModule, + IngredientsModule, + PrescriptionsModule, + B2BModule, ], controllers: [MetricsController], providers: [ diff --git a/backend/src/b2b/b2b.controller.ts b/backend/src/b2b/b2b.controller.ts new file mode 100644 index 0000000..db4cfbb --- /dev/null +++ b/backend/src/b2b/b2b.controller.ts @@ -0,0 +1,84 @@ +import { + Controller, + Get, + Post, + Patch, + Body, + Param, + UseGuards, + Req, +} from '@nestjs/common'; +import { B2BService } from './b2b.service'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; + +@ApiTags('B2B - خدمات عمده‌فروشی و همکاران تجاری') +@Controller('b2b') +export class B2BController { + constructor(private readonly b2bService: B2BService) {} + + @Post('inquiries') + @ApiOperation({ summary: 'ثبت درخواست همکاری عمده (B2B)' }) + createInquiry( + @Body() + body: { + companyName: string; + contactName: string; + email: string; + phone: string; + businessType: string; + estimatedVolume?: string; + message: string; + }, + ) { + return this.b2bService.createInquiry(body); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('Admin') + @ApiBearerAuth() + @Get('inquiries') + @ApiOperation({ + summary: 'دریافت لیست درخواست‌های همکاری B2B (نیازمند ادمین)', + }) + findAllInquiries() { + return this.b2bService.findAllInquiries(); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('Admin') + @ApiBearerAuth() + @Patch('inquiries/:id') + @ApiOperation({ + summary: 'بررسی و تغییر وضعیت درخواست همکاری (نیازمند ادمین)', + }) + updateInquiryStatus( + @Param('id') id: string, + @Body() body: { status: string; adminNotes?: string }, + ) { + return this.b2bService.updateInquiryStatus(id, body); + } + + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @Get('partner') + @ApiOperation({ summary: 'دریافت اطلاعات حساب همکار تجاری' }) + getPartnerProfile(@Req() req: any) { + const userId = req.user.id || req.user.userId; + return this.b2bService.getPartnerProfile(userId); + } + + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @Post('orders') + @ApiOperation({ summary: 'ثبت سفارش عمده‌فروشی B2B' }) + createWholesaleOrder( + @Req() req: any, + @Body() body: { items: any[]; totalAmount: number }, + ) { + const userId = req.user.id || req.user.userId; + return this.b2bService.createWholesaleOrder(userId, body); + } +} diff --git a/backend/src/b2b/b2b.module.ts b/backend/src/b2b/b2b.module.ts new file mode 100644 index 0000000..353ead3 --- /dev/null +++ b/backend/src/b2b/b2b.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { B2BController } from './b2b.controller'; +import { B2BService } from './b2b.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [B2BController], + providers: [B2BService], + exports: [B2BService], +}) +export class B2BModule {} diff --git a/backend/src/b2b/b2b.service.ts b/backend/src/b2b/b2b.service.ts new file mode 100644 index 0000000..c940207 --- /dev/null +++ b/backend/src/b2b/b2b.service.ts @@ -0,0 +1,89 @@ +import { + Injectable, + NotFoundException, + ForbiddenException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class B2BService { + constructor(private prisma: PrismaService) {} + + async createInquiry(data: { + companyName: string; + contactName: string; + email: string; + phone: string; + businessType: string; + estimatedVolume?: string; + message: string; + }) { + return this.prisma.b2BInquiry.create({ + data: { + ...data, + status: 'PENDING', + }, + }); + } + + async findAllInquiries() { + return this.prisma.b2BInquiry.findMany({ + orderBy: { createdAt: 'desc' }, + }); + } + + async updateInquiryStatus( + id: string, + data: { status: string; adminNotes?: string }, + ) { + const item = await this.prisma.b2BInquiry.findUnique({ where: { id } }); + if (!item) { + throw new NotFoundException(`B2BInquiry with ID ${id} not found`); + } + return this.prisma.b2BInquiry.update({ + where: { id }, + data, + }); + } + + async getPartnerProfile(userId: string) { + const partner = await this.prisma.partnerAccount.findUnique({ + where: { userId }, + include: { user: true }, + }); + if (!partner) { + throw new NotFoundException( + `Partner account for user ${userId} not found`, + ); + } + return partner; + } + + async createWholesaleOrder( + userId: string, + data: { items: any[]; totalAmount: number }, + ) { + const partner = await this.prisma.partnerAccount.findUnique({ + where: { userId }, + }); + if (!partner || partner.status !== 'ACTIVE') { + throw new ForbiddenException('User is not an active wholesale partner'); + } + + return this.prisma.order.create({ + data: { + userId, + totalAmount: data.totalAmount, + status: 'processing', + isRefill: false, + orderItems: { + create: data.items.map((item) => ({ + productId: item.productId, + quantity: item.quantity, + })), + }, + }, + include: { orderItems: true }, + }); + } +} diff --git a/backend/src/banners/banners.controller.ts b/backend/src/banners/banners.controller.ts new file mode 100644 index 0000000..14487b3 --- /dev/null +++ b/backend/src/banners/banners.controller.ts @@ -0,0 +1,64 @@ +import { + Controller, + Get, + Post, + Patch, + Put, + Delete, + Body, + Param, + UseGuards, +} from '@nestjs/common'; +import { BannersService } from './banners.service'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; + +@ApiTags('Banners - بنرهای تبلیغاتی و اسلایدر') +@Controller('banners') +export class BannersController { + constructor(private readonly bannersService: BannersService) {} + + @Get() + @ApiOperation({ summary: 'لیست تمامی بنرها' }) + findAll() { + return this.bannersService.findAll(); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('Admin') + @ApiBearerAuth() + @Post() + @ApiOperation({ summary: 'ایجاد بنر جدید (نیازمند ادمین)' }) + create(@Body() body: any) { + return this.bannersService.create(body); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('Admin') + @ApiBearerAuth() + @Put('reorder') + @ApiOperation({ summary: 'تغییر ترتیب نمایش بنرها (نیازمند ادمین)' }) + reorder(@Body() body: { id: string; order: number }[]) { + return this.bannersService.reorder(body); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('Admin') + @ApiBearerAuth() + @Patch(':id') + @ApiOperation({ summary: 'ویرایش بنر (نیازمند ادمین)' }) + update(@Param('id') id: string, @Body() body: any) { + return this.bannersService.update(id, body); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('Admin') + @ApiBearerAuth() + @Delete(':id') + @ApiOperation({ summary: 'حذف بنر (نیازمند ادمین)' }) + remove(@Param('id') id: string) { + return this.bannersService.remove(id); + } +} diff --git a/backend/src/banners/banners.module.ts b/backend/src/banners/banners.module.ts new file mode 100644 index 0000000..c3c7d8f --- /dev/null +++ b/backend/src/banners/banners.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { BannersController } from './banners.controller'; +import { BannersService } from './banners.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [BannersController], + providers: [BannersService], + exports: [BannersService], +}) +export class BannersModule {} diff --git a/backend/src/banners/banners.service.ts b/backend/src/banners/banners.service.ts new file mode 100644 index 0000000..aac39be --- /dev/null +++ b/backend/src/banners/banners.service.ts @@ -0,0 +1,50 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { Prisma } from '@prisma/client'; + +@Injectable() +export class BannersService { + constructor(private prisma: PrismaService) {} + + async findAll() { + return this.prisma.banner.findMany({ + orderBy: { order: 'asc' }, + }); + } + + async create(data: Prisma.BannerCreateInput) { + return this.prisma.banner.create({ + data, + }); + } + + async update(id: string, data: Prisma.BannerUpdateInput) { + const banner = await this.prisma.banner.findUnique({ where: { id } }); + if (!banner) { + throw new NotFoundException(`Banner with ID ${id} not found`); + } + return this.prisma.banner.update({ + where: { id }, + data, + }); + } + + async remove(id: string) { + const banner = await this.prisma.banner.findUnique({ where: { id } }); + if (!banner) { + throw new NotFoundException(`Banner with ID ${id} not found`); + } + return this.prisma.banner.delete({ where: { id } }); + } + + async reorder(items: { id: string; order: number }[]) { + const updates = items.map((item) => + this.prisma.banner.update({ + where: { id: item.id }, + data: { order: item.order }, + }), + ); + await this.prisma.$transaction(updates); + return { success: true, message: 'Banners reordered successfully' }; + } +} diff --git a/backend/src/ingredients/ingredients.controller.ts b/backend/src/ingredients/ingredients.controller.ts new file mode 100644 index 0000000..b2c1f82 --- /dev/null +++ b/backend/src/ingredients/ingredients.controller.ts @@ -0,0 +1,60 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + UseGuards, +} from '@nestjs/common'; +import { IngredientsService } from './ingredients.service'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; + +@ApiTags('Ingredients - دانشنامه ترکیبات و مواد موثره') +@Controller('ingredients') +export class IngredientsController { + constructor(private readonly ingredientsService: IngredientsService) {} + + @Get() + @ApiOperation({ summary: 'دریافت لیست ترکیبات فعال' }) + findAll() { + return this.ingredientsService.findAll(); + } + + @Get(':idOrSlug') + @ApiOperation({ summary: 'دریافت اطلاعات یک ترکیب بر اساس آیدی یا اسلاگ' }) + findOne(@Param('idOrSlug') idOrSlug: string) { + return this.ingredientsService.findOne(idOrSlug); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('Admin') + @ApiBearerAuth() + @Post() + @ApiOperation({ summary: 'ایجاد ترکیب جدید (نیازمند ادمین)' }) + create(@Body() body: any) { + return this.ingredientsService.create(body); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('Admin') + @ApiBearerAuth() + @Patch(':id') + @ApiOperation({ summary: 'ویرایش اطلاعات ترکیب (نیازمند ادمین)' }) + update(@Param('id') id: string, @Body() body: any) { + return this.ingredientsService.update(id, body); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('Admin') + @ApiBearerAuth() + @Delete(':id') + @ApiOperation({ summary: 'حذف ترکیب (نیازمند ادمین)' }) + remove(@Param('id') id: string) { + return this.ingredientsService.remove(id); + } +} diff --git a/backend/src/ingredients/ingredients.module.ts b/backend/src/ingredients/ingredients.module.ts new file mode 100644 index 0000000..565fcd0 --- /dev/null +++ b/backend/src/ingredients/ingredients.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { IngredientsController } from './ingredients.controller'; +import { IngredientsService } from './ingredients.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [IngredientsController], + providers: [IngredientsService], + exports: [IngredientsService], +}) +export class IngredientsModule {} diff --git a/backend/src/ingredients/ingredients.service.ts b/backend/src/ingredients/ingredients.service.ts new file mode 100644 index 0000000..7217ba4 --- /dev/null +++ b/backend/src/ingredients/ingredients.service.ts @@ -0,0 +1,54 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { Prisma } from '@prisma/client'; + +@Injectable() +export class IngredientsService { + constructor(private prisma: PrismaService) {} + + async findAll() { + return this.prisma.ingredient.findMany({ + orderBy: { nameFa: 'asc' }, + }); + } + + async findOne(idOrSlug: string) { + const isUuid = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test( + idOrSlug, + ); + const ingredient = await this.prisma.ingredient.findFirst({ + where: isUuid ? { id: idOrSlug } : { slug: idOrSlug }, + }); + + if (!ingredient) { + throw new NotFoundException(`Ingredient ${idOrSlug} not found`); + } + return ingredient; + } + + async create(data: Prisma.IngredientCreateInput) { + return this.prisma.ingredient.create({ + data, + }); + } + + async update(id: string, data: Prisma.IngredientUpdateInput) { + const item = await this.prisma.ingredient.findUnique({ where: { id } }); + if (!item) { + throw new NotFoundException(`Ingredient with ID ${id} not found`); + } + return this.prisma.ingredient.update({ + where: { id }, + data, + }); + } + + async remove(id: string) { + const item = await this.prisma.ingredient.findUnique({ where: { id } }); + if (!item) { + throw new NotFoundException(`Ingredient with ID ${id} not found`); + } + return this.prisma.ingredient.delete({ where: { id } }); + } +} diff --git a/backend/src/prescriptions/prescriptions.controller.ts b/backend/src/prescriptions/prescriptions.controller.ts new file mode 100644 index 0000000..b760bc8 --- /dev/null +++ b/backend/src/prescriptions/prescriptions.controller.ts @@ -0,0 +1,61 @@ +import { + Controller, + Get, + Post, + Patch, + Body, + Param, + UseGuards, + Req, +} from '@nestjs/common'; +import { PrescriptionsService } from './prescriptions.service'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; + +@ApiTags('Prescriptions - نسخه و تاییدیه دارویی') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +@Controller('prescriptions') +export class PrescriptionsController { + constructor(private readonly prescriptionsService: PrescriptionsService) {} + + @Post() + @ApiOperation({ summary: 'بارگذاری نسخه جدید توسط کاربر' }) + create( + @Req() req: any, + @Body() body: { petId?: string; fileUrl: string; notes?: string }, + ) { + const userId = req.user.id || req.user.userId; + return this.prescriptionsService.create(userId, body); + } + + @Get() + @ApiOperation({ summary: 'دریافت لیست نسخه‌ها (کاربر یا ادمین)' }) + findAll(@Req() req: any) { + const userId = req.user.id || req.user.userId; + const isAdmin = req.user.role === 'Admin'; + return this.prescriptionsService.findAll(userId, isAdmin); + } + + @Get(':id') + @ApiOperation({ summary: 'دریافت جزئیات یک نسخه' }) + findOne(@Req() req: any, @Param('id') id: string) { + const userId = req.user.id || req.user.userId; + const isAdmin = req.user.role === 'Admin'; + return this.prescriptionsService.findOne(id, userId, isAdmin); + } + + @UseGuards(RolesGuard) + @Roles('Admin') + @Patch(':id/review') + @ApiOperation({ summary: 'بررسی و تایید/رد نسخه دارویی (نیازمند ادمین)' }) + review( + @Param('id') id: string, + @Body() + body: { status: 'APPROVED' | 'REJECTED' | 'PENDING'; adminNotes?: string }, + ) { + return this.prescriptionsService.review(id, body); + } +} diff --git a/backend/src/prescriptions/prescriptions.module.ts b/backend/src/prescriptions/prescriptions.module.ts new file mode 100644 index 0000000..8e821aa --- /dev/null +++ b/backend/src/prescriptions/prescriptions.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { PrescriptionsController } from './prescriptions.controller'; +import { PrescriptionsService } from './prescriptions.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [PrescriptionsController], + providers: [PrescriptionsService], + exports: [PrescriptionsService], +}) +export class PrescriptionsModule {} diff --git a/backend/src/prescriptions/prescriptions.service.ts b/backend/src/prescriptions/prescriptions.service.ts new file mode 100644 index 0000000..9abdf17 --- /dev/null +++ b/backend/src/prescriptions/prescriptions.service.ts @@ -0,0 +1,79 @@ +import { + Injectable, + NotFoundException, + ForbiddenException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class PrescriptionsService { + constructor(private prisma: PrismaService) {} + + async create( + userId: string, + data: { petId?: string; fileUrl: string; notes?: string }, + ) { + return this.prisma.prescription.create({ + data: { + userId, + petId: data.petId, + fileUrl: data.fileUrl, + notes: data.notes, + status: 'PENDING', + }, + include: { pet: true }, + }); + } + + async findAll(userId: string, isAdmin: boolean) { + if (isAdmin) { + return this.prisma.prescription.findMany({ + orderBy: { createdAt: 'desc' }, + include: { user: true, pet: true }, + }); + } + return this.prisma.prescription.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + include: { pet: true }, + }); + } + + async findOne(id: string, userId: string, isAdmin: boolean) { + const item = await this.prisma.prescription.findUnique({ + where: { id }, + include: { user: true, pet: true }, + }); + + if (!item) { + throw new NotFoundException(`Prescription with ID ${id} not found`); + } + + if (!isAdmin && item.userId !== userId) { + throw new ForbiddenException('Access denied to this prescription'); + } + + return item; + } + + async review( + id: string, + reviewData: { + status: 'APPROVED' | 'REJECTED' | 'PENDING'; + adminNotes?: string; + }, + ) { + const item = await this.prisma.prescription.findUnique({ where: { id } }); + if (!item) { + throw new NotFoundException(`Prescription with ID ${id} not found`); + } + return this.prisma.prescription.update({ + where: { id }, + data: { + status: reviewData.status, + adminNotes: reviewData.adminNotes, + }, + include: { user: true, pet: true }, + }); + } +} diff --git a/backend/src/products/products.controller.ts b/backend/src/products/products.controller.ts index e571ba5..121096f 100644 --- a/backend/src/products/products.controller.ts +++ b/backend/src/products/products.controller.ts @@ -1,10 +1,13 @@ import { Controller, Get, + Patch, + Body, Query, Param, NotFoundException, HttpStatus, + UseGuards, } from '@nestjs/common'; import { ProductsService } from './products.service'; import { GetProductsDto } from './dto/get-products.dto'; @@ -15,62 +18,21 @@ import { ApiOkResponse, ApiNotFoundResponse, } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; @ApiTags('Products - مدیریت محصولات دارویی') @Controller('products') @ApiResponse({ status: HttpStatus.INTERNAL_SERVER_ERROR, description: 'خطای داخلی سرور', - schema: { - example: { - success: false, - message: 'خطای داخلی سرور', - code: 'SERVER_ERROR', - details: {}, - }, - }, }) export class ProductsController { constructor(private readonly productsService: ProductsService) {} @Get() @ApiOperation({ summary: 'لیست و فیلتر محصولات' }) - @ApiOkResponse({ - description: 'لیست محصولات متناسب با فیلترها (دسته، پت و جستجو)', - schema: { - example: { - data: [ - { - id: 'a1b2c3d4-1234-5678-abcd-ef1234567890', - artNo: 'canhydrox-gag', - name: 'Canhydrox GAG (کنهیدروکس)', - scientificTagline: 'برای تقویت مفاصل و استخوان‌ها', - description: - 'کنهیدروکس محصولی بی‌نظیر برای مفاصل و سیستم حرکتی سگ‌ها...', - shortDescription: 'تقویت مفاصل و غضروف‌ها', - category: 'سیستم حرکتی و مفاصل', - categorySlug: 'joints', - priceValue: '1750000.00', - priceDisplay: '۱,۷۵۰,۰۰۰ تومان', - unit: 'عدد قرص', - packageSize: '120.00', - dosageLogic: 'یک قرص به ازای هر ۱۰ کیلوگرم وزن بدن سگ', - suitableFor: 'سگ', - imageUrl: 'https://example.com/canhydrox.png', - createdAt: '2026-05-26T18:10:00.000Z', - ingredients: [], - symptoms: [], - }, - ], - meta: { - total: 1, - page: 1, - lastPage: 1, - limit: 10, - }, - }, - }, - }) findAll(@Query() query: GetProductsDto) { return this.productsService.findAll(query); } @@ -79,16 +41,6 @@ export class ProductsController { @ApiOperation({ summary: 'دریافت فیلترهای فعال محصولات (دسته، علائم، نوع پت)', }) - @ApiOkResponse({ - description: 'لیست فیلترهای پویا استخراج شده از دیتابیس', - schema: { - example: { - categories: [{ id: '1', name: 'مفاصل و استخوان', slug: 'joints' }], - symptoms: ['لنگش', 'ریزش مو'], - petTypes: ['سگ', 'گربه', 'هر دو'], - }, - }, - }) getActiveFilters() { return this.productsService.getActiveFilters(); } @@ -97,72 +49,28 @@ export class ProductsController { @ApiOperation({ summary: 'دریافت فیلترها و راهکارهای ناوبری (دسته با علائم مربوطه)', }) - @ApiOkResponse({ - description: 'ساختار درختی فیلترها و تگ‌های درمانی واقعی متصل به محصولات', - schema: { - example: [ - { - id: '1', - name: 'مفاصل و استخوان', - slug: 'joints', - symptoms: ['درد مفاصل', 'لنگش'], - }, - ], - }, - }) getNavigationFilters() { return this.productsService.getNavigationFilters(); } + @Get(':id/dosage-config') + @ApiOperation({ summary: 'دریافت پیکربندی دوز مصرف محصول' }) + getDosageConfig(@Param('id') id: string) { + return this.productsService.getDosageConfig(id); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('Admin') + @Patch(':id/dosage-config') + @ApiOperation({ + summary: 'ویرایش پیکربندی دوز مصرف محصول (نیازمند دسترسی ادمین)', + }) + updateDosageConfig(@Param('id') id: string, @Body() body: any) { + return this.productsService.updateDosageConfig(id, body); + } + @Get(':id') @ApiOperation({ summary: 'دریافت اطلاعات محصول با شناسه یکتا (ID)' }) - @ApiOkResponse({ - description: 'اطلاعات کامل محصول شامل مواد تشکیل‌دهنده و علائم مرتبط', - schema: { - example: { - id: 'a1b2c3d4-1234-5678-abcd-ef1234567890', - artNo: 'canhydrox-gag', - name: 'Canhydrox GAG (کنهیدروکس)', - scientificTagline: 'برای تقویت مفاصل و استخوان‌ها', - description: 'کنهیدروکس محصولی بی‌نظیر برای مفاصل...', - shortDescription: 'تقویت مفاصل و غضروف‌ها', - category: 'سیستم حرکتی و مفاصل', - categorySlug: 'joints', - priceValue: '1750000.00', - priceDisplay: '۱,۷۵۰,۰۰۰ تومان', - unit: 'عدد قرص', - packageSize: '120.00', - dosageLogic: 'یک قرص به ازای هر ۱۰ کیلوگرم وزن بدن سگ', - suitableFor: 'سگ', - imageUrl: 'https://example.com/canhydrox.png', - createdAt: '2026-05-26T18:10:00.000Z', - ingredients: [ - { - productId: 'a1b2c3d4-1234-5678-abcd-ef1234567890', - ingredient: 'صدف لب‌سبز', - }, - ], - symptoms: [ - { - productId: 'a1b2c3d4-1234-5678-abcd-ef1234567890', - symptom: 'لنگیدن', - }, - ], - }, - }, - }) - @ApiNotFoundResponse({ - description: 'محصول با شناسه ارسال شده پیدا نشد', - schema: { - example: { - success: false, - message: - 'Product with ID a1b2c3d4-1234-5678-abcd-ef1234567890 not found', - code: 'NOT_FOUND', - details: {}, - }, - }, - }) async findOne(@Param('id') id: string) { const product = await this.productsService.findOne(id); if (!product) { diff --git a/backend/src/products/products.service.ts b/backend/src/products/products.service.ts index 1a0f56b..a7d8abe 100644 --- a/backend/src/products/products.service.ts +++ b/backend/src/products/products.service.ts @@ -1,6 +1,7 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { GetProductsDto } from './dto/get-products.dto'; +import { Prisma } from '@prisma/client'; @Injectable() export class ProductsService { @@ -21,7 +22,7 @@ export class ProductsService { sortOrder = 'desc', } = filters; - const andConditions: any[] = []; + const andConditions: Prisma.ProductWhereInput[] = []; if (requiresRx !== undefined && requiresRx !== null && requiresRx !== '') { andConditions.push({ @@ -75,7 +76,7 @@ export class ProductsService { }); } - const whereClause: any = + const whereClause: Prisma.ProductWhereInput = andConditions.length > 0 ? { AND: andConditions } : {}; const skip = (page - 1) * limit; @@ -102,13 +103,11 @@ export class ProductsService { const isAdmin = userRole === 'ADMIN' || userRole === 'SuperAdmin'; const data = rawProducts.map((p) => { - // Remove buyPrice for all non-admins - const { buyPrice, ...withoutBuyPrice } = p; + const { buyPrice: _b, wholesalePrice: _w, ...publicProduct } = p; if (!isWholesaleOrAdmin) { - const { wholesalePrice, ...publicProduct } = withoutBuyPrice; return publicProduct; } - return isAdmin ? p : withoutBuyPrice; + return isAdmin ? p : { ...publicProduct, wholesalePrice: p.wholesalePrice }; }); return { @@ -143,12 +142,11 @@ export class ProductsService { userRole === 'SuperAdmin'; const isAdmin = userRole === 'ADMIN' || userRole === 'SuperAdmin'; - const { buyPrice, ...withoutBuyPrice } = product; + const { buyPrice: _b, wholesalePrice: _w, ...publicProduct } = product; if (!isWholesaleOrAdmin) { - const { wholesalePrice, ...publicProduct } = withoutBuyPrice; return publicProduct; } - return isAdmin ? product : withoutBuyPrice; + return isAdmin ? product : { ...publicProduct, wholesalePrice: product.wholesalePrice }; } async getActiveFilters() { @@ -220,4 +218,26 @@ export class ProductsService { }; }); } + + async getDosageConfig(id: string) { + const product = await this.prisma.product.findUnique({ + where: { id }, + select: { dosageConfig: true, dosageLogic: true }, + }); + if (!product) { + throw new NotFoundException(`Product with ID ${id} not found`); + } + return product.dosageConfig || {}; + } + + async updateDosageConfig(id: string, dosageConfig: Prisma.InputJsonValue) { + const product = await this.prisma.product.findUnique({ where: { id } }); + if (!product) { + throw new NotFoundException(`Product with ID ${id} not found`); + } + return this.prisma.product.update({ + where: { id }, + data: { dosageConfig }, + }); + } } diff --git a/backend/src/settings/settings.controller.ts b/backend/src/settings/settings.controller.ts index 3ec231f..8a02b28 100644 --- a/backend/src/settings/settings.controller.ts +++ b/backend/src/settings/settings.controller.ts @@ -7,7 +7,6 @@ import { Body, Param, UseGuards, - HttpStatus, } from '@nestjs/common'; import { SettingsService } from './settings.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; @@ -17,12 +16,10 @@ import { ApiTags, ApiOperation, ApiBearerAuth, - ApiResponse, ApiOkResponse, - ApiUnauthorizedResponse, } from '@nestjs/swagger'; -@ApiTags('Settings - تنظیمات متون پویا و واژه‌نامه علمی') +@ApiTags('Settings - تنظیمات متون پویا، تنظیمات سئو، مالی و سیستم') @UseGuards(JwtAuthGuard, RolesGuard) @Roles('Admin') @Controller('settings') @@ -32,15 +29,7 @@ export class SettingsController { @Get('ui-texts') @ApiOperation({ summary: 'دریافت تمامی متون و پیکربندی‌های رابط کاربری' }) @ApiOkResponse({ - description: - 'یک آبجکت کلید-مقدار حاوی تمامی متون، شعارها و برچسب‌های دکمه‌ها', - schema: { - example: { - hero_badge: 'تخصص دارویی از آلمان', - hero_title: 'تخصص آلمانی در خدمت سلامت پت‌های خانگی', - hero_desc: 'بیش از ۴۰ سال تجربه نوآورانه...', - }, - }, + description: 'یک آبجکت کلید-مقدار حاوی تمامی متون، شعارها و برچسب‌های دکمه‌ها', }) getUiTexts() { return this.settingsService.getUiTexts(); @@ -49,45 +38,13 @@ export class SettingsController { @UseGuards(JwtAuthGuard) @ApiBearerAuth() @Patch('ui-texts/:key') - @ApiOperation({ summary: 'ویرایش متن یک کلید در رابط کاربری (نیازمند توکن)' }) - @ApiOkResponse({ - description: 'متن با موفقیت به‌روزرسانی شد', - schema: { - example: { - key: 'hero_badge', - value: 'تخصص دارویی ممتاز از آلمان', - }, - }, - }) - @ApiUnauthorizedResponse({ - description: 'عدم دسترسی به دلیل عدم احراز هویت', - schema: { - example: { - success: false, - message: 'Unauthorized', - code: 'UNAUTHORIZED', - }, - }, - }) + @ApiOperation({ summary: 'ویرایش متن یک کلید در رابط کاربری' }) updateUiText(@Param('key') key: string, @Body('value') value: string) { return this.settingsService.updateUiText(key, value); } @Get('scientific-terms') @ApiOperation({ summary: 'دریافت تمامی اصطلاحات واژه‌نامه علمی' }) - @ApiOkResponse({ - description: 'لیست کامل اصطلاحات علمی به همراه تعاریف و شناسه‌ها', - schema: { - example: [ - { - key: 'green-mussel', - term: 'صدف لب‌سبز (Perna Canaliculus)', - definition: 'این صدف بومی سواحل بکر نیوزیلند است...', - wikiId: 'general', - }, - ], - }, - }) getScientificTerms() { return this.settingsService.getScientificTerms(); } @@ -95,28 +52,7 @@ export class SettingsController { @UseGuards(JwtAuthGuard) @ApiBearerAuth() @Put('scientific-terms/:key') - @ApiOperation({ summary: 'ثبت یا ویرایش یک اصطلاح علمی (نیازمند توکن)' }) - @ApiOkResponse({ - description: 'اصطلاح علمی ثبت یا ویرایش شد', - schema: { - example: { - key: 'green-mussel', - term: 'صدف لب‌سبز اصل نیوزیلند', - definition: 'این صدف بومی سواحل بکر نیوزیلند است...', - wikiId: 'general', - }, - }, - }) - @ApiUnauthorizedResponse({ - description: 'عدم دسترسی', - schema: { - example: { - success: false, - message: 'Unauthorized', - code: 'UNAUTHORIZED', - }, - }, - }) + @ApiOperation({ summary: 'ثبت یا ویرایش یک اصطلاح علمی' }) upsertScientificTerm(@Param('key') key: string, @Body() data: any) { return this.settingsService.upsertScientificTerm(key, data); } @@ -124,27 +60,47 @@ export class SettingsController { @UseGuards(JwtAuthGuard) @ApiBearerAuth() @Delete('scientific-terms/:key') - @ApiOperation({ summary: 'حذف یک اصطلاح علمی (نیازمند توکن)' }) - @ApiOkResponse({ - description: 'اصطلاح علمی حذف شد', - schema: { - example: { - success: true, - message: 'Scientific term successfully deleted', - }, - }, - }) - @ApiUnauthorizedResponse({ - description: 'عدم دسترسی', - schema: { - example: { - success: false, - message: 'Unauthorized', - code: 'UNAUTHORIZED', - }, - }, - }) + @ApiOperation({ summary: 'حذف یک اصطلاح علمی' }) deleteScientificTerm(@Param('key') key: string) { return this.settingsService.deleteScientificTerm(key); } + + // SEO Settings + @Get('seo') + @ApiOperation({ summary: 'دریافت تنظیمات سئو' }) + getSeoSettings() { + return this.settingsService.getCategorySetting('seo'); + } + + @Patch('seo') + @ApiOperation({ summary: 'به‌روزرسانی تنظیمات سئو' }) + updateSeoSettings(@Body() body: any) { + return this.settingsService.updateCategorySetting('seo', body); + } + + // Financial Settings + @Get('financial') + @ApiOperation({ summary: 'دریافت تنظیمات مالی' }) + getFinancialSettings() { + return this.settingsService.getCategorySetting('financial'); + } + + @Patch('financial') + @ApiOperation({ summary: 'به‌روزرسانی تنظیمات مالی' }) + updateFinancialSettings(@Body() body: any) { + return this.settingsService.updateCategorySetting('financial', body); + } + + // System Settings + @Get('system') + @ApiOperation({ summary: 'دریافت تنظیمات سیستم' }) + getSystemSettings() { + return this.settingsService.getCategorySetting('system'); + } + + @Patch('system') + @ApiOperation({ summary: 'به‌روزرسانی تنظیمات سیستم' }) + updateSystemSettings(@Body() body: any) { + return this.settingsService.updateCategorySetting('system', body); + } } diff --git a/backend/src/settings/settings.service.ts b/backend/src/settings/settings.service.ts index cb59e11..a6f1bc8 100644 --- a/backend/src/settings/settings.service.ts +++ b/backend/src/settings/settings.service.ts @@ -22,18 +22,22 @@ export class SettingsService { } async upsertScientificTerm(key: string, data: any) { + const term = String(data?.term || ''); + const definition = String(data?.definition || ''); + const wikiId = String(data?.wikiId || 'general'); + return this.prisma.scientificTerm.upsert({ where: { key }, update: { - term: data.term, - definition: data.definition, - wikiId: data.wikiId || 'general', + term, + definition, + wikiId, }, create: { key, - term: data.term, - definition: data.definition, - wikiId: data.wikiId || 'general', + term, + definition, + wikiId, }, }); } @@ -43,4 +47,20 @@ export class SettingsService { where: { key }, }); } + + async getCategorySetting(category: string) { + const setting = await this.prisma.setting.findFirst({ + where: { category }, + }); + return setting ? setting.value : {}; + } + + async updateCategorySetting(category: string, value: any) { + const key = `${category}_config`; + return this.prisma.setting.upsert({ + where: { key }, + update: { category, value }, + create: { key, category, value }, + }); + } } diff --git a/backend/src/smart-advisor/smart-advisor.controller.ts b/backend/src/smart-advisor/smart-advisor.controller.ts new file mode 100644 index 0000000..7fc0ce9 --- /dev/null +++ b/backend/src/smart-advisor/smart-advisor.controller.ts @@ -0,0 +1,54 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + UseGuards, +} from '@nestjs/common'; +import { SmartAdvisorService } from './smart-advisor.service'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; + +@ApiTags('Smart Advisor - دستیار هوشمند توصیه دارویی') +@Controller('smart-advisor/rules') +export class SmartAdvisorController { + constructor(private readonly smartAdvisorService: SmartAdvisorService) {} + + @Get() + @ApiOperation({ summary: 'لیست قانون‌های دستیار هوشمند' }) + findAll() { + return this.smartAdvisorService.findAll(); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('Admin') + @ApiBearerAuth() + @Post() + @ApiOperation({ summary: 'ایجاد قانون جدید دستیار هوشمند (نیازمند ادمین)' }) + create(@Body() body: any) { + return this.smartAdvisorService.create(body); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('Admin') + @ApiBearerAuth() + @Patch(':id') + @ApiOperation({ summary: 'ویرایش قانون دستیار هوشمند (نیازمند ادمین)' }) + update(@Param('id') id: string, @Body() body: any) { + return this.smartAdvisorService.update(id, body); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('Admin') + @ApiBearerAuth() + @Delete(':id') + @ApiOperation({ summary: 'حذف قانون دستیار هوشمند (نیازمند ادمین)' }) + remove(@Param('id') id: string) { + return this.smartAdvisorService.remove(id); + } +} diff --git a/backend/src/smart-advisor/smart-advisor.module.ts b/backend/src/smart-advisor/smart-advisor.module.ts new file mode 100644 index 0000000..01f9536 --- /dev/null +++ b/backend/src/smart-advisor/smart-advisor.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { SmartAdvisorController } from './smart-advisor.controller'; +import { SmartAdvisorService } from './smart-advisor.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [SmartAdvisorController], + providers: [SmartAdvisorService], + exports: [SmartAdvisorService], +}) +export class SmartAdvisorModule {} diff --git a/backend/src/smart-advisor/smart-advisor.service.ts b/backend/src/smart-advisor/smart-advisor.service.ts new file mode 100644 index 0000000..bb0dbd5 --- /dev/null +++ b/backend/src/smart-advisor/smart-advisor.service.ts @@ -0,0 +1,41 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { Prisma } from '@prisma/client'; + +@Injectable() +export class SmartAdvisorService { + constructor(private prisma: PrismaService) {} + + async findAll() { + return this.prisma.smartAdvisorRule.findMany({ + include: { product: true }, + }); + } + + async create(data: Prisma.SmartAdvisorRuleCreateInput) { + return this.prisma.smartAdvisorRule.create({ + data, + include: { product: true }, + }); + } + + async update(id: string, data: Prisma.SmartAdvisorRuleUpdateInput) { + const rule = await this.prisma.smartAdvisorRule.findUnique({ where: { id } }); + if (!rule) { + throw new NotFoundException(`SmartAdvisorRule with ID ${id} not found`); + } + return this.prisma.smartAdvisorRule.update({ + where: { id }, + data, + include: { product: true }, + }); + } + + async remove(id: string) { + const rule = await this.prisma.smartAdvisorRule.findUnique({ where: { id } }); + if (!rule) { + throw new NotFoundException(`SmartAdvisorRule with ID ${id} not found`); + } + return this.prisma.smartAdvisorRule.delete({ where: { id } }); + } +} diff --git a/backend/src/testimonials/testimonials.controller.ts b/backend/src/testimonials/testimonials.controller.ts new file mode 100644 index 0000000..3a6847b --- /dev/null +++ b/backend/src/testimonials/testimonials.controller.ts @@ -0,0 +1,54 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + UseGuards, +} from '@nestjs/common'; +import { TestimonialsService } from './testimonials.service'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RolesGuard } from '../common/guards/roles.guard'; +import { Roles } from '../common/decorators/roles.decorator'; + +@ApiTags('Testimonials - نظرات و رضایت‌نامه‌ها') +@Controller('testimonials') +export class TestimonialsController { + constructor(private readonly testimonialsService: TestimonialsService) {} + + @Get() + @ApiOperation({ summary: 'دریافت لیست نظرات و رضایت‌نامه‌ها' }) + findAll() { + return this.testimonialsService.findAll(); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('Admin') + @ApiBearerAuth() + @Post() + @ApiOperation({ summary: 'ایجاد نظر جدید (نیازمند ادمین)' }) + create(@Body() body: any) { + return this.testimonialsService.create(body); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('Admin') + @ApiBearerAuth() + @Patch(':id') + @ApiOperation({ summary: 'ویرایش نظر (نیازمند ادمین)' }) + update(@Param('id') id: string, @Body() body: any) { + return this.testimonialsService.update(id, body); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('Admin') + @ApiBearerAuth() + @Delete(':id') + @ApiOperation({ summary: 'حذف نظر (نیازمند ادمین)' }) + remove(@Param('id') id: string) { + return this.testimonialsService.remove(id); + } +} diff --git a/backend/src/testimonials/testimonials.module.ts b/backend/src/testimonials/testimonials.module.ts new file mode 100644 index 0000000..56d234c --- /dev/null +++ b/backend/src/testimonials/testimonials.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TestimonialsController } from './testimonials.controller'; +import { TestimonialsService } from './testimonials.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [TestimonialsController], + providers: [TestimonialsService], + exports: [TestimonialsService], +}) +export class TestimonialsModule {} diff --git a/backend/src/testimonials/testimonials.service.ts b/backend/src/testimonials/testimonials.service.ts new file mode 100644 index 0000000..9909bba --- /dev/null +++ b/backend/src/testimonials/testimonials.service.ts @@ -0,0 +1,39 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { Prisma } from '@prisma/client'; + +@Injectable() +export class TestimonialsService { + constructor(private prisma: PrismaService) {} + + async findAll() { + return this.prisma.testimonial.findMany({ + orderBy: { order: 'asc' }, + }); + } + + async create(data: Prisma.TestimonialCreateInput) { + return this.prisma.testimonial.create({ + data, + }); + } + + async update(id: string, data: Prisma.TestimonialUpdateInput) { + const item = await this.prisma.testimonial.findUnique({ where: { id } }); + if (!item) { + throw new NotFoundException(`Testimonial with ID ${id} not found`); + } + return this.prisma.testimonial.update({ + where: { id }, + data, + }); + } + + async remove(id: string) { + const item = await this.prisma.testimonial.findUnique({ where: { id } }); + if (!item) { + throw new NotFoundException(`Testimonial with ID ${id} not found`); + } + return this.prisma.testimonial.delete({ where: { id } }); + } +} diff --git a/backend/test/app-audit-verification.e2e-spec.ts b/backend/test/app-audit-verification.e2e-spec.ts index 45a6858..053f069 100644 --- a/backend/test/app-audit-verification.e2e-spec.ts +++ b/backend/test/app-audit-verification.e2e-spec.ts @@ -29,7 +29,9 @@ describe('Phase 4 Master Backlog E2E Verification Matrix (e2e)', () => { app = moduleFixture.createNestApplication(); app.setGlobalPrefix('api'); - app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); + app.useGlobalPipes( + new ValidationPipe({ transform: true, whitelist: true }), + ); app.useGlobalFilters( new CustomHttpExceptionFilter(), new PrismaExceptionFilter(), @@ -76,12 +78,12 @@ describe('Phase 4 Master Backlog E2E Verification Matrix (e2e)', () => { } }; - expect(() => validateStartup('short', process.env.JWT_REFRESH_SECRET)).toThrow( - 'FATAL: JWT_ACCESS_SECRET missing or short', - ); - expect(() => validateStartup(process.env.JWT_ACCESS_SECRET, 'short')).toThrow( - 'FATAL: JWT_REFRESH_SECRET missing or short', - ); + expect(() => + validateStartup('short', process.env.JWT_REFRESH_SECRET), + ).toThrow('FATAL: JWT_ACCESS_SECRET missing or short'); + expect(() => + validateStartup(process.env.JWT_ACCESS_SECRET, 'short'), + ).toThrow('FATAL: JWT_REFRESH_SECRET missing or short'); expect(() => validateStartup( process.env.JWT_ACCESS_SECRET, diff --git a/docs/audit/CROSS_BOUNDARY_DEPENDENCIES.md b/docs/audit/CROSS_BOUNDARY_DEPENDENCIES.md new file mode 100644 index 0000000..3ae4cc9 --- /dev/null +++ b/docs/audit/CROSS_BOUNDARY_DEPENDENCIES.md @@ -0,0 +1,29 @@ +# Cross Boundary Dependencies & Backend Architecture Specification (Phase A) + +## Overview +This specification details the database models and backend controller endpoints required to support all 10 cross-boundary feature domains across the Canina Pharma platform. + +## 10 Feature Domains + +1. **SEO Settings**: Global & category-level search engine metadata management (`/api/settings/seo`). +2. **Banners**: Dynamic promotion banners and hero sliders (`/api/banners`). +3. **Smart Advisor**: Rule-based veterinary product recommendation engine (`/api/smart-advisor/rules`). +4. **Testimonials**: Verified customer and veterinary doctor testimonials (`/api/testimonials`). +5. **Ingredients**: Scientific active ingredient glossary and properties (`/api/ingredients`). +6. **Product Dosage**: Dynamic weight-based dosage configuration for medical products (`/api/products/:id/dosage-config`). +7. **Financial Settings**: Platform tax rates, shipping rates, and charity contribution percentages (`/api/settings/financial`). +8. **System Settings**: System operational flags, maintenance modes, and feature toggles (`/api/settings/system`). +9. **Prescriptions**: Veterinary prescription upload, verification, and approval workflow (`/api/prescriptions`). +10. **B2B Logic**: Wholesale partner registration, credit limits, and bulk inquiries (`/api/b2b`). + +## Schema Additions + +- **`Setting`**: Stores category-based (`seo`, `financial`, `system`) key-value dynamic configurations. +- **`Banner`**: Hero and section promotion banners with custom display ordering. +- **`SmartAdvisorRule`**: Condition and pet type matching rules linking to recommended products. +- **`Testimonial`**: Customer & vet testimonials with rating and ordering. +- **`Ingredient`**: Farsi/English ingredient details, scientific names, and benefits list. +- **`Prescription`**: User & pet prescription attachments with verification status. +- **`B2BInquiry`**: Commercial partner inquiries with business metadata. +- **`PartnerAccount`**: Credit limits and wholesale tiers for approved B2B clients. +- **`Product.dosageConfig`**: JSON field holding dynamic calculation parameters per product.