diff --git a/src/marketplace/controllers/analytics.controller.ts b/src/marketplace/controllers/analytics.controller.ts new file mode 100644 index 00000000..32d5ca2c --- /dev/null +++ b/src/marketplace/controllers/analytics.controller.ts @@ -0,0 +1,71 @@ +import { + Controller, + Get, + Query, + Param, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { AnalyticsService } from '../services/analytics.service'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { GetUser } from '../../auth/decorators/get-user.decorator'; + +@ApiTags('Analytics') +@Controller('analytics') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class AnalyticsController { + constructor(private readonly analyticsService: AnalyticsService) {} + + @Get('vendor/:vendorId/dashboard') + @ApiOperation({ summary: 'Get vendor analytics dashboard' }) + @ApiResponse({ status: 200, description: 'Vendor dashboard retrieved successfully' }) + async getVendorDashboard( + @Param('vendorId') vendorId: string, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + ) { + const dateRange = startDate && endDate ? { + start: new Date(startDate), + end: new Date(endDate), + } : undefined; + + return this.analyticsService.getVendorDashboard(vendorId, dateRange); + } + + @Get('my-dashboard') + @ApiOperation({ summary: 'Get current vendor analytics dashboard' }) + @ApiResponse({ status: 200, description: 'Vendor dashboard retrieved successfully' }) + async getMyDashboard( + @GetUser('vendorId') vendorId: string, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + ) { + const dateRange = startDate && endDate ? { + start: new Date(startDate), + end: new Date(endDate), + } : undefined; + + return this.analyticsService.getVendorDashboard(vendorId, dateRange); + } + + @Get('marketplace/dashboard') + @UseGuards(RolesGuard) + @Roles('admin', 'analyst') + @ApiOperation({ summary: 'Get marketplace analytics dashboard (Admin only)' }) + @ApiResponse({ status: 200, description: 'Marketplace dashboard retrieved successfully' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + async getMarketplaceDashboard( + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + ) { + const dateRange = startDate && endDate ? { + start: new Date(startDate), + end: new Date(endDate), + } : undefined; + + return this.analyticsService.getMarketplaceDashboard(dateRange); + } +} diff --git a/src/marketplace/controllers/booking.controller.ts b/src/marketplace/controllers/booking.controller.ts new file mode 100644 index 00000000..8c9948bd --- /dev/null +++ b/src/marketplace/controllers/booking.controller.ts @@ -0,0 +1,139 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + UseGuards, + HttpStatus, + HttpCode, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { BookingService } from '../services/booking.service'; +import { CreateBookingDto } from '../dto/create-booking.dto'; +import { UpdateBookingDto } from '../dto/update-booking.dto'; +import { BookingQueryDto } from '../dto/booking-query.dto'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { GetUser } from '../../auth/decorators/get-user.decorator'; + +@ApiTags('Bookings') +@Controller('bookings') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class BookingController { + constructor(private readonly bookingService: BookingService) {} + + @Post() + @ApiOperation({ summary: 'Create a new service booking' }) + @ApiResponse({ status: 201, description: 'Booking created successfully' }) + @ApiResponse({ status: 400, description: 'Bad request' }) + async create(@Body() createBookingDto: CreateBookingDto) { + return this.bookingService.createBooking(createBookingDto); + } + + @Get() + @ApiOperation({ summary: 'Get bookings with filtering and pagination' }) + @ApiResponse({ status: 200, description: 'Bookings retrieved successfully' }) + async findAll(@Query() query: BookingQueryDto) { + return this.bookingService.findBookings(query); + } + + @Get('my-bookings') + @ApiOperation({ summary: 'Get current user bookings' }) + @ApiResponse({ status: 200, description: 'User bookings retrieved successfully' }) + async getMyBookings( + @GetUser('id') userId: string, + @Query() query: BookingQueryDto, + ) { + query.organizerId = userId; + return this.bookingService.findBookings(query); + } + + @Get('vendor/:vendorId/upcoming') + @ApiOperation({ summary: 'Get upcoming bookings for vendor' }) + @ApiResponse({ status: 200, description: 'Upcoming bookings retrieved successfully' }) + async getUpcomingBookings( + @Param('vendorId') vendorId: string, + @Query('limit') limit?: number, + ) { + return this.bookingService.getUpcomingBookings(vendorId, limit); + } + + @Get('vendor/:vendorId/calendar') + @ApiOperation({ summary: 'Get vendor bookings calendar' }) + @ApiResponse({ status: 200, description: 'Calendar bookings retrieved successfully' }) + async getVendorCalendar( + @Param('vendorId') vendorId: string, + @Query('startDate') startDate: string, + @Query('endDate') endDate: string, + ) { + return this.bookingService.getBookingsByDateRange( + vendorId, + new Date(startDate), + new Date(endDate), + ); + } + + @Get(':id') + @ApiOperation({ summary: 'Get booking by ID' }) + @ApiResponse({ status: 200, description: 'Booking retrieved successfully' }) + @ApiResponse({ status: 404, description: 'Booking not found' }) + async findOne(@Param('id') id: string) { + return this.bookingService.findBookingById(id); + } + + @Get('number/:bookingNumber') + @ApiOperation({ summary: 'Get booking by booking number' }) + @ApiResponse({ status: 200, description: 'Booking retrieved successfully' }) + @ApiResponse({ status: 404, description: 'Booking not found' }) + async findByNumber(@Param('bookingNumber') bookingNumber: string) { + return this.bookingService.findBookingByNumber(bookingNumber); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update booking details' }) + @ApiResponse({ status: 200, description: 'Booking updated successfully' }) + @ApiResponse({ status: 404, description: 'Booking not found' }) + async update( + @Param('id') id: string, + @Body() updateBookingDto: UpdateBookingDto, + ) { + return this.bookingService.updateBooking(id, updateBookingDto); + } + + @Post(':id/confirm') + @ApiOperation({ summary: 'Confirm booking' }) + @ApiResponse({ status: 200, description: 'Booking confirmed successfully' }) + @ApiResponse({ status: 400, description: 'Bad request' }) + @HttpCode(HttpStatus.OK) + async confirm(@Param('id') id: string) { + return this.bookingService.confirmBooking(id); + } + + @Post(':id/complete') + @ApiOperation({ summary: 'Mark booking as completed' }) + @ApiResponse({ status: 200, description: 'Booking completed successfully' }) + @ApiResponse({ status: 400, description: 'Bad request' }) + @HttpCode(HttpStatus.OK) + async complete(@Param('id') id: string) { + return this.bookingService.completeBooking(id); + } + + @Post(':id/cancel') + @ApiOperation({ summary: 'Cancel booking' }) + @ApiResponse({ status: 200, description: 'Booking cancelled successfully' }) + @ApiResponse({ status: 400, description: 'Bad request' }) + @HttpCode(HttpStatus.OK) + async cancel( + @Param('id') id: string, + @Body('reason') reason: string, + @Body('cancelledBy') cancelledBy: 'organizer' | 'vendor' | 'admin', + ) { + return this.bookingService.cancelBooking(id, reason, cancelledBy); + } +} diff --git a/src/marketplace/controllers/commission.controller.ts b/src/marketplace/controllers/commission.controller.ts new file mode 100644 index 00000000..ef47214b --- /dev/null +++ b/src/marketplace/controllers/commission.controller.ts @@ -0,0 +1,166 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Query, + UseGuards, + HttpStatus, + HttpCode, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { CommissionService } from '../services/commission.service'; +import { CommissionStatus } from '../entities/commission.entity'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { GetUser } from '../../auth/decorators/get-user.decorator'; + +@ApiTags('Commissions') +@Controller('commissions') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class CommissionController { + constructor(private readonly commissionService: CommissionService) {} + + @Get('vendor/:vendorId') + @ApiOperation({ summary: 'Get vendor commissions' }) + @ApiResponse({ status: 200, description: 'Commissions retrieved successfully' }) + async getVendorCommissions( + @Param('vendorId') vendorId: string, + @Query('status') status?: CommissionStatus, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + @Query('page') page?: number, + @Query('limit') limit?: number, + ) { + const filters: any = { page, limit }; + + if (status) filters.status = status; + if (startDate && endDate) { + filters.dateRange = { + start: new Date(startDate), + end: new Date(endDate), + }; + } + + return this.commissionService.getVendorCommissions(vendorId, filters); + } + + @Get('my-commissions') + @ApiOperation({ summary: 'Get current vendor commissions' }) + @ApiResponse({ status: 200, description: 'Commissions retrieved successfully' }) + async getMyCommissions( + @GetUser('vendorId') vendorId: string, + @Query('status') status?: CommissionStatus, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + @Query('page') page?: number, + @Query('limit') limit?: number, + ) { + const filters: any = { page, limit }; + + if (status) filters.status = status; + if (startDate && endDate) { + filters.dateRange = { + start: new Date(startDate), + end: new Date(endDate), + }; + } + + return this.commissionService.getVendorCommissions(vendorId, filters); + } + + @Get('platform/summary') + @UseGuards(RolesGuard) + @Roles('admin', 'finance') + @ApiOperation({ summary: 'Get platform commission summary (Admin only)' }) + @ApiResponse({ status: 200, description: 'Platform summary retrieved successfully' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + async getPlatformSummary( + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + ) { + const dateRange = startDate && endDate ? { + start: new Date(startDate), + end: new Date(endDate), + } : undefined; + + return this.commissionService.getPlatformCommissionSummary(dateRange); + } + + @Get('overdue') + @UseGuards(RolesGuard) + @Roles('admin', 'finance') + @ApiOperation({ summary: 'Get overdue commissions (Admin only)' }) + @ApiResponse({ status: 200, description: 'Overdue commissions retrieved successfully' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + async getOverdueCommissions() { + return this.commissionService.getOverdueCommissions(); + } + + @Get(':id') + @ApiOperation({ summary: 'Get commission by ID' }) + @ApiResponse({ status: 200, description: 'Commission retrieved successfully' }) + @ApiResponse({ status: 404, description: 'Commission not found' }) + async findOne(@Param('id') id: string) { + return this.commissionService.findCommissionById(id); + } + + @Post(':id/calculate') + @UseGuards(RolesGuard) + @Roles('admin', 'finance') + @ApiOperation({ summary: 'Calculate commission (Admin only)' }) + @ApiResponse({ status: 200, description: 'Commission calculated successfully' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @HttpCode(HttpStatus.OK) + async calculate(@Param('id') id: string) { + return this.commissionService.calculateCommission(id); + } + + @Post(':id/approve') + @UseGuards(RolesGuard) + @Roles('admin', 'finance') + @ApiOperation({ summary: 'Approve commission (Admin only)' }) + @ApiResponse({ status: 200, description: 'Commission approved successfully' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @HttpCode(HttpStatus.OK) + async approve(@Param('id') id: string) { + return this.commissionService.approveCommission(id); + } + + @Post(':id/process-payment') + @UseGuards(RolesGuard) + @Roles('admin', 'finance') + @ApiOperation({ summary: 'Process commission payment (Admin only)' }) + @ApiResponse({ status: 200, description: 'Commission payment processed successfully' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @HttpCode(HttpStatus.OK) + async processPayment(@Param('id') id: string) { + return this.commissionService.processCommissionPayment(id); + } + + @Post('bulk-approve') + @UseGuards(RolesGuard) + @Roles('admin', 'finance') + @ApiOperation({ summary: 'Bulk approve commissions (Admin only)' }) + @ApiResponse({ status: 200, description: 'Commissions approved successfully' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @HttpCode(HttpStatus.OK) + async bulkApprove(@Body('commissionIds') commissionIds: string[]) { + return this.commissionService.bulkApproveCommissions(commissionIds); + } + + @Post('distributions/:distributionId/retry') + @UseGuards(RolesGuard) + @Roles('admin', 'finance') + @ApiOperation({ summary: 'Retry failed payment distribution (Admin only)' }) + @ApiResponse({ status: 200, description: 'Distribution retry initiated successfully' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @HttpCode(HttpStatus.OK) + async retryDistribution(@Param('distributionId') distributionId: string) { + return this.commissionService.retryFailedDistribution(distributionId); + } +} diff --git a/src/marketplace/controllers/marketplace.controller.ts b/src/marketplace/controllers/marketplace.controller.ts new file mode 100644 index 00000000..79d0d690 --- /dev/null +++ b/src/marketplace/controllers/marketplace.controller.ts @@ -0,0 +1,159 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + UseGuards, + HttpStatus, + HttpCode, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { MarketplaceService } from '../services/marketplace.service'; +import { CreateServiceDto } from '../dto/create-service.dto'; +import { UpdateServiceDto } from '../dto/update-service.dto'; +import { ServiceSearchDto } from '../dto/service-search.dto'; +import { ServiceStatus } from '../entities/service-listing.entity'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { GetUser } from '../../auth/decorators/get-user.decorator'; + +@ApiTags('Marketplace') +@Controller('marketplace') +export class MarketplaceController { + constructor(private readonly marketplaceService: MarketplaceService) {} + + @Get('services') + @ApiOperation({ summary: 'Search and browse services' }) + @ApiResponse({ status: 200, description: 'Services retrieved successfully' }) + async findServices(@Query() searchDto: ServiceSearchDto) { + return this.marketplaceService.findServices(searchDto); + } + + @Get('services/featured') + @ApiOperation({ summary: 'Get featured services' }) + @ApiResponse({ status: 200, description: 'Featured services retrieved successfully' }) + async getFeaturedServices(@Query('limit') limit?: number) { + return this.marketplaceService.getFeaturedServices(limit); + } + + @Get('services/popular') + @ApiOperation({ summary: 'Get popular services' }) + @ApiResponse({ status: 200, description: 'Popular services retrieved successfully' }) + async getPopularServices(@Query('limit') limit?: number) { + return this.marketplaceService.getPopularServices(limit); + } + + @Get('services/recommended') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get recommended services for user' }) + @ApiResponse({ status: 200, description: 'Recommended services retrieved successfully' }) + async getRecommendedServices( + @GetUser('id') userId: string, + @Query('limit') limit?: number, + ) { + return this.marketplaceService.getRecommendedServices(userId, limit); + } + + @Get('services/category/:categoryId') + @ApiOperation({ summary: 'Get services by category' }) + @ApiResponse({ status: 200, description: 'Services retrieved successfully' }) + async getServicesByCategory( + @Param('categoryId') categoryId: string, + @Query('limit') limit?: number, + ) { + return this.marketplaceService.getServicesByCategory(categoryId, limit); + } + + @Get('services/vendor/:vendorId') + @ApiOperation({ summary: 'Get services by vendor' }) + @ApiResponse({ status: 200, description: 'Services retrieved successfully' }) + async getServicesByVendor(@Param('vendorId') vendorId: string) { + return this.marketplaceService.getServicesByVendor(vendorId); + } + + @Get('services/:id') + @ApiOperation({ summary: 'Get service by ID' }) + @ApiResponse({ status: 200, description: 'Service retrieved successfully' }) + @ApiResponse({ status: 404, description: 'Service not found' }) + async findServiceById(@Param('id') id: string) { + return this.marketplaceService.findServiceById(id); + } + + @Get('services/slug/:slug') + @ApiOperation({ summary: 'Get service by slug' }) + @ApiResponse({ status: 200, description: 'Service retrieved successfully' }) + @ApiResponse({ status: 404, description: 'Service not found' }) + async findServiceBySlug(@Param('slug') slug: string) { + return this.marketplaceService.findServiceBySlug(slug); + } + + @Post('services') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Create a new service listing' }) + @ApiResponse({ status: 201, description: 'Service created successfully' }) + @ApiResponse({ status: 400, description: 'Bad request' }) + async createService(@Body() createServiceDto: CreateServiceDto) { + return this.marketplaceService.createService(createServiceDto); + } + + @Patch('services/:id') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Update service listing' }) + @ApiResponse({ status: 200, description: 'Service updated successfully' }) + @ApiResponse({ status: 404, description: 'Service not found' }) + async updateService( + @Param('id') id: string, + @Body() updateServiceDto: UpdateServiceDto, + ) { + return this.marketplaceService.updateService(id, updateServiceDto); + } + + @Patch('services/:id/status') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'moderator') + @ApiBearerAuth() + @ApiOperation({ summary: 'Update service status (Admin/Moderator only)' }) + @ApiResponse({ status: 200, description: 'Service status updated successfully' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + async updateServiceStatus( + @Param('id') id: string, + @Body('status') status: ServiceStatus, + ) { + return this.marketplaceService.updateServiceStatus(id, status); + } + + @Post('services/:id/refresh-stats') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Refresh service statistics' }) + @ApiResponse({ status: 200, description: 'Statistics refreshed successfully' }) + @HttpCode(HttpStatus.OK) + async refreshServiceStats(@Param('id') id: string) { + await Promise.all([ + this.marketplaceService.updateServiceRating(id), + this.marketplaceService.updateServiceStats(id), + ]); + + return { message: 'Service statistics refreshed successfully' }; + } + + @Delete('services/:id') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Delete service listing' }) + @ApiResponse({ status: 200, description: 'Service deleted successfully' }) + @ApiResponse({ status: 404, description: 'Service not found' }) + @HttpCode(HttpStatus.OK) + async deleteService(@Param('id') id: string) { + await this.marketplaceService.deleteService(id); + return { message: 'Service deleted successfully' }; + } +} diff --git a/src/marketplace/controllers/vendor.controller.ts b/src/marketplace/controllers/vendor.controller.ts new file mode 100644 index 00000000..876ba9a6 --- /dev/null +++ b/src/marketplace/controllers/vendor.controller.ts @@ -0,0 +1,158 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + UseGuards, + HttpStatus, + HttpCode, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { VendorService } from '../services/vendor.service'; +import { CreateVendorDto } from '../dto/create-vendor.dto'; +import { UpdateVendorDto } from '../dto/update-vendor.dto'; +import { VendorQueryDto } from '../dto/vendor-query.dto'; +import { VendorStatus, VendorTier } from '../entities/vendor.entity'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { GetUser } from '../../auth/decorators/get-user.decorator'; + +@ApiTags('Vendors') +@Controller('vendors') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class VendorController { + constructor(private readonly vendorService: VendorService) {} + + @Post() + @ApiOperation({ summary: 'Register as a vendor' }) + @ApiResponse({ status: 201, description: 'Vendor registration successful' }) + @ApiResponse({ status: 400, description: 'Bad request' }) + async create(@Body() createVendorDto: CreateVendorDto) { + return this.vendorService.create(createVendorDto); + } + + @Get() + @ApiOperation({ summary: 'Get all vendors with filtering and pagination' }) + @ApiResponse({ status: 200, description: 'Vendors retrieved successfully' }) + async findAll(@Query() query: VendorQueryDto) { + return this.vendorService.findAll(query); + } + + @Get('featured') + @ApiOperation({ summary: 'Get featured vendors' }) + @ApiResponse({ status: 200, description: 'Featured vendors retrieved successfully' }) + async getFeatured(@Query('limit') limit?: number) { + return this.vendorService.getFeaturedVendors(limit); + } + + @Get('top-rated') + @ApiOperation({ summary: 'Get top-rated vendors' }) + @ApiResponse({ status: 200, description: 'Top-rated vendors retrieved successfully' }) + async getTopRated(@Query('limit') limit?: number) { + return this.vendorService.getTopRatedVendors(limit); + } + + @Get('search') + @ApiOperation({ summary: 'Search vendors' }) + @ApiResponse({ status: 200, description: 'Search results retrieved successfully' }) + async search( + @Query('q') searchTerm: string, + @Query('serviceType') serviceType?: string, + @Query('location') location?: string, + @Query('minPrice') minPrice?: number, + @Query('maxPrice') maxPrice?: number, + @Query('rating') rating?: number, + ) { + const filters: any = {}; + + if (serviceType) filters.serviceType = serviceType; + if (location) filters.location = location; + if (minPrice || maxPrice) { + filters.priceRange = { min: minPrice || 0, max: maxPrice || 999999 }; + } + if (rating) filters.rating = rating; + + return this.vendorService.searchVendors(searchTerm, filters); + } + + @Get('my-profile') + @ApiOperation({ summary: 'Get current user vendor profile' }) + @ApiResponse({ status: 200, description: 'Vendor profile retrieved successfully' }) + @ApiResponse({ status: 404, description: 'Vendor profile not found' }) + async getMyProfile(@GetUser('id') userId: string) { + return this.vendorService.findByUserId(userId); + } + + @Get(':id') + @ApiOperation({ summary: 'Get vendor by ID' }) + @ApiResponse({ status: 200, description: 'Vendor retrieved successfully' }) + @ApiResponse({ status: 404, description: 'Vendor not found' }) + async findOne(@Param('id') id: string) { + return this.vendorService.findOne(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update vendor profile' }) + @ApiResponse({ status: 200, description: 'Vendor updated successfully' }) + @ApiResponse({ status: 404, description: 'Vendor not found' }) + async update(@Param('id') id: string, @Body() updateVendorDto: UpdateVendorDto) { + return this.vendorService.update(id, updateVendorDto); + } + + @Patch(':id/status') + @UseGuards(RolesGuard) + @Roles('admin', 'moderator') + @ApiOperation({ summary: 'Update vendor status (Admin only)' }) + @ApiResponse({ status: 200, description: 'Vendor status updated successfully' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + async updateStatus( + @Param('id') id: string, + @Body('status') status: VendorStatus, + ) { + return this.vendorService.updateStatus(id, status); + } + + @Patch(':id/tier') + @UseGuards(RolesGuard) + @Roles('admin') + @ApiOperation({ summary: 'Update vendor tier (Admin only)' }) + @ApiResponse({ status: 200, description: 'Vendor tier updated successfully' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + async updateTier( + @Param('id') id: string, + @Body('tier') tier: VendorTier, + ) { + return this.vendorService.updateTier(id, tier); + } + + @Post(':id/refresh-stats') + @ApiOperation({ summary: 'Refresh vendor statistics' }) + @ApiResponse({ status: 200, description: 'Statistics refreshed successfully' }) + @HttpCode(HttpStatus.OK) + async refreshStats(@Param('id') id: string) { + await Promise.all([ + this.vendorService.updateRating(id), + this.vendorService.updateStats(id), + ]); + + return { message: 'Statistics refreshed successfully' }; + } + + @Delete(':id') + @UseGuards(RolesGuard) + @Roles('admin') + @ApiOperation({ summary: 'Delete vendor (Admin only)' }) + @ApiResponse({ status: 200, description: 'Vendor deleted successfully' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @HttpCode(HttpStatus.OK) + async remove(@Param('id') id: string) { + await this.vendorService.remove(id); + return { message: 'Vendor deleted successfully' }; + } +} diff --git a/src/marketplace/dto/booking-query.dto.ts b/src/marketplace/dto/booking-query.dto.ts new file mode 100644 index 00000000..814f89a6 --- /dev/null +++ b/src/marketplace/dto/booking-query.dto.ts @@ -0,0 +1,49 @@ +import { IsOptional, IsString, IsEnum, IsObject, IsNumber, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; +import { BookingStatus } from '../entities/service-booking.entity'; + +export class BookingQueryDto { + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + @Max(100) + limit?: number = 20; + + @IsOptional() + @IsEnum(BookingStatus) + status?: BookingStatus | BookingStatus[]; + + @IsOptional() + @IsString() + vendorId?: string; + + @IsOptional() + @IsString() + organizerId?: string; + + @IsOptional() + @IsString() + eventId?: string; + + @IsOptional() + @IsObject() + dateRange?: { + start: Date; + end: Date; + }; + + @IsOptional() + @IsString() + sortBy?: string = 'createdAt'; + + @IsOptional() + @IsEnum(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC' = 'DESC'; +} diff --git a/src/marketplace/dto/create-booking.dto.ts b/src/marketplace/dto/create-booking.dto.ts new file mode 100644 index 00000000..1caeed7a --- /dev/null +++ b/src/marketplace/dto/create-booking.dto.ts @@ -0,0 +1,117 @@ +import { IsString, IsOptional, IsNumber, IsObject, IsArray, IsDateString, IsEnum } from 'class-validator'; +import { BookingPriority } from '../entities/service-booking.entity'; + +export class CreateBookingDto { + @IsString() + organizerId: string; + + @IsString() + serviceId: string; + + @IsOptional() + @IsString() + eventId?: string; + + @IsOptional() + @IsString() + pricingId?: string; + + @IsOptional() + @IsEnum(BookingPriority) + priority?: BookingPriority; + + @IsDateString() + eventDate: Date; + + @IsOptional() + @IsString() + startTime?: string; + + @IsOptional() + @IsString() + endTime?: string; + + @IsOptional() + @IsNumber() + duration?: number; + + @IsOptional() + @IsNumber() + guestCount?: number; + + @IsOptional() + @IsObject() + eventLocation?: { + venueName?: string; + address: string; + city: string; + state: string; + zipCode: string; + country: string; + latitude?: number; + longitude?: number; + specialInstructions?: string; + }; + + @IsOptional() + @IsArray() + selectedAddOns?: { + name: string; + price: number; + quantity?: number; + }[]; + + @IsOptional() + @IsArray() + appliedDiscounts?: { + type: string; + value: number; + amount: number; + description?: string; + }[]; + + @IsOptional() + @IsString() + specialRequests?: string; + + @IsOptional() + @IsString() + organizerNotes?: string; + + @IsOptional() + @IsObject() + contactInfo?: { + primaryContact: { + name: string; + phone: string; + email: string; + }; + emergencyContact?: { + name: string; + phone: string; + relationship: string; + }; + }; + + @IsOptional() + @IsObject() + timeline?: { + setupStart?: Date; + serviceStart: Date; + serviceEnd: Date; + cleanupEnd?: Date; + }; + + @IsOptional() + @IsObject() + requirements?: { + equipment?: string[]; + space?: string[]; + power?: string[]; + other?: string[]; + }; + + @IsOptional() + @IsNumber() + travelFee?: number; +} diff --git a/src/marketplace/dto/create-service.dto.ts b/src/marketplace/dto/create-service.dto.ts new file mode 100644 index 00000000..e8364ffa --- /dev/null +++ b/src/marketplace/dto/create-service.dto.ts @@ -0,0 +1,148 @@ +import { IsString, IsEnum, IsOptional, IsArray, IsObject, IsNumber, IsBoolean, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ServiceType } from '../entities/service-listing.entity'; +import { PricingType, PricingTier } from '../entities/service-pricing.entity'; + +class ServicePricingDto { + @IsString() + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsEnum(PricingType) + pricingType: PricingType; + + @IsOptional() + @IsEnum(PricingTier) + tier?: PricingTier; + + @IsNumber() + basePrice: number; + + @IsOptional() + @IsNumber() + minimumPrice?: number; + + @IsOptional() + @IsNumber() + maximumPrice?: number; + + @IsOptional() + @IsString() + currency?: string; + + @IsOptional() + @IsBoolean() + isDefault?: boolean; + + @IsOptional() + @IsArray() + inclusions?: string[]; + + @IsOptional() + @IsArray() + exclusions?: string[]; + + @IsOptional() + @IsArray() + addOns?: { + name: string; + description?: string; + price: number; + isRequired?: boolean; + }[]; +} + +export class CreateServiceDto { + @IsString() + vendorId: string; + + @IsString() + categoryId: string; + + @IsString() + title: string; + + @IsString() + description: string; + + @IsEnum(ServiceType) + serviceType: ServiceType; + + @IsArray() + @IsString({ each: true }) + images: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + videos?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + documents?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsObject() + serviceDetails?: { + duration?: string; + capacity?: { + min: number; + max: number; + }; + setupTime?: string; + cleanupTime?: string; + travelRadius?: number; + equipmentIncluded?: string[]; + additionalServices?: string[]; + requirements?: string[]; + restrictions?: string[]; + }; + + @IsOptional() + @IsObject() + availability?: { + daysOfWeek: string[]; + timeSlots: { + start: string; + end: string; + }[]; + blackoutDates: Date[]; + advanceBookingDays: number; + minimumNotice: number; + }; + + @IsOptional() + @IsObject() + location?: { + serviceAreas: string[]; + travelFee?: number; + maxTravelDistance?: number; + baseLocation?: { + address: string; + city: string; + state: string; + zipCode: string; + latitude?: number; + longitude?: number; + }; + }; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ServicePricingDto) + pricing?: ServicePricingDto[]; + + @IsOptional() + @IsObject() + customFields?: Record; +} diff --git a/src/marketplace/dto/create-vendor.dto.ts b/src/marketplace/dto/create-vendor.dto.ts new file mode 100644 index 00000000..57e0228e --- /dev/null +++ b/src/marketplace/dto/create-vendor.dto.ts @@ -0,0 +1,124 @@ +import { IsString, IsEmail, IsOptional, IsObject, IsArray, IsNumber, IsBoolean, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +class VendorProfileDto { + @IsString() + description: string; + + @IsOptional() + @IsString() + website?: string; + + @IsOptional() + @IsString() + phone?: string; + + @IsOptional() + @IsEmail() + email?: string; + + @IsOptional() + @IsObject() + address?: { + street: string; + city: string; + state: string; + zipCode: string; + country: string; + }; + + @IsOptional() + @IsObject() + socialMedia?: { + facebook?: string; + instagram?: string; + twitter?: string; + linkedin?: string; + youtube?: string; + }; + + @IsOptional() + @IsArray() + specialties?: string[]; + + @IsOptional() + @IsArray() + languages?: string[]; + + @IsOptional() + @IsNumber() + yearsOfExperience?: number; + + @IsOptional() + @IsNumber() + teamSize?: number; + + @IsOptional() + @IsNumber() + minimumBookingAmount?: number; + + @IsOptional() + @IsNumber() + advanceBookingDays?: number; + + @IsOptional() + @IsString() + cancellationPolicy?: string; + + @IsOptional() + @IsString() + refundPolicy?: string; +} + +export class CreateVendorDto { + @IsString() + userId: string; + + @IsString() + businessName: string; + + @IsString() + businessRegistrationNumber: string; + + @IsString() + taxId: string; + + @IsOptional() + @IsNumber() + commissionRate?: number; + + @IsOptional() + @IsArray() + serviceAreas?: string[]; + + @IsOptional() + @IsObject() + businessHours?: { + [key: string]: { + open: string; + close: string; + isOpen: boolean; + }; + }; + + @IsOptional() + @IsObject() + paymentMethods?: { + bankAccount?: { + accountNumber: string; + routingNumber: string; + accountHolderName: string; + }; + paypal?: { + email: string; + }; + stripe?: { + accountId: string; + }; + }; + + @IsOptional() + @ValidateNested() + @Type(() => VendorProfileDto) + profile?: VendorProfileDto; +} diff --git a/src/marketplace/dto/service-search.dto.ts b/src/marketplace/dto/service-search.dto.ts new file mode 100644 index 00000000..cf0fc3c4 --- /dev/null +++ b/src/marketplace/dto/service-search.dto.ts @@ -0,0 +1,64 @@ +import { IsOptional, IsString, IsNumber, IsEnum, IsObject, IsArray, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ServiceType } from '../entities/service-listing.entity'; + +export class ServiceSearchDto { + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + @Max(100) + limit?: number = 20; + + @IsOptional() + @IsString() + categoryId?: string; + + @IsOptional() + @IsEnum(ServiceType) + serviceType?: ServiceType; + + @IsOptional() + @IsString() + location?: string; + + @IsOptional() + @IsObject() + priceRange?: { + min: number; + max: number; + }; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + @Max(5) + rating?: number; + + @IsOptional() + @IsObject() + availability?: { + date: Date; + startTime?: string; + endTime?: string; + }; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsString() + sortBy?: string = 'createdAt'; + + @IsOptional() + @IsEnum(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC' = 'DESC'; +} diff --git a/src/marketplace/dto/update-booking.dto.ts b/src/marketplace/dto/update-booking.dto.ts new file mode 100644 index 00000000..70e3a12e --- /dev/null +++ b/src/marketplace/dto/update-booking.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateBookingDto } from './create-booking.dto'; + +export class UpdateBookingDto extends PartialType(CreateBookingDto) {} diff --git a/src/marketplace/dto/update-service.dto.ts b/src/marketplace/dto/update-service.dto.ts new file mode 100644 index 00000000..b81ed745 --- /dev/null +++ b/src/marketplace/dto/update-service.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateServiceDto } from './create-service.dto'; + +export class UpdateServiceDto extends PartialType(CreateServiceDto) {} diff --git a/src/marketplace/dto/update-vendor.dto.ts b/src/marketplace/dto/update-vendor.dto.ts new file mode 100644 index 00000000..8d89673a --- /dev/null +++ b/src/marketplace/dto/update-vendor.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateVendorDto } from './create-vendor.dto'; + +export class UpdateVendorDto extends PartialType(CreateVendorDto) {} diff --git a/src/marketplace/dto/vendor-query.dto.ts b/src/marketplace/dto/vendor-query.dto.ts new file mode 100644 index 00000000..726d1294 --- /dev/null +++ b/src/marketplace/dto/vendor-query.dto.ts @@ -0,0 +1,53 @@ +import { IsOptional, IsString, IsBoolean, IsArray, IsEnum, IsNumber, Min, Max } from 'class-validator'; +import { Type, Transform } from 'class-transformer'; +import { VendorStatus, VendorTier } from '../entities/vendor.entity'; + +export class VendorQueryDto { + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + @Max(100) + limit?: number = 20; + + @IsOptional() + @IsEnum(VendorStatus) + status?: VendorStatus; + + @IsOptional() + @IsEnum(VendorTier) + tier?: VendorTier; + + @IsOptional() + @Transform(({ value }) => value === 'true') + @IsBoolean() + isVerified?: boolean; + + @IsOptional() + @Transform(({ value }) => value === 'true') + @IsBoolean() + isFeatured?: boolean; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + serviceAreas?: string[]; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsString() + sortBy?: string = 'createdAt'; + + @IsOptional() + @IsEnum(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC' = 'DESC'; +} diff --git a/src/marketplace/entities/booking-payment.entity.ts b/src/marketplace/entities/booking-payment.entity.ts new file mode 100644 index 00000000..32193e57 --- /dev/null +++ b/src/marketplace/entities/booking-payment.entity.ts @@ -0,0 +1,148 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { ServiceBooking } from './service-booking.entity'; + +export enum PaymentStatus { + PENDING = 'pending', + PROCESSING = 'processing', + COMPLETED = 'completed', + FAILED = 'failed', + CANCELLED = 'cancelled', + REFUNDED = 'refunded', + PARTIALLY_REFUNDED = 'partially_refunded', +} + +export enum PaymentType { + DEPOSIT = 'deposit', + PARTIAL = 'partial', + FINAL = 'final', + FULL = 'full', + REFUND = 'refund', +} + +export enum PaymentMethod { + CREDIT_CARD = 'credit_card', + DEBIT_CARD = 'debit_card', + BANK_TRANSFER = 'bank_transfer', + PAYPAL = 'paypal', + STRIPE = 'stripe', + APPLE_PAY = 'apple_pay', + GOOGLE_PAY = 'google_pay', +} + +@Entity('booking_payments') +@Index(['bookingId', 'status']) +@Index(['status', 'createdAt']) +export class BookingPayment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + bookingId: string; + + @Column({ type: 'varchar', length: 50, unique: true }) + @Index() + transactionId: string; + + @Column({ + type: 'enum', + enum: PaymentStatus, + default: PaymentStatus.PENDING, + }) + status: PaymentStatus; + + @Column({ + type: 'enum', + enum: PaymentType, + }) + paymentType: PaymentType; + + @Column({ + type: 'enum', + enum: PaymentMethod, + }) + paymentMethod: PaymentMethod; + + @Column({ type: 'decimal', precision: 10, scale: 2 }) + amount: number; + + @Column({ type: 'varchar', length: 10, default: 'USD' }) + currency: string; + + @Column({ type: 'decimal', precision: 5, scale: 4, default: 0 }) + processingFeeRate: number; + + @Column({ type: 'decimal', precision: 8, scale: 2, default: 0 }) + processingFee: number; + + @Column({ type: 'decimal', precision: 10, scale: 2 }) + netAmount: number; + + @Column({ type: 'varchar', length: 100, nullable: true }) + externalTransactionId: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + paymentIntentId: string; + + @Column({ type: 'json', nullable: true }) + paymentDetails: { + cardLast4?: string; + cardBrand?: string; + cardExpMonth?: number; + cardExpYear?: number; + bankName?: string; + accountLast4?: string; + paypalEmail?: string; + }; + + @Column({ type: 'json', nullable: true }) + billingAddress: { + name: string; + address: string; + city: string; + state: string; + zipCode: string; + country: string; + }; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ type: 'text', nullable: true }) + failureReason: string; + + @Column({ type: 'timestamp', nullable: true }) + processedAt: Date; + + @Column({ type: 'timestamp', nullable: true }) + refundedAt: Date; + + @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 }) + refundAmount: number; + + @Column({ type: 'text', nullable: true }) + refundReason: string; + + @Column({ type: 'json', nullable: true }) + metadata: Record; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @ManyToOne(() => ServiceBooking, (booking) => booking.payments) + @JoinColumn({ name: 'bookingId' }) + booking: ServiceBooking; +} diff --git a/src/marketplace/entities/commission.entity.ts b/src/marketplace/entities/commission.entity.ts new file mode 100644 index 00000000..f64d000d --- /dev/null +++ b/src/marketplace/entities/commission.entity.ts @@ -0,0 +1,145 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Vendor } from './vendor.entity'; +import { ServiceBooking } from './service-booking.entity'; +import { PaymentDistribution } from './payment-distribution.entity'; + +export enum CommissionStatus { + PENDING = 'pending', + CALCULATED = 'calculated', + APPROVED = 'approved', + PAID = 'paid', + DISPUTED = 'disputed', + CANCELLED = 'cancelled', +} + +export enum CommissionType { + BOOKING = 'booking', + SUBSCRIPTION = 'subscription', + FEATURED_LISTING = 'featured_listing', + ADVERTISING = 'advertising', + PREMIUM_PLACEMENT = 'premium_placement', +} + +@Entity('commissions') +@Index(['vendorId', 'status']) +@Index(['status', 'dueDate']) +@Index(['commissionType', 'createdAt']) +export class Commission { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 20, unique: true }) + @Index() + commissionNumber: string; + + @Column({ type: 'uuid' }) + @Index() + vendorId: string; + + @Column({ type: 'uuid', nullable: true }) + @Index() + bookingId: string; + + @Column({ + type: 'enum', + enum: CommissionType, + }) + commissionType: CommissionType; + + @Column({ + type: 'enum', + enum: CommissionStatus, + default: CommissionStatus.PENDING, + }) + status: CommissionStatus; + + @Column({ type: 'decimal', precision: 10, scale: 2 }) + bookingAmount: number; + + @Column({ type: 'decimal', precision: 5, scale: 2 }) + commissionRate: number; + + @Column({ type: 'decimal', precision: 10, scale: 2 }) + commissionAmount: number; + + @Column({ type: 'decimal', precision: 10, scale: 2 }) + vendorPayout: number; + + @Column({ type: 'decimal', precision: 8, scale: 2, default: 0 }) + processingFee: number; + + @Column({ type: 'decimal', precision: 8, scale: 2, default: 0 }) + platformFee: number; + + @Column({ type: 'varchar', length: 10, default: 'USD' }) + currency: string; + + @Column({ type: 'date' }) + @Index() + dueDate: Date; + + @Column({ type: 'timestamp', nullable: true }) + calculatedAt: Date; + + @Column({ type: 'timestamp', nullable: true }) + approvedAt: Date; + + @Column({ type: 'timestamp', nullable: true }) + paidAt: Date; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @Column({ type: 'json', nullable: true }) + breakdown: { + baseCommission: number; + bonusCommission?: number; + penalties?: number; + adjustments?: { + type: string; + amount: number; + reason: string; + }[]; + }; + + @Column({ type: 'json', nullable: true }) + paymentDetails: { + method: string; + accountInfo: Record; + transactionId?: string; + reference?: string; + }; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @ManyToOne(() => Vendor, (vendor) => vendor.commissions) + @JoinColumn({ name: 'vendorId' }) + vendor: Vendor; + + @OneToOne(() => ServiceBooking, (booking) => booking.commission, { + nullable: true, + }) + @JoinColumn({ name: 'bookingId' }) + booking: ServiceBooking; + + @OneToOne(() => PaymentDistribution, (distribution) => distribution.commission) + paymentDistribution: PaymentDistribution; +} diff --git a/src/marketplace/entities/payment-distribution.entity.ts b/src/marketplace/entities/payment-distribution.entity.ts new file mode 100644 index 00000000..b812b54b --- /dev/null +++ b/src/marketplace/entities/payment-distribution.entity.ts @@ -0,0 +1,83 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Commission } from './commission.entity'; + +export enum DistributionStatus { + PENDING = 'pending', + PROCESSING = 'processing', + COMPLETED = 'completed', + FAILED = 'failed', + CANCELLED = 'cancelled', +} + +@Entity('payment_distributions') +@Index(['status', 'scheduledDate']) +export class PaymentDistribution { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + commissionId: string; + + @Column({ type: 'varchar', length: 50, unique: true }) + @Index() + distributionId: string; + + @Column({ + type: 'enum', + enum: DistributionStatus, + default: DistributionStatus.PENDING, + }) + status: DistributionStatus; + + @Column({ type: 'decimal', precision: 10, scale: 2 }) + amount: number; + + @Column({ type: 'varchar', length: 10, default: 'USD' }) + currency: string; + + @Column({ type: 'date' }) + @Index() + scheduledDate: Date; + + @Column({ type: 'timestamp', nullable: true }) + processedAt: Date; + + @Column({ type: 'varchar', length: 100, nullable: true }) + externalTransactionId: string; + + @Column({ type: 'json' }) + paymentMethod: { + type: 'bank_transfer' | 'paypal' | 'stripe'; + details: Record; + }; + + @Column({ type: 'text', nullable: true }) + failureReason: string; + + @Column({ type: 'int', default: 0 }) + retryCount: number; + + @Column({ type: 'timestamp', nullable: true }) + nextRetryAt: Date; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @OneToOne(() => Commission, (commission) => commission.paymentDistribution) + @JoinColumn({ name: 'commissionId' }) + commission: Commission; +} diff --git a/src/marketplace/entities/service-booking.entity.ts b/src/marketplace/entities/service-booking.entity.ts new file mode 100644 index 00000000..42b3d8cd --- /dev/null +++ b/src/marketplace/entities/service-booking.entity.ts @@ -0,0 +1,232 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + OneToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { Event } from '../../events/entities/event.entity'; +import { Vendor } from './vendor.entity'; +import { ServiceListing } from './service-listing.entity'; +import { ServicePricing } from './service-pricing.entity'; +import { BookingPayment } from './booking-payment.entity'; +import { Commission } from './commission.entity'; + +export enum BookingStatus { + PENDING = 'pending', + CONFIRMED = 'confirmed', + IN_PROGRESS = 'in_progress', + COMPLETED = 'completed', + CANCELLED = 'cancelled', + REFUNDED = 'refunded', + DISPUTED = 'disputed', +} + +export enum BookingPriority { + LOW = 'low', + MEDIUM = 'medium', + HIGH = 'high', + URGENT = 'urgent', +} + +@Entity('service_bookings') +@Index(['status', 'eventDate']) +@Index(['vendorId', 'status']) +@Index(['organizerId', 'status']) +@Index(['eventId']) +export class ServiceBooking { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 20, unique: true }) + @Index() + bookingNumber: string; + + @Column({ type: 'uuid' }) + @Index() + organizerId: string; + + @Column({ type: 'uuid' }) + @Index() + vendorId: string; + + @Column({ type: 'uuid' }) + @Index() + serviceId: string; + + @Column({ type: 'uuid', nullable: true }) + @Index() + eventId: string; + + @Column({ type: 'uuid', nullable: true }) + pricingId: string; + + @Column({ + type: 'enum', + enum: BookingStatus, + default: BookingStatus.PENDING, + }) + status: BookingStatus; + + @Column({ + type: 'enum', + enum: BookingPriority, + default: BookingPriority.MEDIUM, + }) + priority: BookingPriority; + + @Column({ type: 'timestamp' }) + @Index() + eventDate: Date; + + @Column({ type: 'time', nullable: true }) + startTime: string; + + @Column({ type: 'time', nullable: true }) + endTime: string; + + @Column({ type: 'int', nullable: true }) + duration: number; // in minutes + + @Column({ type: 'int', nullable: true }) + guestCount: number; + + @Column({ type: 'json', nullable: true }) + eventLocation: { + venueName?: string; + address: string; + city: string; + state: string; + zipCode: string; + country: string; + latitude?: number; + longitude?: number; + specialInstructions?: string; + }; + + @Column({ type: 'decimal', precision: 10, scale: 2 }) + subtotal: number; + + @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 }) + serviceFee: number; + + @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 }) + travelFee: number; + + @Column({ type: 'decimal', precision: 10, scale: 2 }) + totalAmount: number; + + @Column({ type: 'varchar', length: 10, default: 'USD' }) + currency: string; + + @Column({ type: 'json', nullable: true }) + selectedAddOns: { + name: string; + price: number; + quantity?: number; + }[]; + + @Column({ type: 'json', nullable: true }) + appliedDiscounts: { + type: string; + value: number; + amount: number; + description?: string; + }[]; + + @Column({ type: 'text', nullable: true }) + specialRequests: string; + + @Column({ type: 'text', nullable: true }) + organizerNotes: string; + + @Column({ type: 'text', nullable: true }) + vendorNotes: string; + + @Column({ type: 'json', nullable: true }) + contactInfo: { + primaryContact: { + name: string; + phone: string; + email: string; + }; + emergencyContact?: { + name: string; + phone: string; + relationship: string; + }; + }; + + @Column({ type: 'json', nullable: true }) + timeline: { + setupStart?: Date; + serviceStart: Date; + serviceEnd: Date; + cleanupEnd?: Date; + }; + + @Column({ type: 'json', nullable: true }) + requirements: { + equipment?: string[]; + space?: string[]; + power?: string[]; + other?: string[]; + }; + + @Column({ type: 'timestamp', nullable: true }) + confirmedAt: Date; + + @Column({ type: 'timestamp', nullable: true }) + completedAt: Date; + + @Column({ type: 'timestamp', nullable: true }) + cancelledAt: Date; + + @Column({ type: 'text', nullable: true }) + cancellationReason: string; + + @Column({ type: 'decimal', precision: 5, scale: 2, nullable: true }) + cancellationFee: number; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @ManyToOne(() => User) + @JoinColumn({ name: 'organizerId' }) + organizer: User; + + @ManyToOne(() => Vendor, (vendor) => vendor.bookings) + @JoinColumn({ name: 'vendorId' }) + vendor: Vendor; + + @ManyToOne(() => ServiceListing, (service) => service.bookings) + @JoinColumn({ name: 'serviceId' }) + service: ServiceListing; + + @ManyToOne(() => Event, { nullable: true }) + @JoinColumn({ name: 'eventId' }) + event: Event; + + @ManyToOne(() => ServicePricing, { nullable: true }) + @JoinColumn({ name: 'pricingId' }) + pricing: ServicePricing; + + @OneToMany(() => BookingPayment, (payment) => payment.booking) + payments: BookingPayment[]; + + @OneToOne(() => Commission, (commission) => commission.booking) + commission: Commission; +} diff --git a/src/marketplace/entities/service-category.entity.ts b/src/marketplace/entities/service-category.entity.ts new file mode 100644 index 00000000..d44615d2 --- /dev/null +++ b/src/marketplace/entities/service-category.entity.ts @@ -0,0 +1,77 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { ServiceListing } from './service-listing.entity'; + +@Entity('service_categories') +@Index(['isActive']) +export class ServiceCategory { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 100, unique: true }) + @Index() + name: string; + + @Column({ type: 'varchar', length: 200, unique: true }) + @Index() + slug: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + icon: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + image: string; + + @Column({ type: 'uuid', nullable: true }) + @Index() + parentId: string; + + @Column({ type: 'int', default: 0 }) + sortOrder: number; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; + + @Column({ type: 'boolean', default: false }) + isFeatured: boolean; + + @Column({ type: 'int', default: 0 }) + serviceCount: number; + + @Column({ type: 'json', nullable: true }) + metadata: { + keywords?: string[]; + seoTitle?: string; + seoDescription?: string; + customFields?: Record; + }; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @ManyToOne(() => ServiceCategory, (category) => category.children) + @JoinColumn({ name: 'parentId' }) + parent: ServiceCategory; + + @OneToMany(() => ServiceCategory, (category) => category.parent) + children: ServiceCategory[]; + + @OneToMany(() => ServiceListing, (service) => service.category) + services: ServiceListing[]; +} diff --git a/src/marketplace/entities/service-listing.entity.ts b/src/marketplace/entities/service-listing.entity.ts new file mode 100644 index 00000000..14fba34b --- /dev/null +++ b/src/marketplace/entities/service-listing.entity.ts @@ -0,0 +1,186 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Vendor } from './vendor.entity'; +import { ServiceCategory } from './service-category.entity'; +import { ServicePricing } from './service-pricing.entity'; +import { ServiceBooking } from './service-booking.entity'; +import { VendorReview } from './vendor-review.entity'; + +export enum ServiceStatus { + DRAFT = 'draft', + ACTIVE = 'active', + PAUSED = 'paused', + ARCHIVED = 'archived', +} + +export enum ServiceType { + CATERING = 'catering', + PHOTOGRAPHY = 'photography', + VIDEOGRAPHY = 'videography', + MUSIC_DJ = 'music_dj', + LIVE_BAND = 'live_band', + DECORATION = 'decoration', + LIGHTING = 'lighting', + SOUND_SYSTEM = 'sound_system', + VENUE_SETUP = 'venue_setup', + SECURITY = 'security', + TRANSPORTATION = 'transportation', + ENTERTAINMENT = 'entertainment', + FLOWERS = 'flowers', + EQUIPMENT_RENTAL = 'equipment_rental', + PLANNING = 'planning', + OTHER = 'other', +} + +@Entity('service_listings') +@Index(['status', 'isActive']) +@Index(['vendorId', 'status']) +@Index(['categoryId']) +export class ServiceListing { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + vendorId: string; + + @Column({ type: 'uuid' }) + @Index() + categoryId: string; + + @Column({ type: 'varchar', length: 200 }) + @Index() + title: string; + + @Column({ type: 'varchar', length: 300, unique: true }) + @Index() + slug: string; + + @Column({ type: 'text' }) + description: string; + + @Column({ + type: 'enum', + enum: ServiceType, + }) + serviceType: ServiceType; + + @Column({ + type: 'enum', + enum: ServiceStatus, + default: ServiceStatus.DRAFT, + }) + status: ServiceStatus; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; + + @Column({ type: 'boolean', default: false }) + isFeatured: boolean; + + @Column({ type: 'json' }) + images: string[]; + + @Column({ type: 'json', nullable: true }) + videos: string[]; + + @Column({ type: 'json', nullable: true }) + documents: string[]; + + @Column({ type: 'simple-array', nullable: true }) + tags: string[]; + + @Column({ type: 'decimal', precision: 3, scale: 2, default: 0 }) + averageRating: number; + + @Column({ type: 'int', default: 0 }) + totalReviews: number; + + @Column({ type: 'int', default: 0 }) + totalBookings: number; + + @Column({ type: 'int', default: 0 }) + viewCount: number; + + @Column({ type: 'int', default: 0 }) + favoriteCount: number; + + @Column({ type: 'json', nullable: true }) + serviceDetails: { + duration?: string; + capacity?: { + min: number; + max: number; + }; + setupTime?: string; + cleanupTime?: string; + travelRadius?: number; + equipmentIncluded?: string[]; + additionalServices?: string[]; + requirements?: string[]; + restrictions?: string[]; + }; + + @Column({ type: 'json', nullable: true }) + availability: { + daysOfWeek: string[]; + timeSlots: { + start: string; + end: string; + }[]; + blackoutDates: Date[]; + advanceBookingDays: number; + minimumNotice: number; + }; + + @Column({ type: 'json', nullable: true }) + location: { + serviceAreas: string[]; + travelFee?: number; + maxTravelDistance?: number; + baseLocation?: { + address: string; + city: string; + state: string; + zipCode: string; + latitude?: number; + longitude?: number; + }; + }; + + @Column({ type: 'json', nullable: true }) + customFields: Record; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @ManyToOne(() => Vendor, (vendor) => vendor.services) + @JoinColumn({ name: 'vendorId' }) + vendor: Vendor; + + @ManyToOne(() => ServiceCategory, (category) => category.services) + @JoinColumn({ name: 'categoryId' }) + category: ServiceCategory; + + @OneToMany(() => ServicePricing, (pricing) => pricing.service) + pricing: ServicePricing[]; + + @OneToMany(() => ServiceBooking, (booking) => booking.service) + bookings: ServiceBooking[]; + + @OneToMany(() => VendorReview, (review) => review.service) + reviews: VendorReview[]; +} diff --git a/src/marketplace/entities/service-pricing.entity.ts b/src/marketplace/entities/service-pricing.entity.ts new file mode 100644 index 00000000..065f094e --- /dev/null +++ b/src/marketplace/entities/service-pricing.entity.ts @@ -0,0 +1,140 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { ServiceListing } from './service-listing.entity'; + +export enum PricingType { + FIXED = 'fixed', + HOURLY = 'hourly', + DAILY = 'daily', + PACKAGE = 'package', + CUSTOM = 'custom', +} + +export enum PricingTier { + BASIC = 'basic', + STANDARD = 'standard', + PREMIUM = 'premium', + ENTERPRISE = 'enterprise', +} + +@Entity('service_pricing') +@Index(['serviceId', 'isActive']) +export class ServicePricing { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + serviceId: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ + type: 'enum', + enum: PricingType, + }) + pricingType: PricingType; + + @Column({ + type: 'enum', + enum: PricingTier, + default: PricingTier.BASIC, + }) + tier: PricingTier; + + @Column({ type: 'decimal', precision: 10, scale: 2 }) + basePrice: number; + + @Column({ type: 'decimal', precision: 10, scale: 2, nullable: true }) + minimumPrice: number; + + @Column({ type: 'decimal', precision: 10, scale: 2, nullable: true }) + maximumPrice: number; + + @Column({ type: 'varchar', length: 10, default: 'USD' }) + currency: string; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; + + @Column({ type: 'boolean', default: false }) + isDefault: boolean; + + @Column({ type: 'json', nullable: true }) + inclusions: string[]; + + @Column({ type: 'json', nullable: true }) + exclusions: string[]; + + @Column({ type: 'json', nullable: true }) + addOns: { + name: string; + description?: string; + price: number; + isRequired?: boolean; + }[]; + + @Column({ type: 'json', nullable: true }) + discounts: { + type: 'percentage' | 'fixed'; + value: number; + minQuantity?: number; + validFrom?: Date; + validTo?: Date; + description?: string; + }[]; + + @Column({ type: 'json', nullable: true }) + duration: { + hours?: number; + days?: number; + weeks?: number; + isFlexible?: boolean; + }; + + @Column({ type: 'json', nullable: true }) + capacity: { + minGuests?: number; + maxGuests?: number; + pricePerGuest?: number; + }; + + @Column({ type: 'json', nullable: true }) + paymentTerms: { + depositPercentage?: number; + depositAmount?: number; + paymentSchedule?: { + percentage: number; + dueDate: string; + description?: string; + }[]; + cancellationFee?: number; + lateFeePercentage?: number; + }; + + @Column({ type: 'json', nullable: true }) + customFields: Record; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @ManyToOne(() => ServiceListing, (service) => service.pricing) + @JoinColumn({ name: 'serviceId' }) + service: ServiceListing; +} diff --git a/src/marketplace/entities/vendor-profile.entity.ts b/src/marketplace/entities/vendor-profile.entity.ts new file mode 100644 index 00000000..588d3938 --- /dev/null +++ b/src/marketplace/entities/vendor-profile.entity.ts @@ -0,0 +1,119 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Vendor } from './vendor.entity'; + +@Entity('vendor_profiles') +export class VendorProfile { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + vendorId: string; + + @Column({ type: 'text' }) + description: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + website: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + phone: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + email: string; + + @Column({ type: 'json', nullable: true }) + address: { + street: string; + city: string; + state: string; + zipCode: string; + country: string; + }; + + @Column({ type: 'json', nullable: true }) + socialMedia: { + facebook?: string; + instagram?: string; + twitter?: string; + linkedin?: string; + youtube?: string; + }; + + @Column({ type: 'json', nullable: true }) + portfolio: { + images: string[]; + videos: string[]; + documents: string[]; + }; + + @Column({ type: 'json', nullable: true }) + certifications: { + name: string; + issuer: string; + issueDate: Date; + expiryDate?: Date; + certificateUrl?: string; + }[]; + + @Column({ type: 'json', nullable: true }) + insurance: { + provider: string; + policyNumber: string; + coverage: string; + expiryDate: Date; + documentUrl?: string; + }; + + @Column({ type: 'simple-array', nullable: true }) + specialties: string[]; + + @Column({ type: 'simple-array', nullable: true }) + languages: string[]; + + @Column({ type: 'int', nullable: true }) + yearsOfExperience: number; + + @Column({ type: 'int', nullable: true }) + teamSize: number; + + @Column({ type: 'decimal', precision: 10, scale: 2, nullable: true }) + minimumBookingAmount: number; + + @Column({ type: 'int', nullable: true }) + advanceBookingDays: number; + + @Column({ type: 'text', nullable: true }) + cancellationPolicy: string; + + @Column({ type: 'text', nullable: true }) + refundPolicy: string; + + @Column({ type: 'json', nullable: true }) + emergencyContact: { + name: string; + phone: string; + email: string; + relationship: string; + }; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @OneToOne(() => Vendor, (vendor) => vendor.profile) + @JoinColumn({ name: 'vendorId' }) + vendor: Vendor; +} diff --git a/src/marketplace/entities/vendor-review.entity.ts b/src/marketplace/entities/vendor-review.entity.ts new file mode 100644 index 00000000..65ff26a1 --- /dev/null +++ b/src/marketplace/entities/vendor-review.entity.ts @@ -0,0 +1,124 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { Vendor } from './vendor.entity'; +import { ServiceListing } from './service-listing.entity'; +import { ServiceBooking } from './service-booking.entity'; + +export enum ReviewStatus { + PENDING = 'pending', + APPROVED = 'approved', + REJECTED = 'rejected', + FLAGGED = 'flagged', +} + +@Entity('vendor_reviews') +@Index(['vendorId', 'status']) +@Index(['serviceId', 'status']) +@Index(['rating', 'createdAt']) +export class VendorReview { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + reviewerId: string; + + @Column({ type: 'uuid' }) + @Index() + vendorId: string; + + @Column({ type: 'uuid', nullable: true }) + @Index() + serviceId: string; + + @Column({ type: 'uuid', nullable: true }) + @Index() + bookingId: string; + + @Column({ type: 'int', width: 1 }) + @Index() + rating: number; // 1-5 stars + + @Column({ type: 'varchar', length: 200, nullable: true }) + title: string; + + @Column({ type: 'text' }) + comment: string; + + @Column({ + type: 'enum', + enum: ReviewStatus, + default: ReviewStatus.PENDING, + }) + status: ReviewStatus; + + @Column({ type: 'json', nullable: true }) + ratings: { + communication?: number; + quality?: number; + timeliness?: number; + professionalism?: number; + value?: number; + }; + + @Column({ type: 'json', nullable: true }) + images: string[]; + + @Column({ type: 'boolean', default: false }) + isVerifiedPurchase: boolean; + + @Column({ type: 'boolean', default: false }) + isRecommended: boolean; + + @Column({ type: 'int', default: 0 }) + helpfulCount: number; + + @Column({ type: 'int', default: 0 }) + reportCount: number; + + @Column({ type: 'text', nullable: true }) + vendorResponse: string; + + @Column({ type: 'timestamp', nullable: true }) + vendorResponseAt: Date; + + @Column({ type: 'text', nullable: true }) + moderatorNotes: string; + + @Column({ type: 'timestamp', nullable: true }) + moderatedAt: Date; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @ManyToOne(() => User) + @JoinColumn({ name: 'reviewerId' }) + reviewer: User; + + @ManyToOne(() => Vendor, (vendor) => vendor.reviews) + @JoinColumn({ name: 'vendorId' }) + vendor: Vendor; + + @ManyToOne(() => ServiceListing, (service) => service.reviews, { + nullable: true, + }) + @JoinColumn({ name: 'serviceId' }) + service: ServiceListing; + + @ManyToOne(() => ServiceBooking, { nullable: true }) + @JoinColumn({ name: 'bookingId' }) + booking: ServiceBooking; +} diff --git a/src/marketplace/entities/vendor.entity.ts b/src/marketplace/entities/vendor.entity.ts new file mode 100644 index 00000000..85576526 --- /dev/null +++ b/src/marketplace/entities/vendor.entity.ts @@ -0,0 +1,153 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { VendorProfile } from './vendor-profile.entity'; +import { ServiceListing } from './service-listing.entity'; +import { ServiceBooking } from './service-booking.entity'; +import { VendorReview } from './vendor-review.entity'; +import { Commission } from './commission.entity'; + +export enum VendorStatus { + PENDING = 'pending', + ACTIVE = 'active', + SUSPENDED = 'suspended', + REJECTED = 'rejected', +} + +export enum VendorTier { + BASIC = 'basic', + PREMIUM = 'premium', + ENTERPRISE = 'enterprise', +} + +@Entity('vendors') +@Index(['status', 'tier']) +@Index(['createdAt']) +export class Vendor { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + userId: string; + + @Column({ + type: 'enum', + enum: VendorStatus, + default: VendorStatus.PENDING, + }) + status: VendorStatus; + + @Column({ + type: 'enum', + enum: VendorTier, + default: VendorTier.BASIC, + }) + tier: VendorTier; + + @Column({ type: 'varchar', length: 255, unique: true }) + @Index() + businessName: string; + + @Column({ type: 'varchar', length: 20, unique: true }) + @Index() + businessRegistrationNumber: string; + + @Column({ type: 'varchar', length: 50, unique: true }) + @Index() + taxId: string; + + @Column({ type: 'decimal', precision: 5, scale: 2, default: 10.00 }) + commissionRate: number; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; + + @Column({ type: 'boolean', default: false }) + isVerified: boolean; + + @Column({ type: 'boolean', default: false }) + isFeatured: boolean; + + @Column({ type: 'decimal', precision: 3, scale: 2, default: 0 }) + averageRating: number; + + @Column({ type: 'int', default: 0 }) + totalReviews: number; + + @Column({ type: 'int', default: 0 }) + totalBookings: number; + + @Column({ type: 'decimal', precision: 12, scale: 2, default: 0 }) + totalRevenue: number; + + @Column({ type: 'json', nullable: true }) + paymentMethods: { + bankAccount?: { + accountNumber: string; + routingNumber: string; + accountHolderName: string; + }; + paypal?: { + email: string; + }; + stripe?: { + accountId: string; + }; + }; + + @Column({ type: 'json', nullable: true }) + businessHours: { + [key: string]: { + open: string; + close: string; + isOpen: boolean; + }; + }; + + @Column({ type: 'json', nullable: true }) + serviceAreas: string[]; + + @Column({ type: 'timestamp', nullable: true }) + verifiedAt: Date; + + @Column({ type: 'timestamp', nullable: true }) + lastActiveAt: Date; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @OneToOne(() => User) + @JoinColumn({ name: 'userId' }) + user: User; + + @OneToOne(() => VendorProfile, (profile) => profile.vendor, { + cascade: true, + }) + profile: VendorProfile; + + @OneToMany(() => ServiceListing, (service) => service.vendor) + services: ServiceListing[]; + + @OneToMany(() => ServiceBooking, (booking) => booking.vendor) + bookings: ServiceBooking[]; + + @OneToMany(() => VendorReview, (review) => review.vendor) + reviews: VendorReview[]; + + @OneToMany(() => Commission, (commission) => commission.vendor) + commissions: Commission[]; +} diff --git a/src/marketplace/marketplace.module.ts b/src/marketplace/marketplace.module.ts new file mode 100644 index 00000000..82799354 --- /dev/null +++ b/src/marketplace/marketplace.module.ts @@ -0,0 +1,67 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +// Entities +import { Vendor } from './entities/vendor.entity'; +import { VendorProfile } from './entities/vendor-profile.entity'; +import { ServiceCategory } from './entities/service-category.entity'; +import { ServiceListing } from './entities/service-listing.entity'; +import { ServicePricing } from './entities/service-pricing.entity'; +import { ServiceBooking } from './entities/service-booking.entity'; +import { BookingPayment } from './entities/booking-payment.entity'; +import { VendorReview } from './entities/vendor-review.entity'; +import { Commission } from './entities/commission.entity'; +import { PaymentDistribution } from './entities/payment-distribution.entity'; + +// Services +import { VendorService } from './services/vendor.service'; +import { MarketplaceService } from './services/marketplace.service'; +import { BookingService } from './services/booking.service'; +import { CommissionService } from './services/commission.service'; +import { AnalyticsService } from './services/analytics.service'; + +// Controllers +import { VendorController } from './controllers/vendor.controller'; +import { MarketplaceController } from './controllers/marketplace.controller'; +import { BookingController } from './controllers/booking.controller'; +import { CommissionController } from './controllers/commission.controller'; +import { AnalyticsController } from './controllers/analytics.controller'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Vendor, + VendorProfile, + ServiceCategory, + ServiceListing, + ServicePricing, + ServiceBooking, + BookingPayment, + VendorReview, + Commission, + PaymentDistribution, + ]), + ], + controllers: [ + VendorController, + MarketplaceController, + BookingController, + CommissionController, + AnalyticsController, + ], + providers: [ + VendorService, + MarketplaceService, + BookingService, + CommissionService, + AnalyticsService, + ], + exports: [ + VendorService, + MarketplaceService, + BookingService, + CommissionService, + AnalyticsService, + ], +}) +export class MarketplaceModule {} diff --git a/src/marketplace/services/__tests__/booking.service.spec.ts b/src/marketplace/services/__tests__/booking.service.spec.ts new file mode 100644 index 00000000..6c142286 --- /dev/null +++ b/src/marketplace/services/__tests__/booking.service.spec.ts @@ -0,0 +1,259 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { BookingService } from '../booking.service'; +import { ServiceBooking, BookingStatus } from '../../entities/service-booking.entity'; +import { BookingPayment } from '../../entities/booking-payment.entity'; +import { ServiceListing } from '../../entities/service-listing.entity'; +import { Vendor } from '../../entities/vendor.entity'; +import { CommissionService } from '../commission.service'; + +describe('BookingService', () => { + let service: BookingService; + let bookingRepository: jest.Mocked>; + let paymentRepository: jest.Mocked>; + let serviceRepository: jest.Mocked>; + let vendorRepository: jest.Mocked>; + let commissionService: jest.Mocked; + + const mockBooking = { + id: '1', + bookingNumber: 'BK12345', + organizerId: 'organizer-1', + vendorId: 'vendor-1', + serviceId: 'service-1', + status: BookingStatus.PENDING, + eventDate: new Date('2024-12-01'), + startTime: '10:00', + endTime: '14:00', + guestCount: 50, + subtotal: 1000, + totalAmount: 1080, + currency: 'USD', + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockService = { + id: 'service-1', + vendorId: 'vendor-1', + title: 'Test Service', + isActive: true, + vendor: { + id: 'vendor-1', + isActive: true, + }, + pricing: [ + { + id: 'pricing-1', + basePrice: 1000, + isDefault: true, + }, + ], + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BookingService, + { + provide: getRepositoryToken(ServiceBooking), + useValue: { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + count: jest.fn(), + createQueryBuilder: jest.fn(), + }, + }, + { + provide: getRepositoryToken(BookingPayment), + useValue: { + create: jest.fn(), + save: jest.fn(), + }, + }, + { + provide: getRepositoryToken(ServiceListing), + useValue: { + findOne: jest.fn(), + update: jest.fn(), + }, + }, + { + provide: getRepositoryToken(Vendor), + useValue: { + update: jest.fn(), + createQueryBuilder: jest.fn(), + }, + }, + { + provide: CommissionService, + useValue: { + createCommissionForBooking: jest.fn(), + processCommissionPayment: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(BookingService); + bookingRepository = module.get(getRepositoryToken(ServiceBooking)); + paymentRepository = module.get(getRepositoryToken(BookingPayment)); + serviceRepository = module.get(getRepositoryToken(ServiceListing)); + vendorRepository = module.get(getRepositoryToken(Vendor)); + commissionService = module.get(CommissionService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('createBooking', () => { + const createBookingDto = { + organizerId: 'organizer-1', + serviceId: 'service-1', + eventDate: new Date('2024-12-01'), + startTime: '10:00', + endTime: '14:00', + guestCount: 50, + }; + + it('should create a booking successfully', async () => { + serviceRepository.findOne.mockResolvedValue(mockService as any); + jest.spyOn(service as any, 'checkAvailability').mockResolvedValue(null); + jest.spyOn(service as any, 'generateBookingNumber').mockResolvedValue('BK12345'); + jest.spyOn(service as any, 'calculateBookingPrice').mockResolvedValue({ + subtotal: 1000, + taxAmount: 80, + serviceFee: 30, + travelFee: 0, + totalAmount: 1110, + }); + + bookingRepository.create.mockReturnValue(mockBooking as any); + bookingRepository.save.mockResolvedValue(mockBooking as any); + commissionService.createCommissionForBooking.mockResolvedValue({} as any); + jest.spyOn(service, 'findBookingById').mockResolvedValue(mockBooking as any); + + const result = await service.createBooking(createBookingDto); + + expect(serviceRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'service-1', isActive: true }, + relations: ['vendor', 'pricing'], + }); + expect(bookingRepository.create).toHaveBeenCalled(); + expect(bookingRepository.save).toHaveBeenCalled(); + expect(commissionService.createCommissionForBooking).toHaveBeenCalled(); + expect(result).toEqual(mockBooking); + }); + + it('should throw NotFoundException if service not found', async () => { + serviceRepository.findOne.mockResolvedValue(null); + + await expect(service.createBooking(createBookingDto)).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw BadRequestException if vendor is not active', async () => { + const inactiveService = { + ...mockService, + vendor: { ...mockService.vendor, isActive: false }, + }; + serviceRepository.findOne.mockResolvedValue(inactiveService as any); + + await expect(service.createBooking(createBookingDto)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw BadRequestException if service is not available', async () => { + serviceRepository.findOne.mockResolvedValue(mockService as any); + jest.spyOn(service as any, 'checkAvailability').mockResolvedValue(mockBooking); + + await expect(service.createBooking(createBookingDto)).rejects.toThrow( + BadRequestException, + ); + }); + }); + + describe('confirmBooking', () => { + it('should confirm a pending booking', async () => { + const pendingBooking = { ...mockBooking, status: BookingStatus.PENDING }; + jest.spyOn(service, 'findBookingById').mockResolvedValue(pendingBooking as any); + bookingRepository.save.mockResolvedValue({ + ...pendingBooking, + status: BookingStatus.CONFIRMED, + confirmedAt: expect.any(Date), + } as any); + + jest.spyOn(service as any, 'updateServiceStats').mockResolvedValue(undefined); + jest.spyOn(service as any, 'updateVendorStats').mockResolvedValue(undefined); + + const result = await service.confirmBooking('1'); + + expect(result.status).toBe(BookingStatus.CONFIRMED); + expect(result.confirmedAt).toBeDefined(); + }); + + it('should throw BadRequestException if booking is not pending', async () => { + const confirmedBooking = { ...mockBooking, status: BookingStatus.CONFIRMED }; + jest.spyOn(service, 'findBookingById').mockResolvedValue(confirmedBooking as any); + + await expect(service.confirmBooking('1')).rejects.toThrow( + BadRequestException, + ); + }); + }); + + describe('cancelBooking', () => { + it('should cancel a booking and calculate cancellation fee', async () => { + const futureDate = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000); // 10 days from now + const bookingToCancel = { + ...mockBooking, + eventDate: futureDate, + payments: [], + }; + + jest.spyOn(service, 'findBookingById').mockResolvedValue(bookingToCancel as any); + jest.spyOn(service as any, 'calculateCancellationFee').mockResolvedValue(0); + bookingRepository.save.mockResolvedValue({ + ...bookingToCancel, + status: BookingStatus.CANCELLED, + cancelledAt: expect.any(Date), + cancellationReason: 'Test reason', + cancellationFee: 0, + } as any); + + const result = await service.cancelBooking('1', 'Test reason', 'organizer'); + + expect(result.status).toBe(BookingStatus.CANCELLED); + expect(result.cancelledAt).toBeDefined(); + expect(result.cancellationReason).toBe('Test reason'); + }); + }); + + describe('getUpcomingBookings', () => { + it('should return upcoming bookings for vendor', async () => { + const upcomingBookings = [mockBooking]; + bookingRepository.find.mockResolvedValue(upcomingBookings as any); + + const result = await service.getUpcomingBookings('vendor-1', 5); + + expect(bookingRepository.find).toHaveBeenCalledWith({ + where: { + vendorId: 'vendor-1', + eventDate: expect.any(Object), // Between dates + status: expect.any(Object), // In array + }, + relations: ['service', 'organizer', 'event'], + order: { eventDate: 'ASC', startTime: 'ASC' }, + take: 5, + }); + expect(result).toEqual(upcomingBookings); + }); + }); +}); diff --git a/src/marketplace/services/__tests__/vendor.service.spec.ts b/src/marketplace/services/__tests__/vendor.service.spec.ts new file mode 100644 index 00000000..2063f2a1 --- /dev/null +++ b/src/marketplace/services/__tests__/vendor.service.spec.ts @@ -0,0 +1,252 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { VendorService } from '../vendor.service'; +import { Vendor, VendorStatus, VendorTier } from '../../entities/vendor.entity'; +import { VendorProfile } from '../../entities/vendor-profile.entity'; + +describe('VendorService', () => { + let service: VendorService; + let vendorRepository: jest.Mocked>; + let vendorProfileRepository: jest.Mocked>; + + const mockVendor = { + id: '1', + userId: 'user-1', + businessName: 'Test Vendor', + businessRegistrationNumber: 'REG123', + taxId: 'TAX123', + status: VendorStatus.ACTIVE, + tier: VendorTier.BASIC, + commissionRate: 10, + isActive: true, + isVerified: true, + averageRating: 4.5, + totalReviews: 10, + totalBookings: 5, + totalRevenue: 1000, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockVendorProfile = { + id: '1', + vendorId: '1', + description: 'Test description', + website: 'https://test.com', + phone: '+1234567890', + email: 'test@example.com', + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + VendorService, + { + provide: getRepositoryToken(Vendor), + useValue: { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + update: jest.fn(), + increment: jest.fn(), + createQueryBuilder: jest.fn(), + }, + }, + { + provide: getRepositoryToken(VendorProfile), + useValue: { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(VendorService); + vendorRepository = module.get(getRepositoryToken(Vendor)); + vendorProfileRepository = module.get(getRepositoryToken(VendorProfile)); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('create', () => { + const createVendorDto = { + userId: 'user-1', + businessName: 'Test Vendor', + businessRegistrationNumber: 'REG123', + taxId: 'TAX123', + profile: { + description: 'Test description', + website: 'https://test.com', + }, + }; + + it('should create a vendor successfully', async () => { + vendorRepository.findOne.mockResolvedValueOnce(null); // No existing vendor + vendorRepository.findOne.mockResolvedValueOnce(null); // No existing business name + vendorRepository.create.mockReturnValue(mockVendor as any); + vendorRepository.save.mockResolvedValue(mockVendor as any); + vendorProfileRepository.create.mockReturnValue(mockVendorProfile as any); + vendorProfileRepository.save.mockResolvedValue(mockVendorProfile as any); + + jest.spyOn(service, 'findOne').mockResolvedValue(mockVendor as any); + + const result = await service.create(createVendorDto); + + expect(vendorRepository.create).toHaveBeenCalledWith({ + ...createVendorDto, + status: VendorStatus.PENDING, + tier: VendorTier.BASIC, + }); + expect(vendorRepository.save).toHaveBeenCalled(); + expect(vendorProfileRepository.create).toHaveBeenCalled(); + expect(vendorProfileRepository.save).toHaveBeenCalled(); + expect(result).toEqual(mockVendor); + }); + + it('should throw BadRequestException if user is already a vendor', async () => { + vendorRepository.findOne.mockResolvedValueOnce(mockVendor as any); + + await expect(service.create(createVendorDto)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw BadRequestException if business name already exists', async () => { + vendorRepository.findOne.mockResolvedValueOnce(null); // No existing vendor + vendorRepository.findOne.mockResolvedValueOnce(mockVendor as any); // Existing business name + + await expect(service.create(createVendorDto)).rejects.toThrow( + BadRequestException, + ); + }); + }); + + describe('findOne', () => { + it('should return a vendor by id', async () => { + vendorRepository.findOne.mockResolvedValue(mockVendor as any); + + const result = await service.findOne('1'); + + expect(vendorRepository.findOne).toHaveBeenCalledWith({ + where: { id: '1' }, + relations: [ + 'profile', + 'user', + 'services', + 'reviews', + 'bookings', + 'commissions', + ], + }); + expect(result).toEqual(mockVendor); + }); + + it('should throw NotFoundException if vendor not found', async () => { + vendorRepository.findOne.mockResolvedValue(null); + + await expect(service.findOne('1')).rejects.toThrow(NotFoundException); + }); + }); + + describe('updateStatus', () => { + it('should update vendor status to active and set verification', async () => { + const vendor = { ...mockVendor, status: VendorStatus.PENDING }; + jest.spyOn(service, 'findOne').mockResolvedValue(vendor as any); + vendorRepository.save.mockResolvedValue({ + ...vendor, + status: VendorStatus.ACTIVE, + isVerified: true, + verifiedAt: expect.any(Date), + } as any); + + const result = await service.updateStatus('1', VendorStatus.ACTIVE); + + expect(result.status).toBe(VendorStatus.ACTIVE); + expect(result.isVerified).toBe(true); + expect(result.verifiedAt).toBeDefined(); + }); + }); + + describe('updateRating', () => { + it('should update vendor rating and review count', async () => { + const mockQueryBuilder = { + leftJoin: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ + avgRating: '4.5', + totalReviews: '10', + }), + }; + + vendorRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + vendorRepository.update.mockResolvedValue({} as any); + + await service.updateRating('1'); + + expect(vendorRepository.update).toHaveBeenCalledWith('1', { + averageRating: 4.5, + totalReviews: 10, + }); + }); + }); + + describe('searchVendors', () => { + it('should search vendors with filters', async () => { + const mockQueryBuilder = { + createQueryBuilder: jest.fn().mockReturnThis(), + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([mockVendor]), + }; + + vendorRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.searchVendors('test', { + serviceType: 'catering', + location: 'New York', + rating: 4, + }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + '(vendor.businessName LIKE :search OR profile.description LIKE :search OR service.title LIKE :search)', + { search: '%test%' }, + ); + expect(result).toEqual([mockVendor]); + }); + }); + + describe('getFeaturedVendors', () => { + it('should return featured vendors', async () => { + vendorRepository.find.mockResolvedValue([mockVendor] as any); + + const result = await service.getFeaturedVendors(5); + + expect(vendorRepository.find).toHaveBeenCalledWith({ + where: { + isFeatured: true, + isActive: true, + status: VendorStatus.ACTIVE, + }, + relations: ['profile', 'services'], + order: { averageRating: 'DESC', totalReviews: 'DESC' }, + take: 5, + }); + expect(result).toEqual([mockVendor]); + }); + }); +}); diff --git a/src/marketplace/services/analytics.service.ts b/src/marketplace/services/analytics.service.ts new file mode 100644 index 00000000..fbc6eab9 --- /dev/null +++ b/src/marketplace/services/analytics.service.ts @@ -0,0 +1,286 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between } from 'typeorm'; +import { Vendor } from '../entities/vendor.entity'; +import { ServiceListing } from '../entities/service-listing.entity'; +import { ServiceBooking } from '../entities/service-booking.entity'; +import { Commission } from '../entities/commission.entity'; +import { VendorReview } from '../entities/vendor-review.entity'; + +@Injectable() +export class AnalyticsService { + constructor( + @InjectRepository(Vendor) + private vendorRepository: Repository, + @InjectRepository(ServiceListing) + private serviceRepository: Repository, + @InjectRepository(ServiceBooking) + private bookingRepository: Repository, + @InjectRepository(Commission) + private commissionRepository: Repository, + @InjectRepository(VendorReview) + private reviewRepository: Repository, + ) {} + + async getVendorDashboard(vendorId: string, dateRange?: { start: Date; end: Date }) { + const baseQuery = dateRange ? + { vendorId, createdAt: Between(dateRange.start, dateRange.end) } : + { vendorId }; + + const [ + bookingStats, + revenueStats, + serviceStats, + reviewStats, + recentBookings, + topServices, + ] = await Promise.all([ + this.getBookingStats(baseQuery), + this.getRevenueStats(baseQuery), + this.getServiceStats(vendorId), + this.getReviewStats(vendorId, dateRange), + this.getRecentBookings(vendorId, 5), + this.getTopServices(vendorId, 5), + ]); + + return { + bookingStats, + revenueStats, + serviceStats, + reviewStats, + recentBookings, + topServices, + dateRange, + }; + } + + async getMarketplaceDashboard(dateRange?: { start: Date; end: Date }) { + const [ + platformStats, + vendorStats, + serviceStats, + bookingStats, + revenueStats, + topVendors, + topServices, + ] = await Promise.all([ + this.getPlatformStats(dateRange), + this.getVendorOverview(dateRange), + this.getServiceOverview(dateRange), + this.getBookingOverview(dateRange), + this.getRevenueOverview(dateRange), + this.getTopVendors(10), + this.getTopMarketplaceServices(10), + ]); + + return { + platformStats, + vendorStats, + serviceStats, + bookingStats, + revenueStats, + topVendors, + topServices, + dateRange, + }; + } + + private async getBookingStats(filters: any) { + const result = await this.bookingRepository + .createQueryBuilder('booking') + .select('COUNT(booking.id)', 'totalBookings') + .addSelect('COUNT(CASE WHEN booking.status = :confirmed THEN 1 END)', 'confirmedBookings') + .addSelect('COUNT(CASE WHEN booking.status = :completed THEN 1 END)', 'completedBookings') + .addSelect('COUNT(CASE WHEN booking.status = :cancelled THEN 1 END)', 'cancelledBookings') + .where('booking.vendorId = :vendorId', { vendorId: filters.vendorId }) + .setParameters({ confirmed: 'confirmed', completed: 'completed', cancelled: 'cancelled' }) + .getRawOne(); + + return { + total: parseInt(result.totalBookings) || 0, + confirmed: parseInt(result.confirmedBookings) || 0, + completed: parseInt(result.completedBookings) || 0, + cancelled: parseInt(result.cancelledBookings) || 0, + completionRate: result.totalBookings > 0 ? + (parseInt(result.completedBookings) / parseInt(result.totalBookings)) * 100 : 0, + }; + } + + private async getRevenueStats(filters: any) { + const result = await this.bookingRepository + .createQueryBuilder('booking') + .select('SUM(booking.totalAmount)', 'totalRevenue') + .addSelect('AVG(booking.totalAmount)', 'averageBookingValue') + .addSelect('SUM(CASE WHEN booking.status = :completed THEN booking.totalAmount ELSE 0 END)', 'completedRevenue') + .where('booking.vendorId = :vendorId', { vendorId: filters.vendorId }) + .setParameter('completed', 'completed') + .getRawOne(); + + return { + totalRevenue: parseFloat(result.totalRevenue) || 0, + completedRevenue: parseFloat(result.completedRevenue) || 0, + averageBookingValue: parseFloat(result.averageBookingValue) || 0, + }; + } + + private async getServiceStats(vendorId: string) { + const result = await this.serviceRepository + .createQueryBuilder('service') + .select('COUNT(service.id)', 'totalServices') + .addSelect('COUNT(CASE WHEN service.status = :active THEN 1 END)', 'activeServices') + .addSelect('AVG(service.averageRating)', 'averageRating') + .addSelect('SUM(service.viewCount)', 'totalViews') + .where('service.vendorId = :vendorId', { vendorId }) + .setParameter('active', 'active') + .getRawOne(); + + return { + total: parseInt(result.totalServices) || 0, + active: parseInt(result.activeServices) || 0, + averageRating: parseFloat(result.averageRating) || 0, + totalViews: parseInt(result.totalViews) || 0, + }; + } + + private async getReviewStats(vendorId: string, dateRange?: { start: Date; end: Date }) { + const queryBuilder = this.reviewRepository + .createQueryBuilder('review') + .select('COUNT(review.id)', 'totalReviews') + .addSelect('AVG(review.rating)', 'averageRating') + .addSelect('COUNT(CASE WHEN review.rating >= 4 THEN 1 END)', 'positiveReviews') + .where('review.vendorId = :vendorId', { vendorId }) + .andWhere('review.status = :approved', { approved: 'approved' }); + + if (dateRange) { + queryBuilder.andWhere('review.createdAt BETWEEN :start AND :end', dateRange); + } + + const result = await queryBuilder.getRawOne(); + + return { + total: parseInt(result.totalReviews) || 0, + averageRating: parseFloat(result.averageRating) || 0, + positiveReviews: parseInt(result.positiveReviews) || 0, + positiveRate: result.totalReviews > 0 ? + (parseInt(result.positiveReviews) / parseInt(result.totalReviews)) * 100 : 0, + }; + } + + private async getRecentBookings(vendorId: string, limit: number) { + return this.bookingRepository.find({ + where: { vendorId }, + relations: ['organizer', 'service'], + order: { createdAt: 'DESC' }, + take: limit, + }); + } + + private async getTopServices(vendorId: string, limit: number) { + return this.serviceRepository.find({ + where: { vendorId }, + order: { totalBookings: 'DESC', averageRating: 'DESC' }, + take: limit, + }); + } + + private async getPlatformStats(dateRange?: { start: Date; end: Date }) { + const vendorQuery = this.vendorRepository.createQueryBuilder('vendor'); + const serviceQuery = this.serviceRepository.createQueryBuilder('service'); + const bookingQuery = this.bookingRepository.createQueryBuilder('booking'); + + if (dateRange) { + vendorQuery.where('vendor.createdAt BETWEEN :start AND :end', dateRange); + serviceQuery.where('service.createdAt BETWEEN :start AND :end', dateRange); + bookingQuery.where('booking.createdAt BETWEEN :start AND :end', dateRange); + } + + const [vendorCount, serviceCount, bookingCount] = await Promise.all([ + vendorQuery.getCount(), + serviceQuery.getCount(), + bookingQuery.getCount(), + ]); + + return { + totalVendors: vendorCount, + totalServices: serviceCount, + totalBookings: bookingCount, + }; + } + + private async getVendorOverview(dateRange?: { start: Date; end: Date }) { + const queryBuilder = this.vendorRepository + .createQueryBuilder('vendor') + .select('COUNT(vendor.id)', 'total') + .addSelect('COUNT(CASE WHEN vendor.status = :active THEN 1 END)', 'active') + .addSelect('COUNT(CASE WHEN vendor.isVerified = true THEN 1 END)', 'verified') + .setParameter('active', 'active'); + + if (dateRange) { + queryBuilder.where('vendor.createdAt BETWEEN :start AND :end', dateRange); + } + + return queryBuilder.getRawOne(); + } + + private async getServiceOverview(dateRange?: { start: Date; end: Date }) { + const queryBuilder = this.serviceRepository + .createQueryBuilder('service') + .select('COUNT(service.id)', 'total') + .addSelect('COUNT(CASE WHEN service.status = :active THEN 1 END)', 'active') + .addSelect('AVG(service.averageRating)', 'averageRating') + .setParameter('active', 'active'); + + if (dateRange) { + queryBuilder.where('service.createdAt BETWEEN :start AND :end', dateRange); + } + + return queryBuilder.getRawOne(); + } + + private async getBookingOverview(dateRange?: { start: Date; end: Date }) { + const queryBuilder = this.bookingRepository + .createQueryBuilder('booking') + .select('COUNT(booking.id)', 'total') + .addSelect('COUNT(CASE WHEN booking.status = :completed THEN 1 END)', 'completed') + .addSelect('SUM(booking.totalAmount)', 'totalValue') + .setParameter('completed', 'completed'); + + if (dateRange) { + queryBuilder.where('booking.createdAt BETWEEN :start AND :end', dateRange); + } + + return queryBuilder.getRawOne(); + } + + private async getRevenueOverview(dateRange?: { start: Date; end: Date }) { + const queryBuilder = this.commissionRepository + .createQueryBuilder('commission') + .select('SUM(commission.bookingAmount)', 'totalRevenue') + .addSelect('SUM(commission.commissionAmount)', 'totalCommissions') + .addSelect('SUM(commission.vendorPayout)', 'totalPayouts'); + + if (dateRange) { + queryBuilder.where('commission.createdAt BETWEEN :start AND :end', dateRange); + } + + return queryBuilder.getRawOne(); + } + + private async getTopVendors(limit: number) { + return this.vendorRepository.find({ + where: { isActive: true }, + relations: ['profile'], + order: { totalRevenue: 'DESC', averageRating: 'DESC' }, + take: limit, + }); + } + + private async getTopMarketplaceServices(limit: number) { + return this.serviceRepository.find({ + where: { isActive: true }, + relations: ['vendor', 'category'], + order: { totalBookings: 'DESC', averageRating: 'DESC' }, + take: limit, + }); + } +} diff --git a/src/marketplace/services/booking.service.ts b/src/marketplace/services/booking.service.ts new file mode 100644 index 00000000..59314c91 --- /dev/null +++ b/src/marketplace/services/booking.service.ts @@ -0,0 +1,518 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between, In } from 'typeorm'; +import { ServiceBooking, BookingStatus } from '../entities/service-booking.entity'; +import { BookingPayment, PaymentStatus } from '../entities/booking-payment.entity'; +import { ServiceListing } from '../entities/service-listing.entity'; +import { Vendor } from '../entities/vendor.entity'; +import { CreateBookingDto } from '../dto/create-booking.dto'; +import { UpdateBookingDto } from '../dto/update-booking.dto'; +import { BookingQueryDto } from '../dto/booking-query.dto'; +import { CommissionService } from './commission.service'; + +@Injectable() +export class BookingService { + constructor( + @InjectRepository(ServiceBooking) + private bookingRepository: Repository, + @InjectRepository(BookingPayment) + private paymentRepository: Repository, + @InjectRepository(ServiceListing) + private serviceRepository: Repository, + @InjectRepository(Vendor) + private vendorRepository: Repository, + private commissionService: CommissionService, + ) {} + + async createBooking(createBookingDto: CreateBookingDto): Promise { + const service = await this.serviceRepository.findOne({ + where: { id: createBookingDto.serviceId, isActive: true }, + relations: ['vendor', 'pricing'], + }); + + if (!service) { + throw new NotFoundException('Service not found or inactive'); + } + + if (!service.vendor.isActive) { + throw new BadRequestException('Vendor is not active'); + } + + // Check availability + const conflictingBooking = await this.checkAvailability( + createBookingDto.serviceId, + createBookingDto.eventDate, + createBookingDto.startTime, + createBookingDto.endTime, + ); + + if (conflictingBooking) { + throw new BadRequestException('Service is not available at the requested time'); + } + + // Generate unique booking number + const bookingNumber = await this.generateBookingNumber(); + + // Calculate pricing + const pricing = await this.calculateBookingPrice(createBookingDto, service); + + const booking = this.bookingRepository.create({ + ...createBookingDto, + bookingNumber, + vendorId: service.vendorId, + ...pricing, + status: BookingStatus.PENDING, + }); + + const savedBooking = await this.bookingRepository.save(booking); + + // Create commission record + await this.commissionService.createCommissionForBooking(savedBooking); + + return this.findBookingById(savedBooking.id); + } + + async findBookings(query: BookingQueryDto): Promise<{ + bookings: ServiceBooking[]; + total: number; + page: number; + limit: number; + }> { + const { + page = 1, + limit = 20, + status, + vendorId, + organizerId, + eventId, + dateRange, + sortBy = 'createdAt', + sortOrder = 'DESC', + } = query; + + const queryBuilder = this.bookingRepository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.organizer', 'organizer') + .leftJoinAndSelect('booking.vendor', 'vendor') + .leftJoinAndSelect('booking.service', 'service') + .leftJoinAndSelect('booking.event', 'event') + .leftJoinAndSelect('booking.payments', 'payments'); + + // Apply filters + if (status) { + if (Array.isArray(status)) { + queryBuilder.andWhere('booking.status IN (:...statuses)', { statuses: status }); + } else { + queryBuilder.andWhere('booking.status = :status', { status }); + } + } + + if (vendorId) { + queryBuilder.andWhere('booking.vendorId = :vendorId', { vendorId }); + } + + if (organizerId) { + queryBuilder.andWhere('booking.organizerId = :organizerId', { organizerId }); + } + + if (eventId) { + queryBuilder.andWhere('booking.eventId = :eventId', { eventId }); + } + + if (dateRange) { + queryBuilder.andWhere('booking.eventDate BETWEEN :startDate AND :endDate', { + startDate: dateRange.start, + endDate: dateRange.end, + }); + } + + // Apply sorting + queryBuilder.orderBy(`booking.${sortBy}`, sortOrder); + + // Apply pagination + const offset = (page - 1) * limit; + queryBuilder.skip(offset).take(limit); + + const [bookings, total] = await queryBuilder.getManyAndCount(); + + return { + bookings, + total, + page, + limit, + }; + } + + async findBookingById(id: string): Promise { + const booking = await this.bookingRepository.findOne({ + where: { id }, + relations: [ + 'organizer', + 'vendor', + 'vendor.profile', + 'service', + 'service.category', + 'event', + 'pricing', + 'payments', + 'commission', + ], + }); + + if (!booking) { + throw new NotFoundException('Booking not found'); + } + + return booking; + } + + async findBookingByNumber(bookingNumber: string): Promise { + const booking = await this.bookingRepository.findOne({ + where: { bookingNumber }, + relations: [ + 'organizer', + 'vendor', + 'service', + 'event', + 'payments', + ], + }); + + if (!booking) { + throw new NotFoundException('Booking not found'); + } + + return booking; + } + + async updateBooking(id: string, updateBookingDto: UpdateBookingDto): Promise { + const booking = await this.findBookingById(id); + + // Check if booking can be updated + if (booking.status === BookingStatus.COMPLETED || booking.status === BookingStatus.CANCELLED) { + throw new BadRequestException('Cannot update completed or cancelled booking'); + } + + // If changing date/time, check availability + if ( + updateBookingDto.eventDate || + updateBookingDto.startTime || + updateBookingDto.endTime + ) { + const eventDate = updateBookingDto.eventDate || booking.eventDate; + const startTime = updateBookingDto.startTime || booking.startTime; + const endTime = updateBookingDto.endTime || booking.endTime; + + const conflictingBooking = await this.checkAvailability( + booking.serviceId, + eventDate, + startTime, + endTime, + id, // Exclude current booking + ); + + if (conflictingBooking) { + throw new BadRequestException('Service is not available at the requested time'); + } + } + + // Recalculate pricing if relevant fields changed + if ( + updateBookingDto.guestCount || + updateBookingDto.selectedAddOns || + updateBookingDto.appliedDiscounts + ) { + const service = await this.serviceRepository.findOne({ + where: { id: booking.serviceId }, + relations: ['pricing'], + }); + + const pricing = await this.calculateBookingPrice( + { ...booking, ...updateBookingDto }, + service, + ); + + Object.assign(updateBookingDto, pricing); + } + + Object.assign(booking, updateBookingDto); + return this.bookingRepository.save(booking); + } + + async confirmBooking(id: string): Promise { + const booking = await this.findBookingById(id); + + if (booking.status !== BookingStatus.PENDING) { + throw new BadRequestException('Only pending bookings can be confirmed'); + } + + booking.status = BookingStatus.CONFIRMED; + booking.confirmedAt = new Date(); + + const updatedBooking = await this.bookingRepository.save(booking); + + // Update service and vendor stats + await this.updateServiceStats(booking.serviceId); + await this.updateVendorStats(booking.vendorId); + + return updatedBooking; + } + + async completeBooking(id: string): Promise { + const booking = await this.findBookingById(id); + + if (booking.status !== BookingStatus.CONFIRMED && booking.status !== BookingStatus.IN_PROGRESS) { + throw new BadRequestException('Only confirmed or in-progress bookings can be completed'); + } + + booking.status = BookingStatus.COMPLETED; + booking.completedAt = new Date(); + + const updatedBooking = await this.bookingRepository.save(booking); + + // Process commission payment + await this.commissionService.processCommissionPayment(booking.commission.id); + + return updatedBooking; + } + + async cancelBooking( + id: string, + reason: string, + cancelledBy: 'organizer' | 'vendor' | 'admin', + ): Promise { + const booking = await this.findBookingById(id); + + if (booking.status === BookingStatus.COMPLETED || booking.status === BookingStatus.CANCELLED) { + throw new BadRequestException('Cannot cancel completed or already cancelled booking'); + } + + // Calculate cancellation fee based on timing and policy + const cancellationFee = await this.calculateCancellationFee(booking); + + booking.status = BookingStatus.CANCELLED; + booking.cancelledAt = new Date(); + booking.cancellationReason = reason; + booking.cancellationFee = cancellationFee; + + const updatedBooking = await this.bookingRepository.save(booking); + + // Process refund if applicable + if (booking.payments && booking.payments.length > 0) { + await this.processRefund(booking, cancellationFee); + } + + return updatedBooking; + } + + async getBookingsByDateRange( + vendorId: string, + startDate: Date, + endDate: Date, + ): Promise { + return this.bookingRepository.find({ + where: { + vendorId, + eventDate: Between(startDate, endDate), + status: In([BookingStatus.CONFIRMED, BookingStatus.IN_PROGRESS]), + }, + relations: ['service', 'organizer'], + order: { eventDate: 'ASC', startTime: 'ASC' }, + }); + } + + async getUpcomingBookings( + vendorId: string, + limit: number = 10, + ): Promise { + const today = new Date(); + + return this.bookingRepository.find({ + where: { + vendorId, + eventDate: Between(today, new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000)), // Next 30 days + status: In([BookingStatus.CONFIRMED, BookingStatus.IN_PROGRESS]), + }, + relations: ['service', 'organizer', 'event'], + order: { eventDate: 'ASC', startTime: 'ASC' }, + take: limit, + }); + } + + private async checkAvailability( + serviceId: string, + eventDate: Date, + startTime: string, + endTime: string, + excludeBookingId?: string, + ): Promise { + const queryBuilder = this.bookingRepository + .createQueryBuilder('booking') + .where('booking.serviceId = :serviceId', { serviceId }) + .andWhere('booking.eventDate = :eventDate', { eventDate }) + .andWhere('booking.status IN (:...statuses)', { + statuses: [BookingStatus.CONFIRMED, BookingStatus.IN_PROGRESS], + }) + .andWhere( + '(booking.startTime < :endTime AND booking.endTime > :startTime)', + { startTime, endTime }, + ); + + if (excludeBookingId) { + queryBuilder.andWhere('booking.id != :excludeBookingId', { excludeBookingId }); + } + + return queryBuilder.getOne(); + } + + private async generateBookingNumber(): Promise { + const prefix = 'BK'; + const timestamp = Date.now().toString().slice(-8); + const random = Math.floor(Math.random() * 1000).toString().padStart(3, '0'); + + let bookingNumber = `${prefix}${timestamp}${random}`; + + // Ensure uniqueness + while (await this.bookingRepository.findOne({ where: { bookingNumber } })) { + const newRandom = Math.floor(Math.random() * 1000).toString().padStart(3, '0'); + bookingNumber = `${prefix}${timestamp}${newRandom}`; + } + + return bookingNumber; + } + + private async calculateBookingPrice( + bookingData: any, + service: ServiceListing, + ): Promise<{ + subtotal: number; + taxAmount: number; + serviceFee: number; + travelFee: number; + totalAmount: number; + }> { + let subtotal = 0; + + // Get base price from selected pricing or default pricing + const pricing = bookingData.pricingId + ? service.pricing.find(p => p.id === bookingData.pricingId) + : service.pricing.find(p => p.isDefault) || service.pricing[0]; + + if (pricing) { + subtotal = parseFloat(pricing.basePrice.toString()); + + // Apply guest count pricing if applicable + if (pricing.capacity?.pricePerGuest && bookingData.guestCount) { + subtotal += pricing.capacity.pricePerGuest * bookingData.guestCount; + } + } + + // Add selected add-ons + if (bookingData.selectedAddOns) { + for (const addOn of bookingData.selectedAddOns) { + subtotal += addOn.price * (addOn.quantity || 1); + } + } + + // Apply discounts + if (bookingData.appliedDiscounts) { + for (const discount of bookingData.appliedDiscounts) { + if (discount.type === 'percentage') { + subtotal -= subtotal * (discount.value / 100); + } else { + subtotal -= discount.value; + } + } + } + + // Calculate fees + const taxRate = 0.08; // 8% tax rate (should be configurable) + const serviceFeeRate = 0.03; // 3% service fee + + const taxAmount = subtotal * taxRate; + const serviceFee = subtotal * serviceFeeRate; + const travelFee = bookingData.travelFee || 0; + + const totalAmount = subtotal + taxAmount + serviceFee + travelFee; + + return { + subtotal, + taxAmount, + serviceFee, + travelFee, + totalAmount, + }; + } + + private async calculateCancellationFee(booking: ServiceBooking): Promise { + const now = new Date(); + const eventDate = new Date(booking.eventDate); + const hoursUntilEvent = (eventDate.getTime() - now.getTime()) / (1000 * 60 * 60); + + // Cancellation policy based on timing + if (hoursUntilEvent > 168) { // More than 7 days + return 0; // No fee + } else if (hoursUntilEvent > 72) { // 3-7 days + return booking.totalAmount * 0.25; // 25% fee + } else if (hoursUntilEvent > 24) { // 1-3 days + return booking.totalAmount * 0.50; // 50% fee + } else { + return booking.totalAmount * 0.75; // 75% fee + } + } + + private async processRefund(booking: ServiceBooking, cancellationFee: number): Promise { + const totalPaid = booking.payments + .filter(p => p.status === PaymentStatus.COMPLETED) + .reduce((sum, p) => sum + parseFloat(p.amount.toString()), 0); + + const refundAmount = totalPaid - cancellationFee; + + if (refundAmount > 0) { + // Create refund payment record + const refundPayment = this.paymentRepository.create({ + bookingId: booking.id, + transactionId: `REF-${Date.now()}`, + status: PaymentStatus.PENDING, + paymentType: 'refund', + paymentMethod: 'refund', + amount: refundAmount, + currency: booking.currency, + description: `Refund for cancelled booking ${booking.bookingNumber}`, + }); + + await this.paymentRepository.save(refundPayment); + + // TODO: Integrate with payment processor to process actual refund + } + } + + private async updateServiceStats(serviceId: string): Promise { + const bookingCount = await this.bookingRepository.count({ + where: { + serviceId, + status: In([BookingStatus.CONFIRMED, BookingStatus.COMPLETED]), + }, + }); + + await this.serviceRepository.update(serviceId, { + totalBookings: bookingCount, + }); + } + + private async updateVendorStats(vendorId: string): Promise { + const stats = await this.bookingRepository + .createQueryBuilder('booking') + .select('COUNT(booking.id)', 'totalBookings') + .addSelect('SUM(booking.totalAmount)', 'totalRevenue') + .where('booking.vendorId = :vendorId', { vendorId }) + .andWhere('booking.status = :status', { status: BookingStatus.COMPLETED }) + .getRawOne(); + + await this.vendorRepository.update(vendorId, { + totalBookings: parseInt(stats.totalBookings) || 0, + totalRevenue: parseFloat(stats.totalRevenue) || 0, + lastActiveAt: new Date(), + }); + } +} diff --git a/src/marketplace/services/commission.service.ts b/src/marketplace/services/commission.service.ts new file mode 100644 index 00000000..0acf832f --- /dev/null +++ b/src/marketplace/services/commission.service.ts @@ -0,0 +1,405 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between, In } from 'typeorm'; +import { Commission, CommissionStatus, CommissionType } from '../entities/commission.entity'; +import { PaymentDistribution, DistributionStatus } from '../entities/payment-distribution.entity'; +import { ServiceBooking } from '../entities/service-booking.entity'; +import { Vendor } from '../entities/vendor.entity'; + +@Injectable() +export class CommissionService { + constructor( + @InjectRepository(Commission) + private commissionRepository: Repository, + @InjectRepository(PaymentDistribution) + private distributionRepository: Repository, + @InjectRepository(ServiceBooking) + private bookingRepository: Repository, + @InjectRepository(Vendor) + private vendorRepository: Repository, + ) {} + + async createCommissionForBooking(booking: ServiceBooking): Promise { + const vendor = await this.vendorRepository.findOne({ + where: { id: booking.vendorId }, + }); + + if (!vendor) { + throw new NotFoundException('Vendor not found'); + } + + const commissionNumber = await this.generateCommissionNumber(); + const commissionRate = vendor.commissionRate; + const commissionAmount = booking.totalAmount * (commissionRate / 100); + const processingFee = booking.totalAmount * 0.029; // 2.9% processing fee + const platformFee = booking.totalAmount * 0.005; // 0.5% platform fee + const vendorPayout = booking.totalAmount - commissionAmount - processingFee - platformFee; + + const commission = this.commissionRepository.create({ + commissionNumber, + vendorId: booking.vendorId, + bookingId: booking.id, + commissionType: CommissionType.BOOKING, + status: CommissionStatus.PENDING, + bookingAmount: booking.totalAmount, + commissionRate, + commissionAmount, + vendorPayout, + processingFee, + platformFee, + currency: booking.currency, + dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now + description: `Commission for booking ${booking.bookingNumber}`, + }); + + return this.commissionRepository.save(commission); + } + + async calculateCommission(commissionId: string): Promise { + const commission = await this.commissionRepository.findOne({ + where: { id: commissionId }, + relations: ['booking', 'vendor'], + }); + + if (!commission) { + throw new NotFoundException('Commission not found'); + } + + if (commission.status !== CommissionStatus.PENDING) { + throw new BadRequestException('Commission already calculated'); + } + + // Recalculate based on current booking amount + const booking = commission.booking; + const vendor = commission.vendor; + + const commissionRate = vendor.commissionRate; + const commissionAmount = booking.totalAmount * (commissionRate / 100); + const processingFee = booking.totalAmount * 0.029; + const platformFee = booking.totalAmount * 0.005; + const vendorPayout = booking.totalAmount - commissionAmount - processingFee - platformFee; + + commission.bookingAmount = booking.totalAmount; + commission.commissionRate = commissionRate; + commission.commissionAmount = commissionAmount; + commission.vendorPayout = vendorPayout; + commission.processingFee = processingFee; + commission.platformFee = platformFee; + commission.status = CommissionStatus.CALCULATED; + commission.calculatedAt = new Date(); + + return this.commissionRepository.save(commission); + } + + async approveCommission(commissionId: string): Promise { + const commission = await this.findCommissionById(commissionId); + + if (commission.status !== CommissionStatus.CALCULATED) { + throw new BadRequestException('Commission must be calculated before approval'); + } + + commission.status = CommissionStatus.APPROVED; + commission.approvedAt = new Date(); + + const updatedCommission = await this.commissionRepository.save(commission); + + // Create payment distribution + await this.createPaymentDistribution(updatedCommission); + + return updatedCommission; + } + + async processCommissionPayment(commissionId: string): Promise { + const commission = await this.findCommissionById(commissionId); + + if (commission.status !== CommissionStatus.APPROVED) { + throw new BadRequestException('Commission must be approved before payment'); + } + + // Update commission status + commission.status = CommissionStatus.PAID; + commission.paidAt = new Date(); + + // Update payment distribution status + if (commission.paymentDistribution) { + commission.paymentDistribution.status = DistributionStatus.PROCESSING; + commission.paymentDistribution.processedAt = new Date(); + await this.distributionRepository.save(commission.paymentDistribution); + } + + return this.commissionRepository.save(commission); + } + + async findCommissionById(id: string): Promise { + const commission = await this.commissionRepository.findOne({ + where: { id }, + relations: ['vendor', 'booking', 'paymentDistribution'], + }); + + if (!commission) { + throw new NotFoundException('Commission not found'); + } + + return commission; + } + + async getVendorCommissions( + vendorId: string, + filters: { + status?: CommissionStatus; + dateRange?: { start: Date; end: Date }; + page?: number; + limit?: number; + } = {}, + ): Promise<{ + commissions: Commission[]; + total: number; + summary: { + totalEarnings: number; + pendingAmount: number; + paidAmount: number; + totalCommissions: number; + }; + }> { + const { status, dateRange, page = 1, limit = 20 } = filters; + + const queryBuilder = this.commissionRepository + .createQueryBuilder('commission') + .leftJoinAndSelect('commission.booking', 'booking') + .leftJoinAndSelect('commission.paymentDistribution', 'distribution') + .where('commission.vendorId = :vendorId', { vendorId }); + + if (status) { + queryBuilder.andWhere('commission.status = :status', { status }); + } + + if (dateRange) { + queryBuilder.andWhere('commission.createdAt BETWEEN :start AND :end', { + start: dateRange.start, + end: dateRange.end, + }); + } + + // Get total count and summary + const [commissions, total] = await queryBuilder + .orderBy('commission.createdAt', 'DESC') + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); + + // Calculate summary + const summaryQuery = this.commissionRepository + .createQueryBuilder('commission') + .select('SUM(commission.vendorPayout)', 'totalEarnings') + .addSelect('SUM(CASE WHEN commission.status IN (:...pendingStatuses) THEN commission.vendorPayout ELSE 0 END)', 'pendingAmount') + .addSelect('SUM(CASE WHEN commission.status = :paidStatus THEN commission.vendorPayout ELSE 0 END)', 'paidAmount') + .addSelect('COUNT(commission.id)', 'totalCommissions') + .where('commission.vendorId = :vendorId', { vendorId }) + .setParameters({ + pendingStatuses: [CommissionStatus.PENDING, CommissionStatus.CALCULATED, CommissionStatus.APPROVED], + paidStatus: CommissionStatus.PAID, + }); + + if (dateRange) { + summaryQuery.andWhere('commission.createdAt BETWEEN :start AND :end', { + start: dateRange.start, + end: dateRange.end, + }); + } + + const summary = await summaryQuery.getRawOne(); + + return { + commissions, + total, + summary: { + totalEarnings: parseFloat(summary.totalEarnings) || 0, + pendingAmount: parseFloat(summary.pendingAmount) || 0, + paidAmount: parseFloat(summary.paidAmount) || 0, + totalCommissions: parseInt(summary.totalCommissions) || 0, + }, + }; + } + + async getPlatformCommissionSummary( + dateRange?: { start: Date; end: Date }, + ): Promise<{ + totalRevenue: number; + totalCommissions: number; + totalProcessingFees: number; + totalPlatformFees: number; + vendorPayouts: number; + commissionsByStatus: Record; + }> { + const queryBuilder = this.commissionRepository + .createQueryBuilder('commission') + .select('SUM(commission.bookingAmount)', 'totalRevenue') + .addSelect('SUM(commission.commissionAmount)', 'totalCommissions') + .addSelect('SUM(commission.processingFee)', 'totalProcessingFees') + .addSelect('SUM(commission.platformFee)', 'totalPlatformFees') + .addSelect('SUM(commission.vendorPayout)', 'vendorPayouts'); + + if (dateRange) { + queryBuilder.where('commission.createdAt BETWEEN :start AND :end', { + start: dateRange.start, + end: dateRange.end, + }); + } + + const summary = await queryBuilder.getRawOne(); + + // Get commission counts by status + const statusQuery = this.commissionRepository + .createQueryBuilder('commission') + .select('commission.status', 'status') + .addSelect('COUNT(commission.id)', 'count'); + + if (dateRange) { + statusQuery.where('commission.createdAt BETWEEN :start AND :end', { + start: dateRange.start, + end: dateRange.end, + }); + } + + const statusCounts = await statusQuery + .groupBy('commission.status') + .getRawMany(); + + const commissionsByStatus = statusCounts.reduce((acc, item) => { + acc[item.status] = parseInt(item.count); + return acc; + }, {} as Record); + + return { + totalRevenue: parseFloat(summary.totalRevenue) || 0, + totalCommissions: parseFloat(summary.totalCommissions) || 0, + totalProcessingFees: parseFloat(summary.totalProcessingFees) || 0, + totalPlatformFees: parseFloat(summary.totalPlatformFees) || 0, + vendorPayouts: parseFloat(summary.vendorPayouts) || 0, + commissionsByStatus, + }; + } + + async getOverdueCommissions(): Promise { + const today = new Date(); + + return this.commissionRepository.find({ + where: { + dueDate: Between(new Date('1900-01-01'), today), + status: In([CommissionStatus.CALCULATED, CommissionStatus.APPROVED]), + }, + relations: ['vendor', 'booking'], + order: { dueDate: 'ASC' }, + }); + } + + async bulkApproveCommissions(commissionIds: string[]): Promise { + const commissions = await this.commissionRepository.find({ + where: { + id: In(commissionIds), + status: CommissionStatus.CALCULATED, + }, + }); + + if (commissions.length !== commissionIds.length) { + throw new BadRequestException('Some commissions not found or not in calculated status'); + } + + const approvedCommissions = []; + + for (const commission of commissions) { + commission.status = CommissionStatus.APPROVED; + commission.approvedAt = new Date(); + + const updatedCommission = await this.commissionRepository.save(commission); + await this.createPaymentDistribution(updatedCommission); + + approvedCommissions.push(updatedCommission); + } + + return approvedCommissions; + } + + private async createPaymentDistribution(commission: Commission): Promise { + const vendor = await this.vendorRepository.findOne({ + where: { id: commission.vendorId }, + }); + + if (!vendor.paymentMethods) { + throw new BadRequestException('Vendor has no payment methods configured'); + } + + const distributionId = `DIST-${Date.now()}-${Math.floor(Math.random() * 1000)}`; + + // Determine payment method (prioritize in order: stripe, bank, paypal) + let paymentMethod: any; + if (vendor.paymentMethods.stripe) { + paymentMethod = { + type: 'stripe', + details: vendor.paymentMethods.stripe, + }; + } else if (vendor.paymentMethods.bankAccount) { + paymentMethod = { + type: 'bank_transfer', + details: vendor.paymentMethods.bankAccount, + }; + } else if (vendor.paymentMethods.paypal) { + paymentMethod = { + type: 'paypal', + details: vendor.paymentMethods.paypal, + }; + } else { + throw new BadRequestException('No valid payment method found for vendor'); + } + + const distribution = this.distributionRepository.create({ + commissionId: commission.id, + distributionId, + status: DistributionStatus.PENDING, + amount: commission.vendorPayout, + currency: commission.currency, + scheduledDate: commission.dueDate, + paymentMethod, + }); + + return this.distributionRepository.save(distribution); + } + + private async generateCommissionNumber(): Promise { + const prefix = 'COM'; + const timestamp = Date.now().toString().slice(-8); + const random = Math.floor(Math.random() * 1000).toString().padStart(3, '0'); + + let commissionNumber = `${prefix}${timestamp}${random}`; + + // Ensure uniqueness + while (await this.commissionRepository.findOne({ where: { commissionNumber } })) { + const newRandom = Math.floor(Math.random() * 1000).toString().padStart(3, '0'); + commissionNumber = `${prefix}${timestamp}${newRandom}`; + } + + return commissionNumber; + } + + async retryFailedDistribution(distributionId: string): Promise { + const distribution = await this.distributionRepository.findOne({ + where: { id: distributionId }, + relations: ['commission'], + }); + + if (!distribution) { + throw new NotFoundException('Payment distribution not found'); + } + + if (distribution.status !== DistributionStatus.FAILED) { + throw new BadRequestException('Only failed distributions can be retried'); + } + + distribution.status = DistributionStatus.PENDING; + distribution.retryCount += 1; + distribution.nextRetryAt = new Date(Date.now() + 60 * 60 * 1000); // Retry in 1 hour + distribution.failureReason = null; + + return this.distributionRepository.save(distribution); + } +} diff --git a/src/marketplace/services/marketplace.service.ts b/src/marketplace/services/marketplace.service.ts new file mode 100644 index 00000000..5d95e82f --- /dev/null +++ b/src/marketplace/services/marketplace.service.ts @@ -0,0 +1,399 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between, In } from 'typeorm'; +import { ServiceListing, ServiceStatus } from '../entities/service-listing.entity'; +import { ServiceCategory } from '../entities/service-category.entity'; +import { ServicePricing } from '../entities/service-pricing.entity'; +import { Vendor, VendorStatus } from '../entities/vendor.entity'; +import { CreateServiceDto } from '../dto/create-service.dto'; +import { UpdateServiceDto } from '../dto/update-service.dto'; +import { ServiceSearchDto } from '../dto/service-search.dto'; + +@Injectable() +export class MarketplaceService { + constructor( + @InjectRepository(ServiceListing) + private serviceRepository: Repository, + @InjectRepository(ServiceCategory) + private categoryRepository: Repository, + @InjectRepository(ServicePricing) + private pricingRepository: Repository, + @InjectRepository(Vendor) + private vendorRepository: Repository, + ) {} + + async createService(createServiceDto: CreateServiceDto): Promise { + const vendor = await this.vendorRepository.findOne({ + where: { id: createServiceDto.vendorId, status: VendorStatus.ACTIVE }, + }); + + if (!vendor) { + throw new NotFoundException('Active vendor not found'); + } + + const category = await this.categoryRepository.findOne({ + where: { id: createServiceDto.categoryId, isActive: true }, + }); + + if (!category) { + throw new NotFoundException('Active category not found'); + } + + // Generate unique slug + const baseSlug = createServiceDto.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); + + let slug = baseSlug; + let counter = 1; + + while (await this.serviceRepository.findOne({ where: { slug } })) { + slug = `${baseSlug}-${counter}`; + counter++; + } + + const service = this.serviceRepository.create({ + ...createServiceDto, + slug, + status: ServiceStatus.DRAFT, + }); + + const savedService = await this.serviceRepository.save(service); + + // Create pricing if provided + if (createServiceDto.pricing && createServiceDto.pricing.length > 0) { + const pricingEntities = createServiceDto.pricing.map(pricing => + this.pricingRepository.create({ + ...pricing, + serviceId: savedService.id, + }) + ); + await this.pricingRepository.save(pricingEntities); + } + + // Update category service count + await this.updateCategoryServiceCount(createServiceDto.categoryId); + + return this.findServiceById(savedService.id); + } + + async findServices(searchDto: ServiceSearchDto): Promise<{ + services: ServiceListing[]; + total: number; + page: number; + limit: number; + filters: any; + }> { + const { + page = 1, + limit = 20, + categoryId, + serviceType, + location, + priceRange, + rating, + availability, + search, + sortBy = 'createdAt', + sortOrder = 'DESC', + } = searchDto; + + const queryBuilder = this.serviceRepository + .createQueryBuilder('service') + .leftJoinAndSelect('service.vendor', 'vendor') + .leftJoinAndSelect('service.category', 'category') + .leftJoinAndSelect('service.pricing', 'pricing') + .where('service.status = :status', { status: ServiceStatus.ACTIVE }) + .andWhere('service.isActive = :isActive', { isActive: true }) + .andWhere('vendor.status = :vendorStatus', { vendorStatus: VendorStatus.ACTIVE }) + .andWhere('vendor.isActive = :vendorActive', { vendorActive: true }); + + // Apply filters + if (categoryId) { + queryBuilder.andWhere('service.categoryId = :categoryId', { categoryId }); + } + + if (serviceType) { + queryBuilder.andWhere('service.serviceType = :serviceType', { serviceType }); + } + + if (location) { + queryBuilder.andWhere( + 'JSON_CONTAINS(vendor.serviceAreas, :location)', + { location: JSON.stringify([location]) } + ); + } + + if (priceRange) { + queryBuilder.andWhere( + 'pricing.basePrice BETWEEN :minPrice AND :maxPrice', + { minPrice: priceRange.min, maxPrice: priceRange.max } + ); + } + + if (rating) { + queryBuilder.andWhere('service.averageRating >= :rating', { rating }); + } + + if (search) { + queryBuilder.andWhere( + '(service.title LIKE :search OR service.description LIKE :search OR vendor.businessName LIKE :search)', + { search: `%${search}%` } + ); + } + + // Apply sorting + const validSortFields = ['createdAt', 'averageRating', 'totalBookings', 'viewCount']; + const sortField = validSortFields.includes(sortBy) ? sortBy : 'createdAt'; + queryBuilder.orderBy(`service.${sortField}`, sortOrder); + + // Add secondary sorting + if (sortField !== 'createdAt') { + queryBuilder.addOrderBy('service.createdAt', 'DESC'); + } + + // Apply pagination + const offset = (page - 1) * limit; + queryBuilder.skip(offset).take(limit); + + const [services, total] = await queryBuilder.getManyAndCount(); + + return { + services, + total, + page, + limit, + filters: { + categoryId, + serviceType, + location, + priceRange, + rating, + search, + }, + }; + } + + async findServiceById(id: string): Promise { + const service = await this.serviceRepository.findOne({ + where: { id }, + relations: [ + 'vendor', + 'vendor.profile', + 'category', + 'pricing', + 'reviews', + 'bookings', + ], + }); + + if (!service) { + throw new NotFoundException('Service not found'); + } + + // Increment view count + await this.serviceRepository.increment({ id }, 'viewCount', 1); + + return service; + } + + async findServiceBySlug(slug: string): Promise { + const service = await this.serviceRepository.findOne({ + where: { slug }, + relations: [ + 'vendor', + 'vendor.profile', + 'category', + 'pricing', + 'reviews', + ], + }); + + if (!service) { + throw new NotFoundException('Service not found'); + } + + // Increment view count + await this.serviceRepository.increment({ id: service.id }, 'viewCount', 1); + + return service; + } + + async updateService(id: string, updateServiceDto: UpdateServiceDto): Promise { + const service = await this.findServiceById(id); + + // Update slug if title changed + if (updateServiceDto.title && updateServiceDto.title !== service.title) { + const baseSlug = updateServiceDto.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); + + let slug = baseSlug; + let counter = 1; + + while (await this.serviceRepository.findOne({ + where: { slug, id: Not(id) } + })) { + slug = `${baseSlug}-${counter}`; + counter++; + } + + updateServiceDto.slug = slug; + } + + Object.assign(service, updateServiceDto); + const updatedService = await this.serviceRepository.save(service); + + // Update pricing if provided + if (updateServiceDto.pricing) { + // Remove existing pricing + await this.pricingRepository.delete({ serviceId: id }); + + // Add new pricing + const pricingEntities = updateServiceDto.pricing.map(pricing => + this.pricingRepository.create({ + ...pricing, + serviceId: id, + }) + ); + await this.pricingRepository.save(pricingEntities); + } + + return this.findServiceById(updatedService.id); + } + + async updateServiceStatus(id: string, status: ServiceStatus): Promise { + const service = await this.findServiceById(id); + service.status = status; + return this.serviceRepository.save(service); + } + + async getFeaturedServices(limit: number = 10): Promise { + return this.serviceRepository.find({ + where: { + isFeatured: true, + isActive: true, + status: ServiceStatus.ACTIVE, + }, + relations: ['vendor', 'category', 'pricing'], + order: { averageRating: 'DESC', totalBookings: 'DESC' }, + take: limit, + }); + } + + async getPopularServices(limit: number = 10): Promise { + return this.serviceRepository.find({ + where: { + isActive: true, + status: ServiceStatus.ACTIVE, + }, + relations: ['vendor', 'category', 'pricing'], + order: { totalBookings: 'DESC', viewCount: 'DESC' }, + take: limit, + }); + } + + async getRecommendedServices( + userId: string, + limit: number = 10 + ): Promise { + // Simple recommendation based on user's booking history + // In a real implementation, this would use ML algorithms + const queryBuilder = this.serviceRepository + .createQueryBuilder('service') + .leftJoinAndSelect('service.vendor', 'vendor') + .leftJoinAndSelect('service.category', 'category') + .leftJoinAndSelect('service.pricing', 'pricing') + .leftJoin('service.bookings', 'booking') + .where('service.isActive = :isActive', { isActive: true }) + .andWhere('service.status = :status', { status: ServiceStatus.ACTIVE }) + .andWhere('vendor.isActive = :vendorActive', { vendorActive: true }) + .groupBy('service.id') + .orderBy('service.averageRating', 'DESC') + .addOrderBy('service.totalBookings', 'DESC') + .take(limit); + + return queryBuilder.getMany(); + } + + async getServicesByCategory(categoryId: string, limit: number = 20): Promise { + return this.serviceRepository.find({ + where: { + categoryId, + isActive: true, + status: ServiceStatus.ACTIVE, + }, + relations: ['vendor', 'pricing'], + order: { averageRating: 'DESC', totalBookings: 'DESC' }, + take: limit, + }); + } + + async getServicesByVendor(vendorId: string): Promise { + return this.serviceRepository.find({ + where: { vendorId }, + relations: ['category', 'pricing', 'reviews'], + order: { createdAt: 'DESC' }, + }); + } + + async updateServiceRating(serviceId: string): Promise { + const result = await this.serviceRepository + .createQueryBuilder('service') + .leftJoin('service.reviews', 'review') + .select('AVG(review.rating)', 'avgRating') + .addSelect('COUNT(review.id)', 'totalReviews') + .where('service.id = :serviceId', { serviceId }) + .andWhere('review.status = :status', { status: 'approved' }) + .getRawOne(); + + await this.serviceRepository.update(serviceId, { + averageRating: parseFloat(result.avgRating) || 0, + totalReviews: parseInt(result.totalReviews) || 0, + }); + } + + async updateServiceStats(serviceId: string): Promise { + const bookingCount = await this.serviceRepository + .createQueryBuilder('service') + .leftJoin('service.bookings', 'booking') + .select('COUNT(booking.id)', 'totalBookings') + .where('service.id = :serviceId', { serviceId }) + .andWhere('booking.status IN (:...statuses)', { + statuses: ['confirmed', 'completed'] + }) + .getRawOne(); + + await this.serviceRepository.update(serviceId, { + totalBookings: parseInt(bookingCount.totalBookings) || 0, + }); + } + + private async updateCategoryServiceCount(categoryId: string): Promise { + const count = await this.serviceRepository.count({ + where: { + categoryId, + isActive: true, + status: ServiceStatus.ACTIVE, + }, + }); + + await this.categoryRepository.update(categoryId, { + serviceCount: count, + }); + } + + async deleteService(id: string): Promise { + const service = await this.findServiceById(id); + + // Soft delete by setting status to archived + service.status = ServiceStatus.ARCHIVED; + service.isActive = false; + await this.serviceRepository.save(service); + + // Update category service count + await this.updateCategoryServiceCount(service.categoryId); + } +} diff --git a/src/marketplace/services/vendor.service.ts b/src/marketplace/services/vendor.service.ts new file mode 100644 index 00000000..5b633457 --- /dev/null +++ b/src/marketplace/services/vendor.service.ts @@ -0,0 +1,327 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, FindOptionsWhere } from 'typeorm'; +import { Vendor, VendorStatus, VendorTier } from '../entities/vendor.entity'; +import { VendorProfile } from '../entities/vendor-profile.entity'; +import { CreateVendorDto } from '../dto/create-vendor.dto'; +import { UpdateVendorDto } from '../dto/update-vendor.dto'; +import { VendorQueryDto } from '../dto/vendor-query.dto'; + +@Injectable() +export class VendorService { + constructor( + @InjectRepository(Vendor) + private vendorRepository: Repository, + @InjectRepository(VendorProfile) + private vendorProfileRepository: Repository, + ) {} + + async create(createVendorDto: CreateVendorDto): Promise { + // Check if user is already a vendor + const existingVendor = await this.vendorRepository.findOne({ + where: { userId: createVendorDto.userId }, + }); + + if (existingVendor) { + throw new BadRequestException('User is already registered as a vendor'); + } + + // Check business name uniqueness + const existingBusinessName = await this.vendorRepository.findOne({ + where: { businessName: createVendorDto.businessName }, + }); + + if (existingBusinessName) { + throw new BadRequestException('Business name already exists'); + } + + const vendor = this.vendorRepository.create({ + ...createVendorDto, + status: VendorStatus.PENDING, + tier: VendorTier.BASIC, + }); + + const savedVendor = await this.vendorRepository.save(vendor); + + // Create vendor profile if provided + if (createVendorDto.profile) { + const profile = this.vendorProfileRepository.create({ + vendorId: savedVendor.id, + ...createVendorDto.profile, + }); + await this.vendorProfileRepository.save(profile); + } + + return this.findOne(savedVendor.id); + } + + async findAll(query: VendorQueryDto): Promise<{ + vendors: Vendor[]; + total: number; + page: number; + limit: number; + }> { + const { + page = 1, + limit = 20, + status, + tier, + isVerified, + isFeatured, + serviceAreas, + search, + sortBy = 'createdAt', + sortOrder = 'DESC', + } = query; + + const queryBuilder = this.vendorRepository + .createQueryBuilder('vendor') + .leftJoinAndSelect('vendor.profile', 'profile') + .leftJoinAndSelect('vendor.user', 'user'); + + // Apply filters + if (status) { + queryBuilder.andWhere('vendor.status = :status', { status }); + } + + if (tier) { + queryBuilder.andWhere('vendor.tier = :tier', { tier }); + } + + if (typeof isVerified === 'boolean') { + queryBuilder.andWhere('vendor.isVerified = :isVerified', { isVerified }); + } + + if (typeof isFeatured === 'boolean') { + queryBuilder.andWhere('vendor.isFeatured = :isFeatured', { isFeatured }); + } + + if (serviceAreas && serviceAreas.length > 0) { + queryBuilder.andWhere( + 'JSON_OVERLAPS(vendor.serviceAreas, :serviceAreas)', + { serviceAreas: JSON.stringify(serviceAreas) }, + ); + } + + if (search) { + queryBuilder.andWhere( + '(vendor.businessName LIKE :search OR profile.description LIKE :search)', + { search: `%${search}%` }, + ); + } + + // Apply sorting + queryBuilder.orderBy(`vendor.${sortBy}`, sortOrder); + + // Apply pagination + const offset = (page - 1) * limit; + queryBuilder.skip(offset).take(limit); + + const [vendors, total] = await queryBuilder.getManyAndCount(); + + return { + vendors, + total, + page, + limit, + }; + } + + async findOne(id: string): Promise { + const vendor = await this.vendorRepository.findOne({ + where: { id }, + relations: [ + 'profile', + 'user', + 'services', + 'reviews', + 'bookings', + 'commissions', + ], + }); + + if (!vendor) { + throw new NotFoundException('Vendor not found'); + } + + return vendor; + } + + async findByUserId(userId: string): Promise { + return this.vendorRepository.findOne({ + where: { userId }, + relations: ['profile', 'user'], + }); + } + + async update(id: string, updateVendorDto: UpdateVendorDto): Promise { + const vendor = await this.findOne(id); + + // Check business name uniqueness if changed + if ( + updateVendorDto.businessName && + updateVendorDto.businessName !== vendor.businessName + ) { + const existingBusinessName = await this.vendorRepository.findOne({ + where: { businessName: updateVendorDto.businessName }, + }); + + if (existingBusinessName) { + throw new BadRequestException('Business name already exists'); + } + } + + Object.assign(vendor, updateVendorDto); + const updatedVendor = await this.vendorRepository.save(vendor); + + // Update profile if provided + if (updateVendorDto.profile) { + if (vendor.profile) { + Object.assign(vendor.profile, updateVendorDto.profile); + await this.vendorProfileRepository.save(vendor.profile); + } else { + const profile = this.vendorProfileRepository.create({ + vendorId: vendor.id, + ...updateVendorDto.profile, + }); + await this.vendorProfileRepository.save(profile); + } + } + + return this.findOne(updatedVendor.id); + } + + async updateStatus(id: string, status: VendorStatus): Promise { + const vendor = await this.findOne(id); + vendor.status = status; + + if (status === VendorStatus.ACTIVE) { + vendor.verifiedAt = new Date(); + vendor.isVerified = true; + } + + return this.vendorRepository.save(vendor); + } + + async updateTier(id: string, tier: VendorTier): Promise { + const vendor = await this.findOne(id); + vendor.tier = tier; + return this.vendorRepository.save(vendor); + } + + async updateRating(vendorId: string): Promise { + const result = await this.vendorRepository + .createQueryBuilder('vendor') + .leftJoin('vendor.reviews', 'review') + .select('AVG(review.rating)', 'avgRating') + .addSelect('COUNT(review.id)', 'totalReviews') + .where('vendor.id = :vendorId', { vendorId }) + .andWhere('review.status = :status', { status: 'approved' }) + .getRawOne(); + + await this.vendorRepository.update(vendorId, { + averageRating: parseFloat(result.avgRating) || 0, + totalReviews: parseInt(result.totalReviews) || 0, + }); + } + + async updateStats(vendorId: string): Promise { + const bookingStats = await this.vendorRepository + .createQueryBuilder('vendor') + .leftJoin('vendor.bookings', 'booking') + .select('COUNT(booking.id)', 'totalBookings') + .addSelect('SUM(booking.totalAmount)', 'totalRevenue') + .where('vendor.id = :vendorId', { vendorId }) + .andWhere('booking.status = :status', { status: 'completed' }) + .getRawOne(); + + await this.vendorRepository.update(vendorId, { + totalBookings: parseInt(bookingStats.totalBookings) || 0, + totalRevenue: parseFloat(bookingStats.totalRevenue) || 0, + lastActiveAt: new Date(), + }); + } + + async getFeaturedVendors(limit: number = 10): Promise { + return this.vendorRepository.find({ + where: { + isFeatured: true, + isActive: true, + status: VendorStatus.ACTIVE, + }, + relations: ['profile', 'services'], + order: { averageRating: 'DESC', totalReviews: 'DESC' }, + take: limit, + }); + } + + async getTopRatedVendors(limit: number = 10): Promise { + return this.vendorRepository.find({ + where: { + isActive: true, + status: VendorStatus.ACTIVE, + totalReviews: 5, // Minimum 5 reviews + }, + relations: ['profile', 'services'], + order: { averageRating: 'DESC', totalReviews: 'DESC' }, + take: limit, + }); + } + + async searchVendors( + searchTerm: string, + filters: { + serviceType?: string; + location?: string; + priceRange?: { min: number; max: number }; + rating?: number; + } = {}, + ): Promise { + const queryBuilder = this.vendorRepository + .createQueryBuilder('vendor') + .leftJoinAndSelect('vendor.profile', 'profile') + .leftJoinAndSelect('vendor.services', 'service') + .where('vendor.isActive = :isActive', { isActive: true }) + .andWhere('vendor.status = :status', { status: VendorStatus.ACTIVE }); + + if (searchTerm) { + queryBuilder.andWhere( + '(vendor.businessName LIKE :search OR profile.description LIKE :search OR service.title LIKE :search)', + { search: `%${searchTerm}%` }, + ); + } + + if (filters.serviceType) { + queryBuilder.andWhere('service.serviceType = :serviceType', { + serviceType: filters.serviceType, + }); + } + + if (filters.location) { + queryBuilder.andWhere( + 'JSON_CONTAINS(vendor.serviceAreas, :location)', + { location: JSON.stringify([filters.location]) }, + ); + } + + if (filters.rating) { + queryBuilder.andWhere('vendor.averageRating >= :rating', { + rating: filters.rating, + }); + } + + return queryBuilder + .orderBy('vendor.averageRating', 'DESC') + .addOrderBy('vendor.totalReviews', 'DESC') + .getMany(); + } + + async remove(id: string): Promise { + const vendor = await this.findOne(id); + + // Soft delete by setting status to archived + vendor.status = VendorStatus.REJECTED; + vendor.isActive = false; + await this.vendorRepository.save(vendor); + } +}