From 2beadb9e1f88b4c7f07cc0a11eb8265b4bfc54e2 Mon Sep 17 00:00:00 2001 From: LaGodxy Date: Fri, 29 Aug 2025 09:28:06 -0700 Subject: [PATCH 1/3] Marketplace System Implementation --- .../controllers/analytics.controller.ts | 71 +++ .../controllers/booking.controller.ts | 139 +++++ .../controllers/commission.controller.ts | 166 ++++++ .../controllers/marketplace.controller.ts | 159 ++++++ .../controllers/vendor.controller.ts | 158 ++++++ src/marketplace/dto/booking-query.dto.ts | 49 ++ src/marketplace/dto/create-booking.dto.ts | 117 ++++ src/marketplace/dto/create-service.dto.ts | 148 +++++ src/marketplace/dto/create-vendor.dto.ts | 124 +++++ src/marketplace/dto/service-search.dto.ts | 64 +++ src/marketplace/dto/update-booking.dto.ts | 4 + src/marketplace/dto/update-service.dto.ts | 4 + src/marketplace/dto/update-vendor.dto.ts | 4 + src/marketplace/dto/vendor-query.dto.ts | 53 ++ .../entities/booking-payment.entity.ts | 148 +++++ src/marketplace/entities/commission.entity.ts | 145 +++++ .../entities/payment-distribution.entity.ts | 83 +++ .../entities/service-booking.entity.ts | 232 ++++++++ .../entities/service-category.entity.ts | 77 +++ .../entities/service-listing.entity.ts | 186 +++++++ .../entities/service-pricing.entity.ts | 140 +++++ .../entities/vendor-profile.entity.ts | 119 ++++ .../entities/vendor-review.entity.ts | 124 +++++ src/marketplace/entities/vendor.entity.ts | 153 ++++++ src/marketplace/marketplace.module.ts | 67 +++ .../__tests__/booking.service.spec.ts | 259 +++++++++ .../services/__tests__/vendor.service.spec.ts | 252 +++++++++ src/marketplace/services/analytics.service.ts | 286 ++++++++++ src/marketplace/services/booking.service.ts | 518 ++++++++++++++++++ .../services/commission.service.ts | 405 ++++++++++++++ .../services/marketplace.service.ts | 399 ++++++++++++++ src/marketplace/services/vendor.service.ts | 327 +++++++++++ 32 files changed, 5180 insertions(+) create mode 100644 src/marketplace/controllers/analytics.controller.ts create mode 100644 src/marketplace/controllers/booking.controller.ts create mode 100644 src/marketplace/controllers/commission.controller.ts create mode 100644 src/marketplace/controllers/marketplace.controller.ts create mode 100644 src/marketplace/controllers/vendor.controller.ts create mode 100644 src/marketplace/dto/booking-query.dto.ts create mode 100644 src/marketplace/dto/create-booking.dto.ts create mode 100644 src/marketplace/dto/create-service.dto.ts create mode 100644 src/marketplace/dto/create-vendor.dto.ts create mode 100644 src/marketplace/dto/service-search.dto.ts create mode 100644 src/marketplace/dto/update-booking.dto.ts create mode 100644 src/marketplace/dto/update-service.dto.ts create mode 100644 src/marketplace/dto/update-vendor.dto.ts create mode 100644 src/marketplace/dto/vendor-query.dto.ts create mode 100644 src/marketplace/entities/booking-payment.entity.ts create mode 100644 src/marketplace/entities/commission.entity.ts create mode 100644 src/marketplace/entities/payment-distribution.entity.ts create mode 100644 src/marketplace/entities/service-booking.entity.ts create mode 100644 src/marketplace/entities/service-category.entity.ts create mode 100644 src/marketplace/entities/service-listing.entity.ts create mode 100644 src/marketplace/entities/service-pricing.entity.ts create mode 100644 src/marketplace/entities/vendor-profile.entity.ts create mode 100644 src/marketplace/entities/vendor-review.entity.ts create mode 100644 src/marketplace/entities/vendor.entity.ts create mode 100644 src/marketplace/marketplace.module.ts create mode 100644 src/marketplace/services/__tests__/booking.service.spec.ts create mode 100644 src/marketplace/services/__tests__/vendor.service.spec.ts create mode 100644 src/marketplace/services/analytics.service.ts create mode 100644 src/marketplace/services/booking.service.ts create mode 100644 src/marketplace/services/commission.service.ts create mode 100644 src/marketplace/services/marketplace.service.ts create mode 100644 src/marketplace/services/vendor.service.ts 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); + } +} From f38ab4597c78d3052ad98b26a1d1c9260bbfec9d Mon Sep 17 00:00:00 2001 From: LaGodxy Date: Fri, 29 Aug 2025 09:58:02 -0700 Subject: [PATCH 2/3] Email Marketing System --- .../controllers/analytics.controller.ts | 59 ++ .../controllers/automation.controller.ts | 141 +++++ .../controllers/campaign.controller.ts | 137 +++++ .../template-builder.controller.ts | 91 +++ .../controllers/template.controller.ts | 166 ++++++ .../dto/create-campaign.dto.ts | 83 +++ .../dto/create-template.dto.ts | 81 +++ src/email-marketing/dto/template-query.dto.ts | 52 ++ .../dto/update-template.dto.ts | 4 + src/email-marketing/email-marketing.module.ts | 83 +++ .../entities/ab-test.entity.ts | 153 +++++ .../entities/automation-action.entity.ts | 145 +++++ .../entities/automation-trigger.entity.ts | 122 ++++ .../entities/automation-workflow.entity.ts | 138 +++++ .../entities/campaign-segment.entity.ts | 54 ++ .../entities/email-bounce.entity.ts | 169 ++++++ .../entities/email-campaign.entity.ts | 235 ++++++++ .../entities/email-click.entity.ts | 74 +++ .../entities/email-delivery.entity.ts | 158 +++++ .../entities/email-open.entity.ts | 62 ++ .../entities/email-template.entity.ts | 169 ++++++ .../entities/segment-rule.entity.ts | 88 +++ .../entities/template-component.entity.ts | 131 +++++ .../entities/test-variant.entity.ts | 108 ++++ .../entities/user-segment.entity.ts | 152 +++++ .../__tests__/analytics.service.spec.ts | 365 ++++++++++++ .../__tests__/automation.service.spec.ts | 324 +++++++++++ .../__tests__/campaign.service.spec.ts | 285 +++++++++ .../template-builder.service.spec.ts | 401 +++++++++++++ .../__tests__/template.service.spec.ts | 284 +++++++++ .../services/analytics.service.ts | 547 ++++++++++++++++++ .../services/automation.service.ts | 451 +++++++++++++++ .../services/campaign.service.ts | 414 +++++++++++++ .../services/template-builder.service.ts | 459 +++++++++++++++ .../services/template.service.ts | 443 ++++++++++++++ 35 files changed, 6828 insertions(+) create mode 100644 src/email-marketing/controllers/analytics.controller.ts create mode 100644 src/email-marketing/controllers/automation.controller.ts create mode 100644 src/email-marketing/controllers/campaign.controller.ts create mode 100644 src/email-marketing/controllers/template-builder.controller.ts create mode 100644 src/email-marketing/controllers/template.controller.ts create mode 100644 src/email-marketing/dto/create-campaign.dto.ts create mode 100644 src/email-marketing/dto/create-template.dto.ts create mode 100644 src/email-marketing/dto/template-query.dto.ts create mode 100644 src/email-marketing/dto/update-template.dto.ts create mode 100644 src/email-marketing/email-marketing.module.ts create mode 100644 src/email-marketing/entities/ab-test.entity.ts create mode 100644 src/email-marketing/entities/automation-action.entity.ts create mode 100644 src/email-marketing/entities/automation-trigger.entity.ts create mode 100644 src/email-marketing/entities/automation-workflow.entity.ts create mode 100644 src/email-marketing/entities/campaign-segment.entity.ts create mode 100644 src/email-marketing/entities/email-bounce.entity.ts create mode 100644 src/email-marketing/entities/email-campaign.entity.ts create mode 100644 src/email-marketing/entities/email-click.entity.ts create mode 100644 src/email-marketing/entities/email-delivery.entity.ts create mode 100644 src/email-marketing/entities/email-open.entity.ts create mode 100644 src/email-marketing/entities/email-template.entity.ts create mode 100644 src/email-marketing/entities/segment-rule.entity.ts create mode 100644 src/email-marketing/entities/template-component.entity.ts create mode 100644 src/email-marketing/entities/test-variant.entity.ts create mode 100644 src/email-marketing/entities/user-segment.entity.ts create mode 100644 src/email-marketing/services/__tests__/analytics.service.spec.ts create mode 100644 src/email-marketing/services/__tests__/automation.service.spec.ts create mode 100644 src/email-marketing/services/__tests__/campaign.service.spec.ts create mode 100644 src/email-marketing/services/__tests__/template-builder.service.spec.ts create mode 100644 src/email-marketing/services/__tests__/template.service.spec.ts create mode 100644 src/email-marketing/services/analytics.service.ts create mode 100644 src/email-marketing/services/automation.service.ts create mode 100644 src/email-marketing/services/campaign.service.ts create mode 100644 src/email-marketing/services/template-builder.service.ts create mode 100644 src/email-marketing/services/template.service.ts diff --git a/src/email-marketing/controllers/analytics.controller.ts b/src/email-marketing/controllers/analytics.controller.ts new file mode 100644 index 00000000..b7673456 --- /dev/null +++ b/src/email-marketing/controllers/analytics.controller.ts @@ -0,0 +1,59 @@ +import { + Controller, + Get, + Param, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { AnalyticsService } from '../services/analytics.service'; + +@ApiTags('Email Analytics') +@Controller('email-marketing/analytics') +@UseGuards(JwtAuthGuard, RolesGuard) +@ApiBearerAuth() +export class AnalyticsController { + constructor(private readonly analyticsService: AnalyticsService) {} + + @Get('dashboard') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Get email marketing dashboard metrics' }) + @ApiResponse({ status: 200, description: 'Dashboard metrics retrieved successfully' }) + async getDashboardMetrics( + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + ) { + const dateRange = { + startDate: startDate ? new Date(startDate) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + endDate: endDate ? new Date(endDate) : new Date(), + }; + return this.analyticsService.getDashboardMetrics(dateRange); + } + + @Get('campaigns/:id') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Get detailed campaign analytics' }) + @ApiResponse({ status: 200, description: 'Campaign analytics retrieved successfully' }) + async getCampaignAnalytics(@Param('id') campaignId: string) { + return this.analyticsService.getCampaignAnalytics(campaignId); + } + + @Get('automation/:id') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Get automation workflow analytics' }) + @ApiResponse({ status: 200, description: 'Automation analytics retrieved successfully' }) + async getAutomationAnalytics(@Param('id') workflowId: string) { + return this.analyticsService.getAutomationAnalytics(workflowId); + } + + @Get('segments/:id') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Get segment analytics' }) + @ApiResponse({ status: 200, description: 'Segment analytics retrieved successfully' }) + async getSegmentAnalytics(@Param('id') segmentId: string) { + return this.analyticsService.getSegmentAnalytics(segmentId); + } +} diff --git a/src/email-marketing/controllers/automation.controller.ts b/src/email-marketing/controllers/automation.controller.ts new file mode 100644 index 00000000..a4958832 --- /dev/null +++ b/src/email-marketing/controllers/automation.controller.ts @@ -0,0 +1,141 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { AutomationService } from '../services/automation.service'; +import { WorkflowType, WorkflowStatus } from '../entities/automation-workflow.entity'; +import { TriggerType } from '../entities/automation-trigger.entity'; +import { ActionType } from '../entities/automation-action.entity'; + +@ApiTags('Email Automation') +@Controller('email-marketing/automation') +@UseGuards(JwtAuthGuard, RolesGuard) +@ApiBearerAuth() +export class AutomationController { + constructor(private readonly automationService: AutomationService) {} + + @Post('workflows') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Create a new automation workflow' }) + @ApiResponse({ status: 201, description: 'Workflow created successfully' }) + async createWorkflow(@Body() workflowData: { + name: string; + description?: string; + workflowType: WorkflowType; + goalType?: string; + goalValue?: number; + settings?: Record; + createdBy?: string; + }) { + return this.automationService.createWorkflow(workflowData); + } + + @Post('workflows/:id/triggers') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Add trigger to workflow' }) + @ApiResponse({ status: 201, description: 'Trigger added successfully' }) + async addTrigger( + @Param('id') workflowId: string, + @Body() triggerData: { + triggerType: TriggerType; + eventName: string; + conditions?: Record; + filters?: Record; + delay?: number; + delayUnit?: string; + }, + ) { + return this.automationService.addTrigger(workflowId, triggerData); + } + + @Post('workflows/:id/actions') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Add action to workflow' }) + @ApiResponse({ status: 201, description: 'Action added successfully' }) + async addAction( + @Param('id') workflowId: string, + @Body() actionData: { + actionType: ActionType; + sortOrder: number; + configuration: Record; + conditions?: Record; + delay?: number; + delayUnit?: string; + }, + ) { + return this.automationService.addAction(workflowId, actionData); + } + + @Post('workflows/:id/activate') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Activate workflow' }) + @ApiResponse({ status: 200, description: 'Workflow activated successfully' }) + async activateWorkflow(@Param('id') workflowId: string) { + return this.automationService.activateWorkflow(workflowId); + } + + @Post('workflows/:id/pause') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Pause workflow' }) + @ApiResponse({ status: 200, description: 'Workflow paused successfully' }) + async pauseWorkflow(@Param('id') workflowId: string) { + return this.automationService.pauseWorkflow(workflowId); + } + + @Post('workflows/:id/resume') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Resume workflow' }) + @ApiResponse({ status: 200, description: 'Workflow resumed successfully' }) + async resumeWorkflow(@Param('id') workflowId: string) { + return this.automationService.resumeWorkflow(workflowId); + } + + @Get('workflows/:id') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Get workflow by ID' }) + @ApiResponse({ status: 200, description: 'Workflow retrieved successfully' }) + async getWorkflow(@Param('id') workflowId: string) { + return this.automationService.findWorkflow(workflowId); + } + + @Get('workflows/:id/stats') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Get workflow statistics' }) + @ApiResponse({ status: 200, description: 'Workflow statistics retrieved successfully' }) + async getWorkflowStats(@Param('id') workflowId: string) { + return this.automationService.getWorkflowStats(workflowId); + } + + @Post('events') + @Roles('admin', 'organizer', 'system') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Process automation event' }) + @ApiResponse({ status: 204, description: 'Event processed successfully' }) + async processEvent(@Body() eventData: { + eventName: string; + data: Record; + }) { + await this.automationService.processEvent(eventData.eventName, eventData.data); + } + + @Delete('workflows/:id') + @Roles('admin') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Delete workflow' }) + @ApiResponse({ status: 204, description: 'Workflow deleted successfully' }) + async deleteWorkflow(@Param('id') workflowId: string) { + await this.automationService.deleteWorkflow(workflowId); + } +} diff --git a/src/email-marketing/controllers/campaign.controller.ts b/src/email-marketing/controllers/campaign.controller.ts new file mode 100644 index 00000000..d2bb4801 --- /dev/null +++ b/src/email-marketing/controllers/campaign.controller.ts @@ -0,0 +1,137 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { CampaignService } from '../services/campaign.service'; +import { CreateCampaignDto } from '../dto/create-campaign.dto'; +import { CampaignStatus, CampaignType } from '../entities/email-campaign.entity'; + +@ApiTags('Email Campaigns') +@Controller('email-marketing/campaigns') +@UseGuards(JwtAuthGuard, RolesGuard) +@ApiBearerAuth() +export class CampaignController { + constructor(private readonly campaignService: CampaignService) {} + + @Post() + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Create a new email campaign' }) + @ApiResponse({ status: 201, description: 'Campaign created successfully' }) + async create(@Body() createCampaignDto: CreateCampaignDto) { + return this.campaignService.create(createCampaignDto); + } + + @Get() + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Get all campaigns with filtering and pagination' }) + @ApiResponse({ status: 200, description: 'Campaigns retrieved successfully' }) + async findAll( + @Query('page') page?: number, + @Query('limit') limit?: number, + @Query('status') status?: CampaignStatus, + @Query('type') campaignType?: CampaignType, + @Query('createdBy') createdBy?: string, + @Query('search') search?: string, + @Query('tags') tags?: string, + ) { + const options = { + page, + limit, + status, + campaignType, + createdBy, + search, + tags: tags ? tags.split(',') : undefined, + }; + return this.campaignService.findAll(options); + } + + @Get(':id') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Get campaign by ID' }) + @ApiResponse({ status: 200, description: 'Campaign retrieved successfully' }) + async findOne(@Param('id') id: string) { + return this.campaignService.findOne(id); + } + + @Patch(':id/status') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Update campaign status' }) + @ApiResponse({ status: 200, description: 'Campaign status updated successfully' }) + async updateStatus(@Param('id') id: string, @Body('status') status: CampaignStatus) { + return this.campaignService.updateStatus(id, status); + } + + @Post(':id/schedule') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Schedule campaign for future sending' }) + @ApiResponse({ status: 200, description: 'Campaign scheduled successfully' }) + async scheduleCampaign( + @Param('id') id: string, + @Body('scheduledAt') scheduledAt: string, + ) { + return this.campaignService.scheduleCampaign(id, new Date(scheduledAt)); + } + + @Post(':id/send') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Send campaign immediately' }) + @ApiResponse({ status: 200, description: 'Campaign sent successfully' }) + async sendCampaign(@Param('id') id: string) { + return this.campaignService.sendCampaign(id); + } + + @Post(':id/pause') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Pause sending campaign' }) + @ApiResponse({ status: 200, description: 'Campaign paused successfully' }) + async pauseCampaign(@Param('id') id: string) { + return this.campaignService.pauseCampaign(id); + } + + @Post(':id/resume') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Resume paused campaign' }) + @ApiResponse({ status: 200, description: 'Campaign resumed successfully' }) + async resumeCampaign(@Param('id') id: string) { + return this.campaignService.resumeCampaign(id); + } + + @Get(':id/stats') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Get campaign statistics' }) + @ApiResponse({ status: 200, description: 'Campaign statistics retrieved successfully' }) + async getCampaignStats(@Param('id') id: string) { + return this.campaignService.getCampaignStats(id); + } + + @Post(':id/duplicate') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Duplicate campaign' }) + @ApiResponse({ status: 201, description: 'Campaign duplicated successfully' }) + async duplicateCampaign(@Param('id') id: string, @Body('name') name: string) { + return this.campaignService.duplicateCampaign(id, name); + } + + @Delete(':id') + @Roles('admin') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Archive campaign' }) + @ApiResponse({ status: 204, description: 'Campaign archived successfully' }) + async remove(@Param('id') id: string) { + await this.campaignService.remove(id); + } +} diff --git a/src/email-marketing/controllers/template-builder.controller.ts b/src/email-marketing/controllers/template-builder.controller.ts new file mode 100644 index 00000000..b72cb6ed --- /dev/null +++ b/src/email-marketing/controllers/template-builder.controller.ts @@ -0,0 +1,91 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { TemplateBuilderService, TemplateLayout, DragDropComponent } from '../services/template-builder.service'; + +@ApiTags('Template Builder') +@Controller('email-marketing/template-builder') +@UseGuards(JwtAuthGuard, RolesGuard) +@ApiBearerAuth() +export class TemplateBuilderController { + constructor(private readonly templateBuilderService: TemplateBuilderService) {} + + @Get('components') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Get component library for drag-and-drop builder' }) + @ApiResponse({ status: 200, description: 'Component library retrieved successfully' }) + async getComponentLibrary() { + return this.templateBuilderService.getComponentLibrary(); + } + + @Get(':templateId/layout') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Get template layout for builder' }) + @ApiResponse({ status: 200, description: 'Template layout retrieved successfully' }) + async getTemplateLayout(@Param('templateId') templateId: string) { + return this.templateBuilderService.getTemplateLayout(templateId); + } + + @Post(':templateId/layout') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Save template layout from builder' }) + @ApiResponse({ status: 200, description: 'Template layout saved successfully' }) + async saveTemplateLayout( + @Param('templateId') templateId: string, + @Body() layout: TemplateLayout, + ) { + return this.templateBuilderService.saveTemplateLayout(templateId, layout); + } + + @Post(':templateId/components') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Add component to template' }) + @ApiResponse({ status: 201, description: 'Component added successfully' }) + async addComponent( + @Param('templateId') templateId: string, + @Body() component: Omit, + ) { + return this.templateBuilderService.addComponent(templateId, component); + } + + @Patch('components/:componentId') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Update component properties' }) + @ApiResponse({ status: 200, description: 'Component updated successfully' }) + async updateComponent( + @Param('componentId') componentId: string, + @Body() updates: Partial, + ) { + return this.templateBuilderService.updateComponent(componentId, updates); + } + + @Post('components/:componentId/duplicate') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Duplicate component' }) + @ApiResponse({ status: 201, description: 'Component duplicated successfully' }) + async duplicateComponent(@Param('componentId') componentId: string) { + return this.templateBuilderService.duplicateComponent(componentId); + } + + @Delete('components/:componentId') + @Roles('admin', 'organizer') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Delete component' }) + @ApiResponse({ status: 204, description: 'Component deleted successfully' }) + async deleteComponent(@Param('componentId') componentId: string) { + await this.templateBuilderService.deleteComponent(componentId); + } +} diff --git a/src/email-marketing/controllers/template.controller.ts b/src/email-marketing/controllers/template.controller.ts new file mode 100644 index 00000000..eb1e5381 --- /dev/null +++ b/src/email-marketing/controllers/template.controller.ts @@ -0,0 +1,166 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { TemplateService } from '../services/template.service'; +import { CreateTemplateDto } from '../dto/create-template.dto'; +import { UpdateTemplateDto } from '../dto/update-template.dto'; +import { TemplateQueryDto } from '../dto/template-query.dto'; +import { TemplateStatus, TemplateType } from '../entities/email-template.entity'; + +@ApiTags('Email Templates') +@Controller('email-marketing/templates') +@UseGuards(JwtAuthGuard, RolesGuard) +@ApiBearerAuth() +export class TemplateController { + constructor(private readonly templateService: TemplateService) {} + + @Post() + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Create a new email template' }) + @ApiResponse({ status: 201, description: 'Template created successfully' }) + async create(@Body() createTemplateDto: CreateTemplateDto) { + return this.templateService.create(createTemplateDto); + } + + @Get() + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Get all email templates with filtering and pagination' }) + @ApiResponse({ status: 200, description: 'Templates retrieved successfully' }) + async findAll(@Query() query: TemplateQueryDto) { + return this.templateService.findAll(query); + } + + @Get('system') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Get system templates' }) + @ApiResponse({ status: 200, description: 'System templates retrieved successfully' }) + async getSystemTemplates() { + return this.templateService.getSystemTemplates(); + } + + @Get('popular') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Get popular templates' }) + @ApiResponse({ status: 200, description: 'Popular templates retrieved successfully' }) + async getPopularTemplates(@Query('limit') limit?: number) { + return this.templateService.getPopularTemplates(limit); + } + + @Get('by-type/:type') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Get templates by type' }) + @ApiResponse({ status: 200, description: 'Templates retrieved successfully' }) + async getTemplatesByType(@Param('type') type: TemplateType) { + return this.templateService.getTemplatesByType(type); + } + + @Get('search') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Search templates' }) + @ApiResponse({ status: 200, description: 'Search results retrieved successfully' }) + async searchTemplates( + @Query('q') searchTerm: string, + @Query('type') templateType?: TemplateType, + @Query('category') category?: string, + @Query('tags') tags?: string, + ) { + const filters = { + templateType, + category, + tags: tags ? tags.split(',') : undefined, + }; + return this.templateService.searchTemplates(searchTerm, filters); + } + + @Get(':id') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Get template by ID' }) + @ApiResponse({ status: 200, description: 'Template retrieved successfully' }) + async findOne(@Param('id') id: string) { + return this.templateService.findOne(id); + } + + @Get('slug/:slug') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Get template by slug' }) + @ApiResponse({ status: 200, description: 'Template retrieved successfully' }) + async findBySlug(@Param('slug') slug: string) { + return this.templateService.findBySlug(slug); + } + + @Patch(':id') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Update template' }) + @ApiResponse({ status: 200, description: 'Template updated successfully' }) + async update(@Param('id') id: string, @Body() updateTemplateDto: UpdateTemplateDto) { + return this.templateService.update(id, updateTemplateDto); + } + + @Post(':id/duplicate') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Duplicate template' }) + @ApiResponse({ status: 201, description: 'Template duplicated successfully' }) + async duplicate(@Param('id') id: string, @Body('name') name: string) { + return this.templateService.duplicate(id, name); + } + + @Patch(':id/status') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Update template status' }) + @ApiResponse({ status: 200, description: 'Template status updated successfully' }) + async updateStatus(@Param('id') id: string, @Body('status') status: TemplateStatus) { + return this.templateService.updateStatus(id, status); + } + + @Post(':id/preview') + @Roles('admin', 'organizer') + @ApiOperation({ summary: 'Generate template preview with variables' }) + @ApiResponse({ status: 200, description: 'Preview generated successfully' }) + async generatePreview( + @Param('id') id: string, + @Body('variables') variables: Record = {}, + ) { + return this.templateService.generatePreview(id, variables); + } + + @Post(':id/usage') + @Roles('admin', 'organizer') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Increment template usage count' }) + @ApiResponse({ status: 204, description: 'Usage count incremented' }) + async incrementUsage(@Param('id') id: string) { + await this.templateService.incrementUsage(id); + } + + @Post(':id/rating') + @Roles('admin', 'organizer') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Add rating to template' }) + @ApiResponse({ status: 204, description: 'Rating added successfully' }) + async addRating(@Param('id') id: string, @Body('rating') rating: number) { + await this.templateService.updateRating(id, rating); + } + + @Delete(':id') + @Roles('admin') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Archive template' }) + @ApiResponse({ status: 204, description: 'Template archived successfully' }) + async remove(@Param('id') id: string) { + await this.templateService.remove(id); + } +} diff --git a/src/email-marketing/dto/create-campaign.dto.ts b/src/email-marketing/dto/create-campaign.dto.ts new file mode 100644 index 00000000..82f31df0 --- /dev/null +++ b/src/email-marketing/dto/create-campaign.dto.ts @@ -0,0 +1,83 @@ +import { IsString, IsOptional, IsEnum, IsArray, IsObject, IsBoolean, IsDateString, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { CampaignType } from '../entities/email-campaign.entity'; + +export class CreateCampaignSegmentDto { + @IsString() + segmentId: string; + + @IsOptional() + @IsBoolean() + isIncluded?: boolean = true; +} + +export class CreateCampaignDto { + @IsString() + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsEnum(CampaignType) + campaignType: CampaignType; + + @IsString() + templateId: string; + + @IsString() + subject: string; + + @IsOptional() + @IsString() + preheaderText?: string; + + @IsOptional() + @IsString() + senderName?: string; + + @IsOptional() + @IsString() + senderEmail?: string; + + @IsOptional() + @IsString() + replyToEmail?: string; + + @IsOptional() + @IsDateString() + scheduledAt?: Date; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateCampaignSegmentDto) + segments?: CreateCampaignSegmentDto[]; + + @IsOptional() + @IsObject() + personalizationData?: Record; + + @IsOptional() + @IsObject() + trackingSettings?: { + trackOpens?: boolean; + trackClicks?: boolean; + trackUnsubscribes?: boolean; + googleAnalytics?: boolean; + customDomain?: string; + }; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsString() + createdBy?: string; + + @IsOptional() + @IsString() + abTestId?: string; +} diff --git a/src/email-marketing/dto/create-template.dto.ts b/src/email-marketing/dto/create-template.dto.ts new file mode 100644 index 00000000..877f46d0 --- /dev/null +++ b/src/email-marketing/dto/create-template.dto.ts @@ -0,0 +1,81 @@ +import { IsString, IsOptional, IsEnum, IsArray, IsObject, IsBoolean, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { TemplateType } from '../entities/email-template.entity'; +import { ComponentType } from '../entities/template-component.entity'; + +export class CreateTemplateComponentDto { + @IsEnum(ComponentType) + componentType: ComponentType; + + @IsString() + name: string; + + @IsObject() + properties: Record; + + @IsOptional() + @IsObject() + conditions?: Record; + + @IsOptional() + @IsBoolean() + isVisible?: boolean; +} + +export class CreateTemplateDto { + @IsString() + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsEnum(TemplateType) + templateType: TemplateType; + + @IsOptional() + @IsString() + category?: string; + + @IsString() + subject: string; + + @IsOptional() + @IsString() + preheaderText?: string; + + @IsOptional() + @IsString() + textContent?: string; + + @IsObject() + designData: Record; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsArray() + variables?: Array<{ + name: string; + type: string; + defaultValue?: any; + description?: string; + }>; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateTemplateComponentDto) + components?: CreateTemplateComponentDto[]; + + @IsOptional() + @IsString() + createdBy?: string; + + @IsOptional() + @IsBoolean() + isSystem?: boolean; +} diff --git a/src/email-marketing/dto/template-query.dto.ts b/src/email-marketing/dto/template-query.dto.ts new file mode 100644 index 00000000..a2aad6e0 --- /dev/null +++ b/src/email-marketing/dto/template-query.dto.ts @@ -0,0 +1,52 @@ +import { IsOptional, IsString, IsEnum, IsArray, IsInt, Min, Max } from 'class-validator'; +import { Type, Transform } from 'class-transformer'; +import { TemplateType, TemplateStatus } from '../entities/email-template.entity'; + +export class TemplateQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; + + @IsOptional() + @IsEnum(TemplateType) + templateType?: TemplateType; + + @IsOptional() + @IsString() + category?: string; + + @IsOptional() + @IsEnum(TemplateStatus) + status?: TemplateStatus; + + @IsOptional() + @IsString() + createdBy?: string; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + @Transform(({ value }) => Array.isArray(value) ? value : [value]) + tags?: string[]; + + @IsOptional() + @IsString() + sortBy?: string = 'createdAt'; + + @IsOptional() + @IsEnum(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC' = 'DESC'; +} diff --git a/src/email-marketing/dto/update-template.dto.ts b/src/email-marketing/dto/update-template.dto.ts new file mode 100644 index 00000000..9c2be2c3 --- /dev/null +++ b/src/email-marketing/dto/update-template.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateTemplateDto } from './create-template.dto'; + +export class UpdateTemplateDto extends PartialType(CreateTemplateDto) {} diff --git a/src/email-marketing/email-marketing.module.ts b/src/email-marketing/email-marketing.module.ts new file mode 100644 index 00000000..6142a84d --- /dev/null +++ b/src/email-marketing/email-marketing.module.ts @@ -0,0 +1,83 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +// Entities +import { EmailTemplate } from './entities/email-template.entity'; +import { TemplateComponent } from './entities/template-component.entity'; +import { EmailCampaign } from './entities/email-campaign.entity'; +import { CampaignSegment } from './entities/campaign-segment.entity'; +import { UserSegment } from './entities/user-segment.entity'; +import { SegmentRule } from './entities/segment-rule.entity'; +import { AutomationWorkflow } from './entities/automation-workflow.entity'; +import { AutomationTrigger } from './entities/automation-trigger.entity'; +import { AutomationAction } from './entities/automation-action.entity'; +import { ABTest } from './entities/ab-test.entity'; +import { TestVariant } from './entities/test-variant.entity'; +import { EmailDelivery } from './entities/email-delivery.entity'; +import { EmailOpen } from './entities/email-open.entity'; +import { EmailClick } from './entities/email-click.entity'; +import { EmailBounce } from './entities/email-bounce.entity'; + +// Services +import { TemplateService } from './services/template.service'; +import { CampaignService } from './services/campaign.service'; +import { AutomationService } from './services/automation.service'; +import { AnalyticsService } from './services/analytics.service'; + +// Controllers +import { TemplateController } from './controllers/template.controller'; +import { CampaignController } from './controllers/campaign.controller'; +import { AutomationController } from './controllers/automation.controller'; +import { AnalyticsController } from './controllers/analytics.controller'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + // Template entities + EmailTemplate, + TemplateComponent, + + // Campaign entities + EmailCampaign, + CampaignSegment, + + // Segmentation entities + UserSegment, + SegmentRule, + + // Automation entities + AutomationWorkflow, + AutomationTrigger, + AutomationAction, + + // A/B Testing entities + ABTest, + TestVariant, + + // Email tracking entities + EmailDelivery, + EmailOpen, + EmailClick, + EmailBounce, + ]), + ], + controllers: [ + TemplateController, + CampaignController, + AutomationController, + AnalyticsController, + ], + providers: [ + TemplateService, + CampaignService, + AutomationService, + AnalyticsService, + ], + exports: [ + TemplateService, + CampaignService, + AutomationService, + AnalyticsService, + ], +}) +export class EmailMarketingModule {} diff --git a/src/email-marketing/entities/ab-test.entity.ts b/src/email-marketing/entities/ab-test.entity.ts new file mode 100644 index 00000000..67c8c9c9 --- /dev/null +++ b/src/email-marketing/entities/ab-test.entity.ts @@ -0,0 +1,153 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { EmailCampaign } from './email-campaign.entity'; +import { TestVariant } from './test-variant.entity'; + +export enum TestStatus { + DRAFT = 'draft', + RUNNING = 'running', + COMPLETED = 'completed', + PAUSED = 'paused', + CANCELLED = 'cancelled', +} + +export enum TestType { + SUBJECT_LINE = 'subject_line', + FROM_NAME = 'from_name', + CONTENT = 'content', + SEND_TIME = 'send_time', + TEMPLATE = 'template', + CALL_TO_ACTION = 'call_to_action', + MULTIVARIATE = 'multivariate', +} + +export enum WinningCriteria { + OPEN_RATE = 'open_rate', + CLICK_RATE = 'click_rate', + CONVERSION_RATE = 'conversion_rate', + REVENUE = 'revenue', + UNSUBSCRIBE_RATE = 'unsubscribe_rate', + CUSTOM = 'custom', +} + +@Entity('ab_tests') +@Index(['campaignId', 'status']) +@Index(['testType', 'status']) +export class ABTest { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + campaignId: string; + + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ + type: 'enum', + enum: TestType, + }) + testType: TestType; + + @Column({ + type: 'enum', + enum: TestStatus, + default: TestStatus.DRAFT, + }) + status: TestStatus; + + @Column({ + type: 'enum', + enum: WinningCriteria, + }) + winningCriteria: WinningCriteria; + + @Column({ type: 'int', default: 50 }) + testPercentage: number; // Percentage of audience to include in test + + @Column({ type: 'int', default: 24 }) + testDurationHours: number; + + @Column({ type: 'decimal', precision: 5, scale: 2, default: 95 }) + confidenceLevel: number; + + @Column({ type: 'timestamp', nullable: true }) + startedAt: Date; + + @Column({ type: 'timestamp', nullable: true }) + completedAt: Date; + + @Column({ type: 'uuid', nullable: true }) + winningVariantId: string; + + @Column({ type: 'boolean', default: false }) + isStatisticallySignificant: boolean; + + @Column({ type: 'decimal', precision: 8, scale: 4, nullable: true }) + pValue: number; + + @Column({ type: 'json', nullable: true }) + results: { + totalParticipants: number; + variantResults: { + variantId: string; + participants: number; + opens: number; + clicks: number; + conversions: number; + revenue: number; + unsubscribes: number; + openRate: number; + clickRate: number; + conversionRate: number; + unsubscribeRate: number; + }[]; + winnerLift: number; + confidenceInterval: { + lower: number; + upper: number; + }; + }; + + @Column({ type: 'json', nullable: true }) + settings: { + autoSelectWinner?: boolean; + minimumSampleSize?: number; + maximumDuration?: { + value: number; + unit: 'hours' | 'days'; + }; + earlyStoppingRules?: { + enabled: boolean; + minimumRunTime: number; + significanceThreshold: number; + }; + }; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @ManyToOne(() => EmailCampaign, (campaign) => campaign.abTests) + @JoinColumn({ name: 'campaignId' }) + campaign: EmailCampaign; + + @OneToMany(() => TestVariant, (variant) => variant.test) + variants: TestVariant[]; +} diff --git a/src/email-marketing/entities/automation-action.entity.ts b/src/email-marketing/entities/automation-action.entity.ts new file mode 100644 index 00000000..f35e192c --- /dev/null +++ b/src/email-marketing/entities/automation-action.entity.ts @@ -0,0 +1,145 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { AutomationWorkflow } from './automation-workflow.entity'; +import { EmailTemplate } from './email-template.entity'; + +export enum ActionType { + SEND_EMAIL = 'send_email', + WAIT = 'wait', + ADD_TO_SEGMENT = 'add_to_segment', + REMOVE_FROM_SEGMENT = 'remove_from_segment', + UPDATE_USER_PROPERTY = 'update_user_property', + SEND_WEBHOOK = 'send_webhook', + CREATE_TASK = 'create_task', + CONDITIONAL_SPLIT = 'conditional_split', + GOAL_CHECK = 'goal_check', + END_WORKFLOW = 'end_workflow', +} + +@Entity('automation_actions') +@Index(['workflowId', 'sortOrder']) +export class AutomationAction { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + workflowId: string; + + @Column({ + type: 'enum', + enum: ActionType, + }) + actionType: ActionType; + + @Column({ type: 'varchar', length: 100, nullable: true }) + name: string; + + @Column({ type: 'int', default: 0 }) + sortOrder: number; + + @Column({ type: 'json' }) + configuration: { + // Email action + templateId?: string; + subject?: string; + fromName?: string; + fromEmail?: string; + personalization?: Record; + + // Wait action + delay?: { + value: number; + unit: 'minutes' | 'hours' | 'days' | 'weeks'; + }; + waitUntil?: { + type: 'specific_time' | 'day_of_week' | 'optimal_time'; + time?: string; + dayOfWeek?: number; + }; + + // Segment actions + segmentId?: string; + + // Property update + propertyUpdates?: { + field: string; + value: any; + operation: 'set' | 'increment' | 'decrement' | 'append'; + }[]; + + // Webhook action + webhookUrl?: string; + webhookMethod?: 'GET' | 'POST' | 'PUT'; + webhookHeaders?: Record; + webhookPayload?: Record; + + // Conditional split + conditions?: { + field: string; + operator: 'equals' | 'not_equals' | 'contains' | 'greater_than' | 'less_than'; + value: any; + }[]; + trueActionId?: string; + falseActionId?: string; + + // Goal check + goalType?: string; + goalValue?: any; + + // Custom configuration + customConfig?: Record; + }; + + @Column({ type: 'json', nullable: true }) + conditions: { + executeIf?: { + field: string; + operator: string; + value: any; + }[]; + skipIf?: { + field: string; + operator: string; + value: any; + }[]; + }; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; + + @Column({ type: 'int', default: 0 }) + executionCount: number; + + @Column({ type: 'int', default: 0 }) + successCount: number; + + @Column({ type: 'int', default: 0 }) + failureCount: number; + + @Column({ type: 'timestamp', nullable: true }) + lastExecutedAt: Date; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @ManyToOne(() => AutomationWorkflow, (workflow) => workflow.actions) + @JoinColumn({ name: 'workflowId' }) + workflow: AutomationWorkflow; + + @ManyToOne(() => EmailTemplate, { nullable: true }) + @JoinColumn({ name: 'templateId' }) + template: EmailTemplate; +} diff --git a/src/email-marketing/entities/automation-trigger.entity.ts b/src/email-marketing/entities/automation-trigger.entity.ts new file mode 100644 index 00000000..b0646b3b --- /dev/null +++ b/src/email-marketing/entities/automation-trigger.entity.ts @@ -0,0 +1,122 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { AutomationWorkflow } from './automation-workflow.entity'; + +export enum TriggerType { + USER_REGISTERED = 'user_registered', + TICKET_PURCHASED = 'ticket_purchased', + EVENT_VIEWED = 'event_viewed', + CART_ABANDONED = 'cart_abandoned', + EMAIL_OPENED = 'email_opened', + EMAIL_CLICKED = 'email_clicked', + EVENT_ATTENDED = 'event_attended', + BIRTHDAY = 'birthday', + ANNIVERSARY = 'anniversary', + INACTIVITY = 'inactivity', + SEGMENT_ENTERED = 'segment_entered', + SEGMENT_EXITED = 'segment_exited', + CUSTOM_EVENT = 'custom_event', + WEBHOOK = 'webhook', + SCHEDULE = 'schedule', +} + +@Entity('automation_triggers') +@Index(['workflowId', 'triggerType']) +export class AutomationTrigger { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + workflowId: string; + + @Column({ + type: 'enum', + enum: TriggerType, + }) + triggerType: TriggerType; + + @Column({ type: 'varchar', length: 100, nullable: true }) + name: string; + + @Column({ type: 'json' }) + conditions: { + // Event-based conditions + eventType?: string; + eventProperties?: { + field: string; + operator: 'equals' | 'not_equals' | 'contains' | 'greater_than' | 'less_than'; + value: any; + }[]; + + // Time-based conditions + timeDelay?: { + value: number; + unit: 'minutes' | 'hours' | 'days' | 'weeks'; + }; + + // Schedule-based conditions + schedule?: { + type: 'once' | 'recurring'; + datetime?: Date; + frequency?: 'daily' | 'weekly' | 'monthly'; + dayOfWeek?: number; + dayOfMonth?: number; + time?: string; + }; + + // Segment conditions + segmentId?: string; + + // Custom conditions + customConditions?: { + field: string; + operator: string; + value: any; + }[]; + }; + + @Column({ type: 'json', nullable: true }) + filters: { + userSegments?: string[]; + excludeSegments?: string[]; + userProperties?: { + field: string; + operator: string; + value: any; + }[]; + timeWindow?: { + start: string; + end: string; + timezone?: string; + }; + }; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; + + @Column({ type: 'int', default: 0 }) + triggerCount: number; + + @Column({ type: 'timestamp', nullable: true }) + lastTriggeredAt: Date; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @ManyToOne(() => AutomationWorkflow, (workflow) => workflow.triggers) + @JoinColumn({ name: 'workflowId' }) + workflow: AutomationWorkflow; +} diff --git a/src/email-marketing/entities/automation-workflow.entity.ts b/src/email-marketing/entities/automation-workflow.entity.ts new file mode 100644 index 00000000..cefa0f77 --- /dev/null +++ b/src/email-marketing/entities/automation-workflow.entity.ts @@ -0,0 +1,138 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { AutomationTrigger } from './automation-trigger.entity'; +import { AutomationAction } from './automation-action.entity'; + +export enum WorkflowStatus { + DRAFT = 'draft', + ACTIVE = 'active', + PAUSED = 'paused', + ARCHIVED = 'archived', +} + +export enum WorkflowType { + WELCOME_SERIES = 'welcome_series', + ABANDONED_CART = 'abandoned_cart', + POST_PURCHASE = 'post_purchase', + RE_ENGAGEMENT = 're_engagement', + EVENT_REMINDER = 'event_reminder', + BIRTHDAY = 'birthday', + CUSTOM = 'custom', +} + +@Entity('automation_workflows') +@Index(['status', 'workflowType']) +@Index(['createdBy']) +export class AutomationWorkflow { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 200 }) + @Index() + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ + type: 'enum', + enum: WorkflowType, + }) + workflowType: WorkflowType; + + @Column({ + type: 'enum', + enum: WorkflowStatus, + default: WorkflowStatus.DRAFT, + }) + status: WorkflowStatus; + + @Column({ type: 'uuid' }) + @Index() + createdBy: string; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; + + @Column({ type: 'int', default: 0 }) + totalEntered: number; + + @Column({ type: 'int', default: 0 }) + totalCompleted: number; + + @Column({ type: 'int', default: 0 }) + currentlyActive: number; + + @Column({ type: 'decimal', precision: 5, scale: 2, default: 0 }) + completionRate: number; + + @Column({ type: 'decimal', precision: 5, scale: 2, default: 0 }) + conversionRate: number; + + @Column({ type: 'json', nullable: true }) + settings: { + allowReentry?: boolean; + exitOnGoalAchievement?: boolean; + maxDuration?: { + value: number; + unit: 'days' | 'weeks' | 'months'; + }; + timezone?: string; + sendingLimits?: { + maxEmailsPerDay?: number; + respectUnsubscribes?: boolean; + }; + }; + + @Column({ type: 'json', nullable: true }) + goals: { + primary?: { + type: 'email_open' | 'email_click' | 'purchase' | 'event_registration' | 'custom'; + target?: number; + timeframe?: string; + }; + secondary?: { + type: string; + target?: number; + timeframe?: string; + }[]; + }; + + @Column({ type: 'simple-array', nullable: true }) + tags: string[]; + + @Column({ type: 'json', nullable: true }) + metadata: { + estimatedDuration?: string; + targetAudience?: string; + expectedResults?: string; + notes?: string; + }; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @ManyToOne(() => User) + @JoinColumn({ name: 'createdBy' }) + creator: User; + + @OneToMany(() => AutomationTrigger, (trigger) => trigger.workflow) + triggers: AutomationTrigger[]; + + @OneToMany(() => AutomationAction, (action) => action.workflow) + actions: AutomationAction[]; +} diff --git a/src/email-marketing/entities/campaign-segment.entity.ts b/src/email-marketing/entities/campaign-segment.entity.ts new file mode 100644 index 00000000..90be4d26 --- /dev/null +++ b/src/email-marketing/entities/campaign-segment.entity.ts @@ -0,0 +1,54 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { EmailCampaign } from './email-campaign.entity'; +import { UserSegment } from './user-segment.entity'; + +@Entity('campaign_segments') +@Index(['campaignId', 'segmentId']) +export class CampaignSegment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + campaignId: string; + + @Column({ type: 'uuid' }) + @Index() + segmentId: string; + + @Column({ type: 'boolean', default: true }) + isIncluded: boolean; // true for include, false for exclude + + @Column({ type: 'int', default: 0 }) + estimatedSize: number; + + @Column({ type: 'int', default: 0 }) + actualSize: number; + + @Column({ type: 'timestamp', nullable: true }) + lastCalculatedAt: Date; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @ManyToOne(() => EmailCampaign, (campaign) => campaign.segments) + @JoinColumn({ name: 'campaignId' }) + campaign: EmailCampaign; + + @ManyToOne(() => UserSegment) + @JoinColumn({ name: 'segmentId' }) + segment: UserSegment; +} diff --git a/src/email-marketing/entities/email-bounce.entity.ts b/src/email-marketing/entities/email-bounce.entity.ts new file mode 100644 index 00000000..8e79342c --- /dev/null +++ b/src/email-marketing/entities/email-bounce.entity.ts @@ -0,0 +1,169 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { EmailDelivery } from './email-delivery.entity'; + +export enum BounceType { + HARD = 'hard', + SOFT = 'soft', + COMPLAINT = 'complaint', + SUPPRESSION = 'suppression', +} + +export enum BounceSubType { + // Hard bounce subtypes + GENERAL = 'general', + NO_EMAIL = 'no_email', + SUPPRESSED = 'suppressed', + MAILBOX_FULL = 'mailbox_full', + MESSAGE_TOO_LARGE = 'message_too_large', + CONTENT_REJECTED = 'content_rejected', + ATTACHMENT_REJECTED = 'attachment_rejected', + + // Soft bounce subtypes + GENERAL_SOFT = 'general_soft', + MAILBOX_FULL_SOFT = 'mailbox_full_soft', + MESSAGE_TOO_LARGE_SOFT = 'message_too_large_soft', + CONTENT_REJECTED_SOFT = 'content_rejected_soft', + + // Complaint subtypes + ABUSE = 'abuse', + AUTH_FAILURE = 'auth_failure', + FRAUD = 'fraud', + NOT_SPAM = 'not_spam', + OTHER = 'other', + VIRUS = 'virus', +} + +@Entity('email_bounces') +@Index(['deliveryId', 'bouncedAt']) +@Index(['bounceType', 'bouncedAt']) +@Index(['recipientEmail', 'bounceType']) +export class EmailBounce { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + deliveryId: string; + + @Column({ type: 'varchar', length: 255 }) + @Index() + recipientEmail: string; + + @Column({ + type: 'enum', + enum: BounceType, + }) + @Index() + bounceType: BounceType; + + @Column({ + type: 'enum', + enum: BounceSubType, + nullable: true, + }) + bounceSubType: BounceSubType; + + @Column({ type: 'timestamp' }) + @Index() + bouncedAt: Date; + + @Column({ type: 'text', nullable: true }) + diagnosticCode: string; + + @Column({ type: 'text', nullable: true }) + bounceMessage: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + remoteMtaIp: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + reportingMta: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + action: string; // failed, delayed, delivered, relayed, expanded + + @Column({ type: 'varchar', length: 100, nullable: true }) + status: string; // SMTP status code (e.g., 5.1.1) + + @Column({ type: 'boolean', default: false }) + isPermanent: boolean; + + @Column({ type: 'boolean', default: false }) + suppressRecipient: boolean; // Whether to suppress future emails to this recipient + + @Column({ type: 'json', nullable: true }) + rawBounceData: { + messageId?: string; + timestamp?: string; + source?: string; + destination?: string[]; + bounceType?: string; + bounceSubType?: string; + bouncedRecipients?: Array<{ + emailAddress: string; + action: string; + status: string; + diagnosticCode: string; + }>; + complainedRecipients?: Array<{ + emailAddress: string; + complaintFeedbackType: string; + complaintSubType: string; + userAgent: string; + arrivalDate: string; + }>; + deliveryDelay?: { + delayType: string; + expirationTime: string; + delayedRecipients: Array<{ + emailAddress: string; + action: string; + status: string; + diagnosticCode: string; + }>; + }; + }; + + @Column({ type: 'json', nullable: true }) + metadata: { + provider?: string; // SES, SendGrid, Mailgun, etc. + messageTag?: string; + campaignId?: string; + listId?: string; + suppressionReason?: string; + feedbackId?: string; + userAgent?: string; + arrivalDate?: string; + }; + + @Column({ type: 'int', default: 0 }) + retryCount: number; + + @Column({ type: 'timestamp', nullable: true }) + lastRetryAt: Date; + + @Column({ type: 'timestamp', nullable: true }) + nextRetryAt: Date; + + @Column({ type: 'boolean', default: false }) + isProcessed: boolean; + + @Column({ type: 'timestamp', nullable: true }) + processedAt: Date; + + @CreateDateColumn() + createdAt: Date; + + // Relations + @ManyToOne(() => EmailDelivery, (delivery) => delivery.bounces) + @JoinColumn({ name: 'deliveryId' }) + delivery: EmailDelivery; +} diff --git a/src/email-marketing/entities/email-campaign.entity.ts b/src/email-marketing/entities/email-campaign.entity.ts new file mode 100644 index 00000000..25b8bdd5 --- /dev/null +++ b/src/email-marketing/entities/email-campaign.entity.ts @@ -0,0 +1,235 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { Event } from '../../events/entities/event.entity'; +import { EmailTemplate } from './email-template.entity'; +import { CampaignSegment } from './campaign-segment.entity'; +import { EmailDelivery } from './email-delivery.entity'; +import { ABTest } from './ab-test.entity'; + +export enum CampaignStatus { + DRAFT = 'draft', + SCHEDULED = 'scheduled', + SENDING = 'sending', + SENT = 'sent', + PAUSED = 'paused', + CANCELLED = 'cancelled', + COMPLETED = 'completed', +} + +export enum CampaignType { + ONE_TIME = 'one_time', + RECURRING = 'recurring', + DRIP_SEQUENCE = 'drip_sequence', + AUTOMATION = 'automation', + AB_TEST = 'ab_test', +} + +export enum SendTimeOptimization { + IMMEDIATE = 'immediate', + OPTIMAL_TIME = 'optimal_time', + TIMEZONE_BASED = 'timezone_based', + CUSTOM_SCHEDULE = 'custom_schedule', +} + +@Entity('email_campaigns') +@Index(['status', 'scheduledAt']) +@Index(['campaignType', 'createdBy']) +@Index(['eventId']) +export class EmailCampaign { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 200 }) + @Index() + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ + type: 'enum', + enum: CampaignType, + }) + campaignType: CampaignType; + + @Column({ + type: 'enum', + enum: CampaignStatus, + default: CampaignStatus.DRAFT, + }) + status: CampaignStatus; + + @Column({ type: 'uuid' }) + @Index() + templateId: string; + + @Column({ type: 'uuid' }) + @Index() + createdBy: string; + + @Column({ type: 'uuid', nullable: true }) + @Index() + eventId: string; + + @Column({ type: 'varchar', length: 300 }) + subject: string; + + @Column({ type: 'varchar', length: 200, nullable: true }) + preheader: string; + + @Column({ type: 'varchar', length: 200 }) + fromName: string; + + @Column({ type: 'varchar', length: 255 }) + fromEmail: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + replyToEmail: string; + + @Column({ + type: 'enum', + enum: SendTimeOptimization, + default: SendTimeOptimization.IMMEDIATE, + }) + sendTimeOptimization: SendTimeOptimization; + + @Column({ type: 'timestamp', nullable: true }) + @Index() + scheduledAt: Date; + + @Column({ type: 'timestamp', nullable: true }) + sentAt: Date; + + @Column({ type: 'int', default: 0 }) + totalRecipients: number; + + @Column({ type: 'int', default: 0 }) + sentCount: number; + + @Column({ type: 'int', default: 0 }) + deliveredCount: number; + + @Column({ type: 'int', default: 0 }) + openedCount: number; + + @Column({ type: 'int', default: 0 }) + clickedCount: number; + + @Column({ type: 'int', default: 0 }) + unsubscribedCount: number; + + @Column({ type: 'int', default: 0 }) + bouncedCount: number; + + @Column({ type: 'int', default: 0 }) + complainedCount: number; + + @Column({ type: 'decimal', precision: 5, scale: 2, default: 0 }) + openRate: number; + + @Column({ type: 'decimal', precision: 5, scale: 2, default: 0 }) + clickRate: number; + + @Column({ type: 'decimal', precision: 5, scale: 2, default: 0 }) + conversionRate: number; + + @Column({ type: 'decimal', precision: 5, scale: 2, default: 0 }) + unsubscribeRate: number; + + @Column({ type: 'json', nullable: true }) + segmentationRules: { + includeSegments?: string[]; + excludeSegments?: string[]; + customRules?: { + field: string; + operator: 'equals' | 'not_equals' | 'contains' | 'greater_than' | 'less_than' | 'in' | 'not_in'; + value: any; + }[]; + audienceSize?: number; + }; + + @Column({ type: 'json', nullable: true }) + personalization: { + variables: Record; + dynamicContent: { + rules: { + condition: string; + content: string; + }[]; + }; + }; + + @Column({ type: 'json', nullable: true }) + trackingSettings: { + trackOpens: boolean; + trackClicks: boolean; + trackUnsubscribes: boolean; + googleAnalytics?: { + enabled: boolean; + utmSource?: string; + utmMedium?: string; + utmCampaign?: string; + }; + customTracking?: Record; + }; + + @Column({ type: 'json', nullable: true }) + deliverySettings: { + throttleRate?: number; // emails per hour + retryAttempts?: number; + suppressionLists?: string[]; + testEmailAddresses?: string[]; + }; + + @Column({ type: 'simple-array', nullable: true }) + tags: string[]; + + @Column({ type: 'json', nullable: true }) + metadata: { + budget?: number; + expectedROI?: number; + campaignGoals?: string[]; + notes?: string; + approvalStatus?: 'pending' | 'approved' | 'rejected'; + approvedBy?: string; + approvedAt?: Date; + }; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @ManyToOne(() => User) + @JoinColumn({ name: 'createdBy' }) + creator: User; + + @ManyToOne(() => Event, { nullable: true }) + @JoinColumn({ name: 'eventId' }) + event: Event; + + @ManyToOne(() => EmailTemplate) + @JoinColumn({ name: 'templateId' }) + template: EmailTemplate; + + @OneToMany(() => CampaignSegment, (segment) => segment.campaign) + segments: CampaignSegment[]; + + @OneToMany(() => EmailDelivery, (delivery) => delivery.campaign) + deliveries: EmailDelivery[]; + + @OneToMany(() => ABTest, (test) => test.campaign) + abTests: ABTest[]; +} diff --git a/src/email-marketing/entities/email-click.entity.ts b/src/email-marketing/entities/email-click.entity.ts new file mode 100644 index 00000000..89db2782 --- /dev/null +++ b/src/email-marketing/entities/email-click.entity.ts @@ -0,0 +1,74 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { EmailDelivery } from './email-delivery.entity'; + +@Entity('email_clicks') +@Index(['deliveryId', 'clickedAt']) +@Index(['url', 'clickedAt']) +export class EmailClick { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + deliveryId: string; + + @Column({ type: 'text' }) + @Index() + url: string; + + @Column({ type: 'varchar', length: 200, nullable: true }) + linkText: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + linkId: string; + + @Column({ type: 'timestamp' }) + @Index() + clickedAt: Date; + + @Column({ type: 'varchar', length: 45, nullable: true }) + ipAddress: string; + + @Column({ type: 'varchar', length: 500, nullable: true }) + userAgent: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + location: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + deviceType: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + browser: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + operatingSystem: string; + + @Column({ type: 'boolean', default: false }) + isUnique: boolean; // First click on this link by this recipient + + @Column({ type: 'json', nullable: true }) + trackingData: { + referrer?: string; + utmParams?: Record; + customParams?: Record; + conversionValue?: number; + conversionType?: string; + }; + + @CreateDateColumn() + createdAt: Date; + + // Relations + @ManyToOne(() => EmailDelivery, (delivery) => delivery.clicks) + @JoinColumn({ name: 'deliveryId' }) + delivery: EmailDelivery; +} diff --git a/src/email-marketing/entities/email-delivery.entity.ts b/src/email-marketing/entities/email-delivery.entity.ts new file mode 100644 index 00000000..3d92a86a --- /dev/null +++ b/src/email-marketing/entities/email-delivery.entity.ts @@ -0,0 +1,158 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { EmailCampaign } from './email-campaign.entity'; +import { EmailOpen } from './email-open.entity'; +import { EmailClick } from './email-click.entity'; + +export enum DeliveryStatus { + QUEUED = 'queued', + SENDING = 'sending', + SENT = 'sent', + DELIVERED = 'delivered', + BOUNCED = 'bounced', + FAILED = 'failed', + DEFERRED = 'deferred', + DROPPED = 'dropped', +} + +export enum BounceType { + HARD = 'hard', + SOFT = 'soft', + BLOCK = 'block', + SPAM = 'spam', +} + +@Entity('email_deliveries') +@Index(['campaignId', 'status']) +@Index(['recipientId', 'campaignId']) +@Index(['status', 'sentAt']) +export class EmailDelivery { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + campaignId: string; + + @Column({ type: 'uuid' }) + @Index() + recipientId: string; + + @Column({ type: 'varchar', length: 255 }) + @Index() + recipientEmail: string; + + @Column({ + type: 'enum', + enum: DeliveryStatus, + default: DeliveryStatus.QUEUED, + }) + status: DeliveryStatus; + + @Column({ type: 'varchar', length: 100, nullable: true }) + messageId: string; + + @Column({ type: 'varchar', length: 300 }) + subject: string; + + @Column({ type: 'varchar', length: 200 }) + fromName: string; + + @Column({ type: 'varchar', length: 255 }) + fromEmail: string; + + @Column({ type: 'text' }) + htmlContent: string; + + @Column({ type: 'text', nullable: true }) + textContent: string; + + @Column({ type: 'timestamp', nullable: true }) + @Index() + sentAt: Date; + + @Column({ type: 'timestamp', nullable: true }) + deliveredAt: Date; + + @Column({ type: 'timestamp', nullable: true }) + bouncedAt: Date; + + @Column({ + type: 'enum', + enum: BounceType, + nullable: true, + }) + bounceType: BounceType; + + @Column({ type: 'text', nullable: true }) + bounceReason: string; + + @Column({ type: 'text', nullable: true }) + failureReason: string; + + @Column({ type: 'int', default: 0 }) + retryCount: number; + + @Column({ type: 'timestamp', nullable: true }) + nextRetryAt: Date; + + @Column({ type: 'json', nullable: true }) + personalizationData: Record; + + @Column({ type: 'json', nullable: true }) + trackingData: { + openTrackingEnabled: boolean; + clickTrackingEnabled: boolean; + unsubscribeTrackingEnabled: boolean; + customTrackingParams?: Record; + }; + + @Column({ type: 'varchar', length: 100, nullable: true }) + testVariantId: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + userAgent: string; + + @Column({ type: 'varchar', length: 45, nullable: true }) + ipAddress: string; + + @Column({ type: 'json', nullable: true }) + metadata: { + timezone?: string; + deviceType?: string; + emailClient?: string; + operatingSystem?: string; + tags?: string[]; + }; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @ManyToOne(() => EmailCampaign, (campaign) => campaign.deliveries) + @JoinColumn({ name: 'campaignId' }) + campaign: EmailCampaign; + + @ManyToOne(() => User) + @JoinColumn({ name: 'recipientId' }) + recipient: User; + + @OneToMany(() => EmailOpen, (open) => open.delivery) + opens: EmailOpen[]; + + @OneToMany(() => EmailClick, (click) => click.delivery) + clicks: EmailClick[]; +} diff --git a/src/email-marketing/entities/email-open.entity.ts b/src/email-marketing/entities/email-open.entity.ts new file mode 100644 index 00000000..8d242d14 --- /dev/null +++ b/src/email-marketing/entities/email-open.entity.ts @@ -0,0 +1,62 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { EmailDelivery } from './email-delivery.entity'; + +@Entity('email_opens') +@Index(['deliveryId', 'openedAt']) +@Index(['ipAddress', 'userAgent']) +export class EmailOpen { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + deliveryId: string; + + @Column({ type: 'timestamp' }) + @Index() + openedAt: Date; + + @Column({ type: 'varchar', length: 45, nullable: true }) + ipAddress: string; + + @Column({ type: 'varchar', length: 500, nullable: true }) + userAgent: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + location: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + deviceType: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + emailClient: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + operatingSystem: string; + + @Column({ type: 'boolean', default: false }) + isUnique: boolean; // First open by this recipient + + @Column({ type: 'json', nullable: true }) + trackingData: { + referrer?: string; + utmParams?: Record; + customParams?: Record; + }; + + @CreateDateColumn() + createdAt: Date; + + // Relations + @ManyToOne(() => EmailDelivery, (delivery) => delivery.opens) + @JoinColumn({ name: 'deliveryId' }) + delivery: EmailDelivery; +} diff --git a/src/email-marketing/entities/email-template.entity.ts b/src/email-marketing/entities/email-template.entity.ts new file mode 100644 index 00000000..9020bd4c --- /dev/null +++ b/src/email-marketing/entities/email-template.entity.ts @@ -0,0 +1,169 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { TemplateComponent } from './template-component.entity'; +import { EmailCampaign } from './email-campaign.entity'; + +export enum TemplateStatus { + DRAFT = 'draft', + ACTIVE = 'active', + ARCHIVED = 'archived', +} + +export enum TemplateType { + EVENT_ANNOUNCEMENT = 'event_announcement', + TICKET_REMINDER = 'ticket_reminder', + WELCOME_SERIES = 'welcome_series', + ABANDONED_CART = 'abandoned_cart', + POST_EVENT = 'post_event', + PROMOTIONAL = 'promotional', + NEWSLETTER = 'newsletter', + CUSTOM = 'custom', +} + +export enum TemplateCategory { + TRANSACTIONAL = 'transactional', + MARKETING = 'marketing', + NOTIFICATION = 'notification', + AUTOMATION = 'automation', +} + +@Entity('email_templates') +@Index(['status', 'isActive']) +@Index(['templateType', 'category']) +@Index(['createdBy']) +export class EmailTemplate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 200 }) + @Index() + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ type: 'varchar', length: 300, unique: true }) + @Index() + slug: string; + + @Column({ + type: 'enum', + enum: TemplateType, + }) + templateType: TemplateType; + + @Column({ + type: 'enum', + enum: TemplateCategory, + }) + category: TemplateCategory; + + @Column({ + type: 'enum', + enum: TemplateStatus, + default: TemplateStatus.DRAFT, + }) + status: TemplateStatus; + + @Column({ type: 'varchar', length: 300 }) + subject: string; + + @Column({ type: 'varchar', length: 200, nullable: true }) + preheader: string; + + @Column({ type: 'text' }) + htmlContent: string; + + @Column({ type: 'text', nullable: true }) + textContent: string; + + @Column({ type: 'json' }) + designData: { + components: any[]; + styles: Record; + layout: { + width: number; + backgroundColor: string; + fontFamily: string; + }; + variables: Record; + }; + + @Column({ type: 'json', nullable: true }) + variables: { + name: string; + type: 'text' | 'image' | 'url' | 'date' | 'number'; + defaultValue?: any; + required: boolean; + description?: string; + }[]; + + @Column({ type: 'varchar', length: 255, nullable: true }) + thumbnailUrl: string; + + @Column({ type: 'uuid' }) + @Index() + createdBy: string; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; + + @Column({ type: 'boolean', default: false }) + isSystem: boolean; + + @Column({ type: 'boolean', default: false }) + isShared: boolean; + + @Column({ type: 'int', default: 0 }) + usageCount: number; + + @Column({ type: 'decimal', precision: 5, scale: 2, default: 0 }) + averageRating: number; + + @Column({ type: 'int', default: 0 }) + ratingCount: number; + + @Column({ type: 'simple-array', nullable: true }) + tags: string[]; + + @Column({ type: 'json', nullable: true }) + metadata: { + industry?: string; + eventTypes?: string[]; + targetAudience?: string; + estimatedReadTime?: number; + lastTestDate?: Date; + testResults?: { + openRate?: number; + clickRate?: number; + conversionRate?: number; + }; + }; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @ManyToOne(() => User) + @JoinColumn({ name: 'createdBy' }) + creator: User; + + @OneToMany(() => TemplateComponent, (component) => component.template) + components: TemplateComponent[]; + + @OneToMany(() => EmailCampaign, (campaign) => campaign.template) + campaigns: EmailCampaign[]; +} diff --git a/src/email-marketing/entities/segment-rule.entity.ts b/src/email-marketing/entities/segment-rule.entity.ts new file mode 100644 index 00000000..52645eec --- /dev/null +++ b/src/email-marketing/entities/segment-rule.entity.ts @@ -0,0 +1,88 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { UserSegment } from './user-segment.entity'; + +export enum RuleOperator { + EQUALS = 'equals', + NOT_EQUALS = 'not_equals', + CONTAINS = 'contains', + NOT_CONTAINS = 'not_contains', + GREATER_THAN = 'greater_than', + LESS_THAN = 'less_than', + BETWEEN = 'between', + IN = 'in', + NOT_IN = 'not_in', + EXISTS = 'exists', + NOT_EXISTS = 'not_exists', + STARTS_WITH = 'starts_with', + ENDS_WITH = 'ends_with', +} + +export enum RuleDataType { + STRING = 'string', + NUMBER = 'number', + DATE = 'date', + BOOLEAN = 'boolean', + ARRAY = 'array', + OBJECT = 'object', +} + +@Entity('segment_rules') +@Index(['segmentId', 'sortOrder']) +export class SegmentRule { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + segmentId: string; + + @Column({ type: 'varchar', length: 100 }) + fieldName: string; + + @Column({ + type: 'enum', + enum: RuleOperator, + }) + operator: RuleOperator; + + @Column({ type: 'json' }) + value: any; + + @Column({ + type: 'enum', + enum: RuleDataType, + }) + dataType: RuleDataType; + + @Column({ type: 'int', default: 0 }) + sortOrder: number; + + @Column({ type: 'varchar', length: 10, default: 'AND' }) + logicOperator: 'AND' | 'OR'; + + @Column({ type: 'int', default: 0 }) + groupId: number; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @ManyToOne(() => UserSegment, (segment) => segment.rules) + @JoinColumn({ name: 'segmentId' }) + segment: UserSegment; +} diff --git a/src/email-marketing/entities/template-component.entity.ts b/src/email-marketing/entities/template-component.entity.ts new file mode 100644 index 00000000..5664c0b8 --- /dev/null +++ b/src/email-marketing/entities/template-component.entity.ts @@ -0,0 +1,131 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { EmailTemplate } from './email-template.entity'; + +export enum ComponentType { + HEADER = 'header', + TEXT = 'text', + IMAGE = 'image', + BUTTON = 'button', + DIVIDER = 'divider', + SPACER = 'spacer', + SOCIAL = 'social', + FOOTER = 'footer', + HERO = 'hero', + COLUMNS = 'columns', + EVENT_DETAILS = 'event_details', + TICKET_INFO = 'ticket_info', + COUNTDOWN = 'countdown', + CUSTOM_HTML = 'custom_html', +} + +@Entity('template_components') +@Index(['templateId', 'sortOrder']) +export class TemplateComponent { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + templateId: string; + + @Column({ + type: 'enum', + enum: ComponentType, + }) + componentType: ComponentType; + + @Column({ type: 'varchar', length: 100, nullable: true }) + name: string; + + @Column({ type: 'int', default: 0 }) + sortOrder: number; + + @Column({ type: 'json' }) + properties: { + // Text component + text?: string; + fontSize?: number; + fontWeight?: string; + color?: string; + textAlign?: 'left' | 'center' | 'right'; + + // Image component + src?: string; + alt?: string; + width?: number; + height?: number; + + // Button component + buttonText?: string; + buttonUrl?: string; + buttonColor?: string; + buttonTextColor?: string; + + // Layout properties + padding?: { + top: number; + right: number; + bottom: number; + left: number; + }; + margin?: { + top: number; + right: number; + bottom: number; + left: number; + }; + backgroundColor?: string; + borderRadius?: number; + border?: { + width: number; + style: string; + color: string; + }; + + // Responsive properties + mobileProperties?: Record; + + // Custom properties for specific components + customProperties?: Record; + }; + + @Column({ type: 'json', nullable: true }) + conditions: { + showIf?: { + variable: string; + operator: 'equals' | 'not_equals' | 'contains' | 'greater_than' | 'less_than'; + value: any; + }[]; + hideIf?: { + variable: string; + operator: 'equals' | 'not_equals' | 'contains' | 'greater_than' | 'less_than'; + value: any; + }[]; + }; + + @Column({ type: 'boolean', default: true }) + isVisible: boolean; + + @Column({ type: 'boolean', default: false }) + isLocked: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @ManyToOne(() => EmailTemplate, (template) => template.components) + @JoinColumn({ name: 'templateId' }) + template: EmailTemplate; +} diff --git a/src/email-marketing/entities/test-variant.entity.ts b/src/email-marketing/entities/test-variant.entity.ts new file mode 100644 index 00000000..00f10f96 --- /dev/null +++ b/src/email-marketing/entities/test-variant.entity.ts @@ -0,0 +1,108 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { ABTest } from './ab-test.entity'; +import { EmailTemplate } from './email-template.entity'; + +@Entity('test_variants') +@Index(['testId', 'variantName']) +export class TestVariant { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + testId: string; + + @Column({ type: 'varchar', length: 100 }) + variantName: string; // A, B, C, etc. + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ type: 'boolean', default: false }) + isControl: boolean; + + @Column({ type: 'int', default: 50 }) + trafficPercentage: number; + + @Column({ type: 'uuid', nullable: true }) + templateId: string; + + @Column({ type: 'varchar', length: 300, nullable: true }) + subject: string; + + @Column({ type: 'varchar', length: 200, nullable: true }) + fromName: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + fromEmail: string; + + @Column({ type: 'text', nullable: true }) + content: string; + + @Column({ type: 'timestamp', nullable: true }) + sendTime: Date; + + @Column({ type: 'json', nullable: true }) + changes: { + field: string; + originalValue: any; + testValue: any; + }[]; + + @Column({ type: 'int', default: 0 }) + sentCount: number; + + @Column({ type: 'int', default: 0 }) + deliveredCount: number; + + @Column({ type: 'int', default: 0 }) + openedCount: number; + + @Column({ type: 'int', default: 0 }) + clickedCount: number; + + @Column({ type: 'int', default: 0 }) + convertedCount: number; + + @Column({ type: 'int', default: 0 }) + unsubscribedCount: number; + + @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 }) + revenue: number; + + @Column({ type: 'decimal', precision: 5, scale: 2, default: 0 }) + openRate: number; + + @Column({ type: 'decimal', precision: 5, scale: 2, default: 0 }) + clickRate: number; + + @Column({ type: 'decimal', precision: 5, scale: 2, default: 0 }) + conversionRate: number; + + @Column({ type: 'decimal', precision: 5, scale: 2, default: 0 }) + unsubscribeRate: number; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @ManyToOne(() => ABTest, (test) => test.variants) + @JoinColumn({ name: 'testId' }) + test: ABTest; + + @ManyToOne(() => EmailTemplate, { nullable: true }) + @JoinColumn({ name: 'templateId' }) + template: EmailTemplate; +} diff --git a/src/email-marketing/entities/user-segment.entity.ts b/src/email-marketing/entities/user-segment.entity.ts new file mode 100644 index 00000000..288df8a8 --- /dev/null +++ b/src/email-marketing/entities/user-segment.entity.ts @@ -0,0 +1,152 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { SegmentRule } from './segment-rule.entity'; +import { CampaignSegment } from './campaign-segment.entity'; + +export enum SegmentType { + STATIC = 'static', + DYNAMIC = 'dynamic', + BEHAVIORAL = 'behavioral', + DEMOGRAPHIC = 'demographic', + ENGAGEMENT = 'engagement', + CUSTOM = 'custom', +} + +export enum SegmentStatus { + ACTIVE = 'active', + INACTIVE = 'inactive', + CALCULATING = 'calculating', + ERROR = 'error', +} + +@Entity('user_segments') +@Index(['segmentType', 'status']) +@Index(['createdBy']) +export class UserSegment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 200 }) + @Index() + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ + type: 'enum', + enum: SegmentType, + }) + segmentType: SegmentType; + + @Column({ + type: 'enum', + enum: SegmentStatus, + default: SegmentStatus.ACTIVE, + }) + status: SegmentStatus; + + @Column({ type: 'uuid' }) + @Index() + createdBy: string; + + @Column({ type: 'int', default: 0 }) + userCount: number; + + @Column({ type: 'timestamp', nullable: true }) + lastCalculatedAt: Date; + + @Column({ type: 'json' }) + criteria: { + conditions: { + field: string; + operator: 'equals' | 'not_equals' | 'contains' | 'not_contains' | 'greater_than' | 'less_than' | 'between' | 'in' | 'not_in' | 'exists' | 'not_exists'; + value: any; + dataType: 'string' | 'number' | 'date' | 'boolean' | 'array'; + }[]; + logic: 'AND' | 'OR'; + groups?: { + conditions: any[]; + logic: 'AND' | 'OR'; + }[]; + }; + + @Column({ type: 'json', nullable: true }) + behaviorCriteria: { + eventActions?: { + action: 'purchased_ticket' | 'viewed_event' | 'abandoned_cart' | 'shared_event' | 'left_review'; + eventType?: string; + timeframe?: { + value: number; + unit: 'days' | 'weeks' | 'months'; + }; + frequency?: { + operator: 'exactly' | 'at_least' | 'at_most'; + count: number; + }; + }[]; + engagementLevel?: { + emailOpens?: { min?: number; max?: number; timeframe: string }; + emailClicks?: { min?: number; max?: number; timeframe: string }; + websiteVisits?: { min?: number; max?: number; timeframe: string }; + }; + }; + + @Column({ type: 'json', nullable: true }) + demographicCriteria: { + age?: { min?: number; max?: number }; + location?: { + countries?: string[]; + states?: string[]; + cities?: string[]; + radius?: { lat: number; lng: number; miles: number }; + }; + interests?: string[]; + eventPreferences?: string[]; + }; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; + + @Column({ type: 'boolean', default: false }) + isSystem: boolean; + + @Column({ type: 'simple-array', nullable: true }) + tags: string[]; + + @Column({ type: 'json', nullable: true }) + metadata: { + refreshFrequency?: 'real_time' | 'hourly' | 'daily' | 'weekly'; + estimatedGrowthRate?: number; + averageEngagement?: number; + topInterests?: string[]; + notes?: string; + }; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @ManyToOne(() => User) + @JoinColumn({ name: 'createdBy' }) + creator: User; + + @OneToMany(() => SegmentRule, (rule) => rule.segment) + rules: SegmentRule[]; + + @OneToMany(() => CampaignSegment, (campaignSegment) => campaignSegment.segment) + campaignSegments: CampaignSegment[]; +} diff --git a/src/email-marketing/services/__tests__/analytics.service.spec.ts b/src/email-marketing/services/__tests__/analytics.service.spec.ts new file mode 100644 index 00000000..19750fe4 --- /dev/null +++ b/src/email-marketing/services/__tests__/analytics.service.spec.ts @@ -0,0 +1,365 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AnalyticsService } from '../analytics.service'; +import { EmailCampaign } from '../../entities/email-campaign.entity'; +import { EmailDelivery, DeliveryStatus } from '../../entities/email-delivery.entity'; +import { EmailOpen } from '../../entities/email-open.entity'; +import { EmailClick } from '../../entities/email-click.entity'; +import { EmailBounce } from '../../entities/email-bounce.entity'; +import { AutomationWorkflow } from '../../entities/automation-workflow.entity'; + +describe('AnalyticsService', () => { + let service: AnalyticsService; + let campaignRepository: jest.Mocked>; + let deliveryRepository: jest.Mocked>; + let openRepository: jest.Mocked>; + let clickRepository: jest.Mocked>; + let bounceRepository: jest.Mocked>; + let workflowRepository: jest.Mocked>; + + const mockCampaign = { + id: '1', + name: 'Test Campaign', + status: 'sent', + recipientCount: 100, + }; + + const mockWorkflow = { + id: '1', + name: 'Test Workflow', + executionCount: 50, + triggers: [ + { id: '1', triggerType: 'event', executionCount: 25, lastExecutedAt: new Date() } + ], + actions: [ + { id: '1', actionType: 'send_email', executionCount: 20, errorCount: 2, lastExecutedAt: new Date() } + ], + }; + + beforeEach(async () => { + const mockCampaignRepository = { + findOne: jest.fn(), + count: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockDeliveryRepository = { + createQueryBuilder: jest.fn(), + count: jest.fn(), + }; + + const mockOpenRepository = { + createQueryBuilder: jest.fn(), + count: jest.fn(), + }; + + const mockClickRepository = { + createQueryBuilder: jest.fn(), + count: jest.fn(), + }; + + const mockBounceRepository = { + count: jest.fn(), + }; + + const mockWorkflowRepository = { + findOne: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AnalyticsService, + { + provide: getRepositoryToken(EmailCampaign), + useValue: mockCampaignRepository, + }, + { + provide: getRepositoryToken(EmailDelivery), + useValue: mockDeliveryRepository, + }, + { + provide: getRepositoryToken(EmailOpen), + useValue: mockOpenRepository, + }, + { + provide: getRepositoryToken(EmailClick), + useValue: mockClickRepository, + }, + { + provide: getRepositoryToken(EmailBounce), + useValue: mockBounceRepository, + }, + { + provide: getRepositoryToken(AutomationWorkflow), + useValue: mockWorkflowRepository, + }, + ], + }).compile(); + + service = module.get(AnalyticsService); + campaignRepository = module.get(getRepositoryToken(EmailCampaign)); + deliveryRepository = module.get(getRepositoryToken(EmailDelivery)); + openRepository = module.get(getRepositoryToken(EmailOpen)); + clickRepository = module.get(getRepositoryToken(EmailClick)); + bounceRepository = module.get(getRepositoryToken(EmailBounce)); + workflowRepository = module.get(getRepositoryToken(AutomationWorkflow)); + }); + + describe('getCampaignAnalytics', () => { + it('should return comprehensive campaign analytics', async () => { + campaignRepository.findOne.mockResolvedValue(mockCampaign as any); + + const mockQueryBuilder = { + createQueryBuilder: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + setParameters: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ + sent: '100', + delivered: '95', + bounced: '5', + opened: '30', + clicked: '10', + unsubscribed: '2', + }), + }; + + deliveryRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + // Mock time series data + jest.spyOn(service as any, 'getTimeSeriesData').mockResolvedValue([ + { date: '2023-01-01', opens: 10, clicks: 3, bounces: 1 }, + { date: '2023-01-02', opens: 15, clicks: 5, bounces: 0 }, + ]); + + // Mock device breakdown + jest.spyOn(service as any, 'getDeviceBreakdown').mockResolvedValue([ + { deviceType: 'desktop', opens: 20, clicks: 8, percentage: 66.7 }, + { deviceType: 'mobile', opens: 10, clicks: 2, percentage: 33.3 }, + ]); + + // Mock location breakdown + jest.spyOn(service as any, 'getLocationBreakdown').mockResolvedValue([ + { location: 'US', opens: 20, clicks: 6, percentage: 66.7 }, + { location: 'UK', opens: 10, clicks: 4, percentage: 33.3 }, + ]); + + // Mock link performance + jest.spyOn(service as any, 'getLinkPerformance').mockResolvedValue([ + { url: 'https://example.com/cta', clicks: 8, uniqueClicks: 6, clickRate: 6.3 }, + { url: 'https://example.com/learn-more', clicks: 2, uniqueClicks: 2, clickRate: 2.1 }, + ]); + + const result = await service.getCampaignAnalytics('1'); + + expect(result.overview).toEqual({ + sent: 100, + delivered: 95, + bounced: 5, + opened: 30, + clicked: 10, + unsubscribed: 2, + openRate: (30 / 95) * 100, + clickRate: (10 / 95) * 100, + clickToOpenRate: (10 / 30) * 100, + bounceRate: (5 / 100) * 100, + unsubscribeRate: (2 / 95) * 100, + }); + + expect(result.timeSeriesData).toHaveLength(2); + expect(result.deviceBreakdown).toHaveLength(2); + expect(result.locationBreakdown).toHaveLength(2); + expect(result.linkPerformance).toHaveLength(2); + }); + + it('should throw error when campaign not found', async () => { + campaignRepository.findOne.mockResolvedValue(null); + + await expect(service.getCampaignAnalytics('999')) + .rejects.toThrow('Campaign not found'); + }); + }); + + describe('getDashboardMetrics', () => { + it('should return dashboard metrics for date range', async () => { + const dateRange = { + startDate: new Date('2023-01-01'), + endDate: new Date('2023-01-31'), + }; + + campaignRepository.count + .mockResolvedValueOnce(25) // total campaigns + .mockResolvedValueOnce(5); // active campaigns + + const mockQueryBuilder = { + createQueryBuilder: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + setParameter: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ + totalSent: '1000', + totalDelivered: '950', + totalOpened: '250', + totalClicked: '50', + }), + }; + + deliveryRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + // Mock top performing campaigns + jest.spyOn(service as any, 'getTopPerformingCampaigns').mockResolvedValue([ + { id: '1', name: 'Campaign 1', openRate: 30, clickRate: 5, sent: 100 }, + { id: '2', name: 'Campaign 2', openRate: 25, clickRate: 4, sent: 200 }, + ]); + + // Mock recent activity + jest.spyOn(service as any, 'getRecentActivity').mockResolvedValue([ + { type: 'campaign_sent', description: 'Campaign sent', timestamp: new Date() }, + ]); + + const result = await service.getDashboardMetrics(dateRange); + + expect(result).toEqual({ + totalCampaigns: 25, + activeCampaigns: 5, + totalEmailsSent: 1000, + averageOpenRate: (250 / 950) * 100, + averageClickRate: (50 / 950) * 100, + totalRevenue: 0, + topPerformingCampaigns: expect.any(Array), + recentActivity: expect.any(Array), + }); + }); + }); + + describe('getAutomationAnalytics', () => { + it('should return automation workflow analytics', async () => { + workflowRepository.findOne.mockResolvedValue(mockWorkflow as any); + + const result = await service.getAutomationAnalytics('1'); + + expect(result.overview).toEqual({ + totalExecutions: 50, + successfulExecutions: Math.floor(50 * 0.95), + failedExecutions: 50 - Math.floor(50 * 0.95), + successRate: 95, + averageExecutionTime: 1500, + }); + + expect(result.triggerPerformance).toHaveLength(1); + expect(result.actionPerformance).toHaveLength(1); + expect(result.conversionFunnel).toHaveLength(5); + + expect(result.actionPerformance[0]).toEqual({ + actionId: '1', + actionType: 'send_email', + executionCount: 20, + errorCount: 2, + successRate: ((20 - 2) / 20) * 100, + averageExecutionTime: 800, + }); + }); + + it('should throw error when workflow not found', async () => { + workflowRepository.findOne.mockResolvedValue(null); + + await expect(service.getAutomationAnalytics('999')) + .rejects.toThrow('Workflow not found'); + }); + }); + + describe('getSegmentAnalytics', () => { + it('should return segment analytics', async () => { + const result = await service.getSegmentAnalytics('segment-1'); + + expect(result).toEqual({ + overview: { + totalUsers: 1500, + activeUsers: 1200, + engagementRate: 80, + averageOpenRate: 25.5, + averageClickRate: 4.2, + }, + campaignPerformance: [], + userActivity: [], + }); + }); + }); + + describe('private methods', () => { + describe('getTimeSeriesData', () => { + it('should return time series data for date range', async () => { + const startDate = new Date('2023-01-01'); + const endDate = new Date('2023-01-02'); + + openRepository.count.mockResolvedValue(5); + clickRepository.count.mockResolvedValue(2); + bounceRepository.count.mockResolvedValue(1); + + const result = await (service as any).getTimeSeriesData('1', startDate, endDate); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + date: '2023-01-01', + opens: 5, + clicks: 2, + bounces: 1, + }); + }); + }); + + describe('getDeviceBreakdown', () => { + it('should return device breakdown data', async () => { + const mockQueryBuilder = { + createQueryBuilder: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([ + { deviceType: 'desktop', opens: '20' }, + { deviceType: 'mobile', opens: '10' }, + ]), + }; + + openRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await (service as any).getDeviceBreakdown('1'); + + expect(result).toEqual([ + { deviceType: 'desktop', opens: 20, clicks: 0, percentage: 66.66666666666666 }, + { deviceType: 'mobile', opens: 10, clicks: 0, percentage: 33.33333333333333 }, + ]); + }); + }); + + describe('getLinkPerformance', () => { + it('should return link performance data', async () => { + const mockQueryBuilder = { + createQueryBuilder: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([ + { url: 'https://example.com/cta', clicks: '10', uniqueClicks: '8' }, + { url: 'https://example.com/learn', clicks: '5', uniqueClicks: '4' }, + ]), + }; + + clickRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + deliveryRepository.count.mockResolvedValue(100); + + const result = await (service as any).getLinkPerformance('1'); + + expect(result).toEqual([ + { url: 'https://example.com/cta', clicks: 10, uniqueClicks: 8, clickRate: 8 }, + { url: 'https://example.com/learn', clicks: 5, uniqueClicks: 4, clickRate: 4 }, + ]); + }); + }); + }); +}); diff --git a/src/email-marketing/services/__tests__/automation.service.spec.ts b/src/email-marketing/services/__tests__/automation.service.spec.ts new file mode 100644 index 00000000..4bbf5afa --- /dev/null +++ b/src/email-marketing/services/__tests__/automation.service.spec.ts @@ -0,0 +1,324 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { AutomationService } from '../automation.service'; +import { AutomationWorkflow, WorkflowStatus, WorkflowType } from '../../entities/automation-workflow.entity'; +import { AutomationTrigger, TriggerType } from '../../entities/automation-trigger.entity'; +import { AutomationAction, ActionType } from '../../entities/automation-action.entity'; +import { EmailCampaign } from '../../entities/email-campaign.entity'; +import { UserSegment } from '../../entities/user-segment.entity'; + +describe('AutomationService', () => { + let service: AutomationService; + let workflowRepository: jest.Mocked>; + let triggerRepository: jest.Mocked>; + let actionRepository: jest.Mocked>; + + const mockWorkflow = { + id: '1', + name: 'Test Workflow', + workflowType: WorkflowType.DRIP_CAMPAIGN, + status: WorkflowStatus.DRAFT, + executionCount: 0, + createdAt: new Date(), + triggers: [], + actions: [], + }; + + const mockTrigger = { + id: '1', + workflowId: '1', + triggerType: TriggerType.EVENT, + eventName: 'user_registered', + isActive: true, + executionCount: 0, + }; + + const mockAction = { + id: '1', + workflowId: '1', + actionType: ActionType.SEND_EMAIL, + sortOrder: 1, + configuration: { templateId: 'template-1' }, + isActive: true, + executionCount: 0, + errorCount: 0, + }; + + beforeEach(async () => { + const mockWorkflowRepository = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + delete: jest.fn(), + }; + + const mockTriggerRepository = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + delete: jest.fn(), + }; + + const mockActionRepository = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + delete: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AutomationService, + { + provide: getRepositoryToken(AutomationWorkflow), + useValue: mockWorkflowRepository, + }, + { + provide: getRepositoryToken(AutomationTrigger), + useValue: mockTriggerRepository, + }, + { + provide: getRepositoryToken(AutomationAction), + useValue: mockActionRepository, + }, + { + provide: getRepositoryToken(EmailCampaign), + useValue: {}, + }, + { + provide: getRepositoryToken(UserSegment), + useValue: {}, + }, + ], + }).compile(); + + service = module.get(AutomationService); + workflowRepository = module.get(getRepositoryToken(AutomationWorkflow)); + triggerRepository = module.get(getRepositoryToken(AutomationTrigger)); + actionRepository = module.get(getRepositoryToken(AutomationAction)); + }); + + describe('createWorkflow', () => { + it('should create a new workflow', async () => { + const workflowData = { + name: 'New Workflow', + workflowType: WorkflowType.DRIP_CAMPAIGN, + description: 'Test workflow', + }; + + workflowRepository.create.mockReturnValue(mockWorkflow as any); + workflowRepository.save.mockResolvedValue(mockWorkflow as any); + + const result = await service.createWorkflow(workflowData); + + expect(workflowRepository.create).toHaveBeenCalledWith({ + ...workflowData, + status: WorkflowStatus.DRAFT, + }); + expect(result).toEqual(mockWorkflow); + }); + }); + + describe('addTrigger', () => { + it('should add trigger to workflow', async () => { + const triggerData = { + triggerType: TriggerType.EVENT, + eventName: 'user_registered', + conditions: { userType: 'premium' }, + }; + + workflowRepository.findOne.mockResolvedValue(mockWorkflow as any); + triggerRepository.create.mockReturnValue(mockTrigger as any); + triggerRepository.save.mockResolvedValue(mockTrigger as any); + + const result = await service.addTrigger('1', triggerData); + + expect(triggerRepository.create).toHaveBeenCalledWith({ + ...triggerData, + workflowId: '1', + isActive: true, + }); + expect(result).toEqual(mockTrigger); + }); + }); + + describe('addAction', () => { + it('should add action to workflow', async () => { + const actionData = { + actionType: ActionType.SEND_EMAIL, + sortOrder: 1, + configuration: { templateId: 'template-1' }, + }; + + workflowRepository.findOne.mockResolvedValue(mockWorkflow as any); + actionRepository.create.mockReturnValue(mockAction as any); + actionRepository.save.mockResolvedValue(mockAction as any); + + const result = await service.addAction('1', actionData); + + expect(actionRepository.create).toHaveBeenCalledWith({ + ...actionData, + workflowId: '1', + isActive: true, + }); + expect(result).toEqual(mockAction); + }); + }); + + describe('activateWorkflow', () => { + it('should activate workflow with triggers and actions', async () => { + workflowRepository.findOne.mockResolvedValue(mockWorkflow as any); + triggerRepository.find.mockResolvedValue([mockTrigger]); + actionRepository.find.mockResolvedValue([mockAction]); + workflowRepository.save.mockResolvedValue({ + ...mockWorkflow, + status: WorkflowStatus.ACTIVE, + } as any); + + const result = await service.activateWorkflow('1'); + + expect(result.status).toBe(WorkflowStatus.ACTIVE); + expect(workflowRepository.save).toHaveBeenCalled(); + }); + + it('should throw BadRequestException when no triggers exist', async () => { + workflowRepository.findOne.mockResolvedValue(mockWorkflow as any); + triggerRepository.find.mockResolvedValue([]); + actionRepository.find.mockResolvedValue([mockAction]); + + await expect(service.activateWorkflow('1')) + .rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when no actions exist', async () => { + workflowRepository.findOne.mockResolvedValue(mockWorkflow as any); + triggerRepository.find.mockResolvedValue([mockTrigger]); + actionRepository.find.mockResolvedValue([]); + + await expect(service.activateWorkflow('1')) + .rejects.toThrow(BadRequestException); + }); + }); + + describe('processEvent', () => { + it('should process event and execute matching triggers', async () => { + const eventData = { userId: '123', userType: 'premium' }; + const activeTrigger = { + ...mockTrigger, + workflow: { ...mockWorkflow, status: WorkflowStatus.ACTIVE }, + conditions: { userType: 'premium' }, + }; + + triggerRepository.find.mockResolvedValue([activeTrigger] as any); + actionRepository.find.mockResolvedValue([mockAction] as any); + triggerRepository.save.mockResolvedValue(mockTrigger as any); + actionRepository.save.mockResolvedValue(mockAction as any); + workflowRepository.save.mockResolvedValue(mockWorkflow as any); + + // Mock private methods + jest.spyOn(service as any, 'matchesConditions').mockReturnValue(true); + jest.spyOn(service as any, 'executeAction').mockResolvedValue(undefined); + + await service.processEvent('user_registered', eventData); + + expect(triggerRepository.find).toHaveBeenCalledWith({ + where: { + eventName: 'user_registered', + isActive: true, + }, + relations: ['workflow'], + }); + }); + }); + + describe('pauseWorkflow', () => { + it('should pause active workflow', async () => { + workflowRepository.findOne.mockResolvedValue({ + ...mockWorkflow, + status: WorkflowStatus.ACTIVE, + } as any); + workflowRepository.save.mockResolvedValue({ + ...mockWorkflow, + status: WorkflowStatus.PAUSED, + } as any); + + const result = await service.pauseWorkflow('1'); + + expect(result.status).toBe(WorkflowStatus.PAUSED); + }); + + it('should throw BadRequestException when workflow is not active', async () => { + workflowRepository.findOne.mockResolvedValue({ + ...mockWorkflow, + status: WorkflowStatus.DRAFT, + } as any); + + await expect(service.pauseWorkflow('1')) + .rejects.toThrow(BadRequestException); + }); + }); + + describe('getWorkflowStats', () => { + it('should return workflow statistics', async () => { + const workflowWithStats = { + ...mockWorkflow, + executionCount: 100, + triggers: [{ ...mockTrigger, executionCount: 50 }], + actions: [{ ...mockAction, executionCount: 45, errorCount: 5 }], + }; + + workflowRepository.findOne.mockResolvedValue(workflowWithStats as any); + + const result = await service.getWorkflowStats('1'); + + expect(result).toEqual({ + executionCount: 100, + successRate: 95, + averageExecutionTime: 1500, + lastExecutedAt: workflowWithStats.lastExecutedAt, + triggerStats: [{ + triggerId: mockTrigger.id, + executionCount: 50, + lastExecutedAt: mockTrigger.lastExecutedAt, + }], + actionStats: [{ + actionId: mockAction.id, + executionCount: 45, + errorCount: 5, + successRate: ((45 - 5) / 45) * 100, + lastExecutedAt: mockAction.lastExecutedAt, + }], + }); + }); + }); + + describe('deleteWorkflow', () => { + it('should delete inactive workflow', async () => { + workflowRepository.findOne.mockResolvedValue({ + ...mockWorkflow, + status: WorkflowStatus.PAUSED, + } as any); + triggerRepository.delete.mockResolvedValue({ affected: 1 } as any); + actionRepository.delete.mockResolvedValue({ affected: 1 } as any); + workflowRepository.delete.mockResolvedValue({ affected: 1 } as any); + + await service.deleteWorkflow('1'); + + expect(triggerRepository.delete).toHaveBeenCalledWith({ workflowId: '1' }); + expect(actionRepository.delete).toHaveBeenCalledWith({ workflowId: '1' }); + expect(workflowRepository.delete).toHaveBeenCalledWith('1'); + }); + + it('should throw BadRequestException when workflow is active', async () => { + workflowRepository.findOne.mockResolvedValue({ + ...mockWorkflow, + status: WorkflowStatus.ACTIVE, + } as any); + + await expect(service.deleteWorkflow('1')) + .rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/src/email-marketing/services/__tests__/campaign.service.spec.ts b/src/email-marketing/services/__tests__/campaign.service.spec.ts new file mode 100644 index 00000000..d1de775d --- /dev/null +++ b/src/email-marketing/services/__tests__/campaign.service.spec.ts @@ -0,0 +1,285 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { CampaignService } from '../campaign.service'; +import { EmailCampaign, CampaignStatus, CampaignType } from '../../entities/email-campaign.entity'; +import { CampaignSegment } from '../../entities/campaign-segment.entity'; +import { EmailTemplate } from '../../entities/email-template.entity'; +import { UserSegment } from '../../entities/user-segment.entity'; +import { EmailDelivery, DeliveryStatus } from '../../entities/email-delivery.entity'; + +describe('CampaignService', () => { + let service: CampaignService; + let campaignRepository: jest.Mocked>; + let segmentRepository: jest.Mocked>; + let templateRepository: jest.Mocked>; + let userSegmentRepository: jest.Mocked>; + let deliveryRepository: jest.Mocked>; + + const mockCampaign = { + id: '1', + name: 'Test Campaign', + slug: 'test-campaign', + campaignType: CampaignType.PROMOTIONAL, + templateId: 'template-1', + subject: 'Test Subject', + status: CampaignStatus.DRAFT, + recipientCount: 0, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockTemplate = { + id: 'template-1', + name: 'Test Template', + status: 'active', + }; + + const mockUserSegment = { + id: 'segment-1', + name: 'Test Segment', + userCount: 100, + }; + + beforeEach(async () => { + const mockCampaignRepository = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + createQueryBuilder: jest.fn(), + count: jest.fn(), + }; + + const mockSegmentRepository = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + }; + + const mockTemplateRepository = { + findOne: jest.fn(), + }; + + const mockUserSegmentRepository = { + findOne: jest.fn(), + }; + + const mockDeliveryRepository = { + create: jest.fn(), + save: jest.fn(), + createQueryBuilder: jest.fn(), + count: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CampaignService, + { + provide: getRepositoryToken(EmailCampaign), + useValue: mockCampaignRepository, + }, + { + provide: getRepositoryToken(CampaignSegment), + useValue: mockSegmentRepository, + }, + { + provide: getRepositoryToken(EmailTemplate), + useValue: mockTemplateRepository, + }, + { + provide: getRepositoryToken(UserSegment), + useValue: mockUserSegmentRepository, + }, + { + provide: getRepositoryToken(EmailDelivery), + useValue: mockDeliveryRepository, + }, + ], + }).compile(); + + service = module.get(CampaignService); + campaignRepository = module.get(getRepositoryToken(EmailCampaign)); + segmentRepository = module.get(getRepositoryToken(CampaignSegment)); + templateRepository = module.get(getRepositoryToken(EmailTemplate)); + userSegmentRepository = module.get(getRepositoryToken(UserSegment)); + deliveryRepository = module.get(getRepositoryToken(EmailDelivery)); + }); + + describe('create', () => { + it('should create a new campaign with segments', async () => { + const createDto = { + name: 'New Campaign', + campaignType: CampaignType.PROMOTIONAL, + templateId: 'template-1', + subject: 'New Subject', + segments: [{ segmentId: 'segment-1', isIncluded: true }], + }; + + templateRepository.findOne.mockResolvedValue(mockTemplate as any); + campaignRepository.findOne.mockResolvedValue(null); // No existing slug + campaignRepository.create.mockReturnValue(mockCampaign as any); + campaignRepository.save.mockResolvedValue(mockCampaign as any); + userSegmentRepository.findOne.mockResolvedValue(mockUserSegment as any); + segmentRepository.create.mockReturnValue({} as any); + segmentRepository.save.mockResolvedValue([]); + campaignRepository.findOne.mockResolvedValueOnce(mockCampaign as any); // For findOne call + + const result = await service.create(createDto as any); + + expect(templateRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'template-1' }, + }); + expect(campaignRepository.create).toHaveBeenCalled(); + expect(segmentRepository.save).toHaveBeenCalled(); + }); + + it('should throw NotFoundException when template not found', async () => { + const createDto = { + name: 'New Campaign', + campaignType: CampaignType.PROMOTIONAL, + templateId: 'invalid-template', + subject: 'New Subject', + }; + + templateRepository.findOne.mockResolvedValue(null); + + await expect(service.create(createDto as any)).rejects.toThrow(NotFoundException); + }); + }); + + describe('updateStatus', () => { + it('should update campaign status when transition is valid', async () => { + campaignRepository.findOne.mockResolvedValue({ + ...mockCampaign, + status: CampaignStatus.DRAFT, + } as any); + campaignRepository.save.mockResolvedValue({ + ...mockCampaign, + status: CampaignStatus.SCHEDULED, + } as any); + + const result = await service.updateStatus('1', CampaignStatus.SCHEDULED); + + expect(result.status).toBe(CampaignStatus.SCHEDULED); + }); + + it('should throw BadRequestException for invalid status transition', async () => { + campaignRepository.findOne.mockResolvedValue({ + ...mockCampaign, + status: CampaignStatus.SENT, + } as any); + + await expect(service.updateStatus('1', CampaignStatus.DRAFT)) + .rejects.toThrow(BadRequestException); + }); + }); + + describe('scheduleCampaign', () => { + it('should schedule a draft campaign', async () => { + const futureDate = new Date(Date.now() + 24 * 60 * 60 * 1000); // Tomorrow + + campaignRepository.findOne.mockResolvedValue({ + ...mockCampaign, + status: CampaignStatus.DRAFT, + } as any); + campaignRepository.save.mockResolvedValue({ + ...mockCampaign, + status: CampaignStatus.SCHEDULED, + scheduledAt: futureDate, + } as any); + + const result = await service.scheduleCampaign('1', futureDate); + + expect(result.status).toBe(CampaignStatus.SCHEDULED); + expect(result.scheduledAt).toBe(futureDate); + }); + + it('should throw BadRequestException when scheduling past date', async () => { + const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // Yesterday + + campaignRepository.findOne.mockResolvedValue({ + ...mockCampaign, + status: CampaignStatus.DRAFT, + } as any); + + await expect(service.scheduleCampaign('1', pastDate)) + .rejects.toThrow(BadRequestException); + }); + }); + + describe('sendCampaign', () => { + it('should send a draft campaign', async () => { + campaignRepository.findOne.mockResolvedValue({ + ...mockCampaign, + status: CampaignStatus.DRAFT, + } as any); + campaignRepository.save.mockResolvedValue({ + ...mockCampaign, + status: CampaignStatus.SENT, + } as any); + deliveryRepository.create.mockReturnValue({} as any); + deliveryRepository.save.mockResolvedValue([]); + + // Mock getCampaignRecipients method + jest.spyOn(service as any, 'getCampaignRecipients').mockResolvedValue([ + { id: '1', email: 'user1@example.com', name: 'User 1' }, + { id: '2', email: 'user2@example.com', name: 'User 2' }, + ]); + + const result = await service.sendCampaign('1'); + + expect(result.status).toBe(CampaignStatus.SENT); + expect(deliveryRepository.save).toHaveBeenCalled(); + }); + + it('should throw BadRequestException when campaign cannot be sent', async () => { + campaignRepository.findOne.mockResolvedValue({ + ...mockCampaign, + status: CampaignStatus.SENT, + } as any); + + await expect(service.sendCampaign('1')) + .rejects.toThrow(BadRequestException); + }); + }); + + describe('getCampaignStats', () => { + it('should return campaign statistics', async () => { + const mockQueryBuilder = { + createQueryBuilder: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + setParameters: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ + sent: '100', + delivered: '95', + bounced: '5', + opened: '30', + clicked: '10', + unsubscribed: '2', + }), + }; + + deliveryRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.getCampaignStats('1'); + + expect(result).toEqual({ + sent: 100, + delivered: 95, + bounced: 5, + opened: 30, + clicked: 10, + unsubscribed: 2, + openRate: (30 / 95) * 100, + clickRate: (10 / 95) * 100, + bounceRate: (5 / 100) * 100, + unsubscribeRate: (2 / 95) * 100, + }); + }); + }); +}); diff --git a/src/email-marketing/services/__tests__/template-builder.service.spec.ts b/src/email-marketing/services/__tests__/template-builder.service.spec.ts new file mode 100644 index 00000000..178a442a --- /dev/null +++ b/src/email-marketing/services/__tests__/template-builder.service.spec.ts @@ -0,0 +1,401 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BadRequestException } from '@nestjs/common'; +import { TemplateBuilderService, TemplateLayout, DragDropComponent } from '../template-builder.service'; +import { EmailTemplate } from '../../entities/email-template.entity'; +import { TemplateComponent, ComponentType } from '../../entities/template-component.entity'; + +describe('TemplateBuilderService', () => { + let service: TemplateBuilderService; + let templateRepository: jest.Mocked>; + let componentRepository: jest.Mocked>; + + const mockTemplate = { + id: '1', + name: 'Test Template', + designData: { + layout: { + width: 600, + backgroundColor: '#ffffff', + fontFamily: 'Arial, sans-serif', + globalStyles: {}, + }, + components: [], + }, + components: [], + }; + + const mockComponent = { + id: '1', + templateId: '1', + componentType: ComponentType.TEXT, + name: 'Text Component', + properties: { + text: 'Hello World', + position: { x: 10, y: 20, width: 200, height: 50 }, + style: { fontSize: 16, color: '#333333' }, + }, + isVisible: true, + sortOrder: 0, + }; + + const mockLayout: TemplateLayout = { + width: 600, + backgroundColor: '#ffffff', + fontFamily: 'Arial, sans-serif', + globalStyles: {}, + components: [ + { + id: '1', + type: ComponentType.TEXT, + properties: { text: 'Hello World' }, + position: { x: 10, y: 20, width: 200, height: 50 }, + style: { fontSize: 16, color: '#333333' }, + isVisible: true, + }, + ], + }; + + beforeEach(async () => { + const mockTemplateRepository = { + findOne: jest.fn(), + save: jest.fn(), + }; + + const mockComponentRepository = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + delete: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TemplateBuilderService, + { + provide: getRepositoryToken(EmailTemplate), + useValue: mockTemplateRepository, + }, + { + provide: getRepositoryToken(TemplateComponent), + useValue: mockComponentRepository, + }, + ], + }).compile(); + + service = module.get(TemplateBuilderService); + templateRepository = module.get(getRepositoryToken(EmailTemplate)); + componentRepository = module.get(getRepositoryToken(TemplateComponent)); + }); + + describe('saveTemplateLayout', () => { + it('should save template layout and generate HTML', async () => { + templateRepository.findOne.mockResolvedValue(mockTemplate as any); + componentRepository.delete.mockResolvedValue({ affected: 1 } as any); + componentRepository.create.mockReturnValue(mockComponent as any); + componentRepository.save.mockResolvedValue([mockComponent] as any); + templateRepository.save.mockResolvedValue({ + ...mockTemplate, + htmlContent: expect.any(String), + } as any); + + const result = await service.saveTemplateLayout('1', mockLayout); + + expect(templateRepository.findOne).toHaveBeenCalledWith({ where: { id: '1' } }); + expect(componentRepository.delete).toHaveBeenCalledWith({ templateId: '1' }); + expect(componentRepository.save).toHaveBeenCalled(); + expect(templateRepository.save).toHaveBeenCalled(); + expect(result.htmlContent).toContain('Hello World'); + }); + + it('should throw BadRequestException when template not found', async () => { + templateRepository.findOne.mockResolvedValue(null); + + await expect(service.saveTemplateLayout('999', mockLayout)) + .rejects.toThrow(BadRequestException); + }); + }); + + describe('getTemplateLayout', () => { + it('should return template layout', async () => { + templateRepository.findOne.mockResolvedValue({ + ...mockTemplate, + components: [mockComponent], + } as any); + + const result = await service.getTemplateLayout('1'); + + expect(result).toEqual({ + width: 600, + backgroundColor: '#ffffff', + fontFamily: 'Arial, sans-serif', + globalStyles: {}, + components: [{ + id: '1', + type: ComponentType.TEXT, + properties: mockComponent.properties, + position: mockComponent.properties.position, + style: mockComponent.properties.style, + conditions: mockComponent.conditions, + isVisible: mockComponent.isVisible, + }], + }); + }); + + it('should throw BadRequestException when template not found', async () => { + templateRepository.findOne.mockResolvedValue(null); + + await expect(service.getTemplateLayout('999')) + .rejects.toThrow(BadRequestException); + }); + }); + + describe('addComponent', () => { + it('should add component to template', async () => { + const componentData: Omit = { + type: ComponentType.BUTTON, + properties: { buttonText: 'Click Me', buttonUrl: '#' }, + position: { x: 50, y: 100, width: 150, height: 40 }, + style: { backgroundColor: '#007bff', color: '#ffffff' }, + isVisible: true, + }; + + templateRepository.findOne.mockResolvedValue(mockTemplate as any); + componentRepository.create.mockReturnValue({ + ...mockComponent, + componentType: ComponentType.BUTTON, + } as any); + componentRepository.save.mockResolvedValue({ + ...mockComponent, + componentType: ComponentType.BUTTON, + } as any); + + const result = await service.addComponent('1', componentData); + + expect(componentRepository.create).toHaveBeenCalledWith({ + templateId: '1', + componentType: ComponentType.BUTTON, + name: 'BUTTON Component', + properties: { + ...componentData.properties, + position: componentData.position, + style: componentData.style, + }, + conditions: componentData.conditions, + isVisible: true, + sortOrder: 0, + }); + expect(result.componentType).toBe(ComponentType.BUTTON); + }); + }); + + describe('updateComponent', () => { + it('should update component properties', async () => { + const updates: Partial = { + properties: { text: 'Updated Text' }, + style: { fontSize: 18 }, + position: { x: 20, y: 30, width: 250, height: 60 }, + }; + + componentRepository.findOne.mockResolvedValue(mockComponent as any); + componentRepository.save.mockResolvedValue({ + ...mockComponent, + properties: { + ...mockComponent.properties, + ...updates.properties, + style: { ...mockComponent.properties.style, ...updates.style }, + position: updates.position, + }, + } as any); + + const result = await service.updateComponent('1', updates); + + expect(componentRepository.save).toHaveBeenCalledWith({ + ...mockComponent, + properties: { + ...mockComponent.properties, + ...updates.properties, + style: { ...mockComponent.properties.style, ...updates.style }, + position: updates.position, + }, + }); + }); + + it('should throw BadRequestException when component not found', async () => { + componentRepository.findOne.mockResolvedValue(null); + + await expect(service.updateComponent('999', {})) + .rejects.toThrow(BadRequestException); + }); + }); + + describe('deleteComponent', () => { + it('should delete component', async () => { + componentRepository.delete.mockResolvedValue({ affected: 1 } as any); + + await service.deleteComponent('1'); + + expect(componentRepository.delete).toHaveBeenCalledWith('1'); + }); + + it('should throw BadRequestException when component not found', async () => { + componentRepository.delete.mockResolvedValue({ affected: 0 } as any); + + await expect(service.deleteComponent('999')) + .rejects.toThrow(BadRequestException); + }); + }); + + describe('duplicateComponent', () => { + it('should duplicate component with offset position', async () => { + componentRepository.findOne.mockResolvedValue(mockComponent as any); + componentRepository.create.mockReturnValue({ + ...mockComponent, + id: '2', + name: 'Text Component (Copy)', + } as any); + componentRepository.save.mockResolvedValue({ + ...mockComponent, + id: '2', + name: 'Text Component (Copy)', + } as any); + + const result = await service.duplicateComponent('1'); + + expect(componentRepository.create).toHaveBeenCalledWith({ + templateId: '1', + componentType: ComponentType.TEXT, + name: 'Text Component (Copy)', + properties: { + ...mockComponent.properties, + position: { + ...mockComponent.properties.position, + x: mockComponent.properties.position.x + 20, + y: mockComponent.properties.position.y + 20, + }, + }, + conditions: mockComponent.conditions, + isVisible: mockComponent.isVisible, + sortOrder: mockComponent.sortOrder + 1, + }); + }); + }); + + describe('getComponentLibrary', () => { + it('should return component library', async () => { + const result = await service.getComponentLibrary(); + + expect(result).toHaveLength(3); // Basic, Layout, Social categories + expect(result[0].category).toBe('Basic'); + expect(result[0].components).toHaveLength(4); // Text, Heading, Image, Button + expect(result[1].category).toBe('Layout'); + expect(result[2].category).toBe('Social'); + + // Check basic components + const textComponent = result[0].components.find(c => c.type === ComponentType.TEXT); + expect(textComponent).toEqual({ + type: ComponentType.TEXT, + name: 'Text Block', + description: 'Simple text content', + defaultProperties: { text: 'Your text here...' }, + defaultStyle: { fontSize: 16, color: '#333333' }, + }); + }); + }); + + describe('HTML generation', () => { + it('should generate HTML for text component', () => { + const component: DragDropComponent = { + id: '1', + type: ComponentType.TEXT, + properties: { text: 'Hello World' }, + position: { x: 10, y: 20, width: 200, height: 50 }, + style: { fontSize: 16, color: '#333333' }, + isVisible: true, + }; + + const html = (service as any).generateComponentHtml(component); + + expect(html).toContain('Hello World'); + expect(html).toContain('position: absolute'); + expect(html).toContain('left: 10px'); + expect(html).toContain('top: 20px'); + expect(html).toContain('width: 200px'); + expect(html).toContain('height: 50px'); + expect(html).toContain('font-size: 16'); + expect(html).toContain('color: #333333'); + }); + + it('should generate HTML for button component', () => { + const component: DragDropComponent = { + id: '1', + type: ComponentType.BUTTON, + properties: { + buttonText: 'Click Me', + buttonUrl: 'https://example.com', + buttonColor: '#007bff', + buttonTextColor: '#ffffff', + }, + position: { x: 50, y: 100, width: 150, height: 40 }, + style: {}, + isVisible: true, + }; + + const html = (service as any).generateComponentHtml(component); + + expect(html).toContain('Click Me'); + expect(html).toContain('href="https://example.com"'); + expect(html).toContain('background-color: #007bff'); + expect(html).toContain('color: #ffffff'); + }); + + it('should generate HTML for image component', () => { + const component: DragDropComponent = { + id: '1', + type: ComponentType.IMAGE, + properties: { + src: 'https://example.com/image.jpg', + alt: 'Test Image', + }, + position: { x: 0, y: 0, width: 300, height: 200 }, + style: {}, + isVisible: true, + }; + + const html = (service as any).generateComponentHtml(component); + + expect(html).toContain('src="https://example.com/image.jpg"'); + expect(html).toContain('alt="Test Image"'); + expect(html).toContain(' { + it('should generate inline styles correctly', () => { + const styles = { + fontSize: 16, + color: '#333333', + backgroundColor: '#ffffff', + textAlign: 'center', + }; + + const result = (service as any).generateInlineStyles(styles); + + expect(result).toBe('font-size: 16; color: #333333; background-color: #ffffff; text-align: center'); + }); + + it('should filter out undefined and null values', () => { + const styles = { + fontSize: 16, + color: undefined, + backgroundColor: null, + textAlign: 'center', + }; + + const result = (service as any).generateInlineStyles(styles); + + expect(result).toBe('font-size: 16; text-align: center'); + }); + }); +}); diff --git a/src/email-marketing/services/__tests__/template.service.spec.ts b/src/email-marketing/services/__tests__/template.service.spec.ts new file mode 100644 index 00000000..be49b817 --- /dev/null +++ b/src/email-marketing/services/__tests__/template.service.spec.ts @@ -0,0 +1,284 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException } from '@nestjs/common'; +import { TemplateService } from '../template.service'; +import { EmailTemplate, TemplateStatus, TemplateType } from '../../entities/email-template.entity'; +import { TemplateComponent, ComponentType } from '../../entities/template-component.entity'; + +describe('TemplateService', () => { + let service: TemplateService; + let templateRepository: jest.Mocked>; + let componentRepository: jest.Mocked>; + + const mockTemplate = { + id: '1', + name: 'Test Template', + slug: 'test-template', + templateType: TemplateType.PROMOTIONAL, + subject: 'Test Subject', + htmlContent: 'Test', + status: TemplateStatus.ACTIVE, + isActive: true, + usageCount: 5, + averageRating: 4.5, + ratingCount: 10, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockComponent = { + id: '1', + templateId: '1', + componentType: ComponentType.TEXT, + name: 'Text Component', + properties: { text: 'Hello World' }, + sortOrder: 0, + isVisible: true, + }; + + beforeEach(async () => { + const mockTemplateRepository = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + createQueryBuilder: jest.fn(), + increment: jest.fn(), + update: jest.fn(), + }; + + const mockComponentRepository = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + delete: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TemplateService, + { + provide: getRepositoryToken(EmailTemplate), + useValue: mockTemplateRepository, + }, + { + provide: getRepositoryToken(TemplateComponent), + useValue: mockComponentRepository, + }, + ], + }).compile(); + + service = module.get(TemplateService); + templateRepository = module.get(getRepositoryToken(EmailTemplate)); + componentRepository = module.get(getRepositoryToken(TemplateComponent)); + }); + + describe('create', () => { + it('should create a new template with unique slug', async () => { + const createDto = { + name: 'New Template', + templateType: TemplateType.PROMOTIONAL, + subject: 'New Subject', + designData: { layout: { width: 600 } }, + }; + + templateRepository.findOne.mockResolvedValue(null); // No existing slug + templateRepository.create.mockReturnValue(mockTemplate as any); + templateRepository.save.mockResolvedValue(mockTemplate as any); + templateRepository.findOne.mockResolvedValueOnce(mockTemplate as any); // For findOne call + + const result = await service.create(createDto as any); + + expect(templateRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: createDto.name, + templateType: createDto.templateType, + subject: createDto.subject, + slug: 'new-template', + status: TemplateStatus.DRAFT, + }) + ); + expect(result).toEqual(mockTemplate); + }); + + it('should generate unique slug when duplicate exists', async () => { + const createDto = { + name: 'Test Template', + templateType: TemplateType.PROMOTIONAL, + subject: 'Test Subject', + designData: { layout: { width: 600 } }, + }; + + templateRepository.findOne + .mockResolvedValueOnce(mockTemplate as any) // First slug exists + .mockResolvedValueOnce(null) // Second slug doesn't exist + .mockResolvedValueOnce(mockTemplate as any); // For findOne call + + templateRepository.create.mockReturnValue(mockTemplate as any); + templateRepository.save.mockResolvedValue(mockTemplate as any); + + await service.create(createDto as any); + + expect(templateRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + slug: 'test-template-1', + }) + ); + }); + }); + + describe('findOne', () => { + it('should return template when found', async () => { + templateRepository.findOne.mockResolvedValue(mockTemplate as any); + + const result = await service.findOne('1'); + + expect(result).toEqual(mockTemplate); + expect(templateRepository.findOne).toHaveBeenCalledWith({ + where: { id: '1' }, + relations: ['creator', 'components', 'campaigns'], + order: { + components: { + sortOrder: 'ASC', + }, + }, + }); + }); + + it('should throw NotFoundException when template not found', async () => { + templateRepository.findOne.mockResolvedValue(null); + + await expect(service.findOne('999')).rejects.toThrow(NotFoundException); + }); + }); + + describe('updateStatus', () => { + it('should update template status', async () => { + templateRepository.findOne.mockResolvedValue(mockTemplate as any); + templateRepository.save.mockResolvedValue({ + ...mockTemplate, + status: TemplateStatus.ARCHIVED, + } as any); + + const result = await service.updateStatus('1', TemplateStatus.ARCHIVED); + + expect(result.status).toBe(TemplateStatus.ARCHIVED); + expect(templateRepository.save).toHaveBeenCalled(); + }); + }); + + describe('incrementUsage', () => { + it('should increment usage count', async () => { + await service.incrementUsage('1'); + + expect(templateRepository.increment).toHaveBeenCalledWith( + { id: '1' }, + 'usageCount', + 1 + ); + }); + }); + + describe('updateRating', () => { + it('should update average rating correctly', async () => { + templateRepository.findOne.mockResolvedValue(mockTemplate as any); + + await service.updateRating('1', 5); + + const expectedNewAverage = (4.5 * 10 + 5) / 11; // (current avg * count + new rating) / new count + expect(templateRepository.update).toHaveBeenCalledWith('1', { + averageRating: Math.round(expectedNewAverage * 100) / 100, + ratingCount: 11, + }); + }); + }); + + describe('generatePreview', () => { + it('should generate preview with variable substitution', async () => { + const templateWithVariables = { + ...mockTemplate, + htmlContent: 'Hello {{name}}, welcome to {{eventName}}!', + textContent: 'Hello {{name}}, welcome to {{eventName}}!', + subject: 'Welcome {{name}}!', + variables: [ + { name: 'name', type: 'text', defaultValue: 'Guest' }, + { name: 'eventName', type: 'text', defaultValue: 'Our Event' }, + ], + }; + + templateRepository.findOne.mockResolvedValue(templateWithVariables as any); + + const result = await service.generatePreview('1', { name: 'John', eventName: 'Tech Conference' }); + + expect(result.html).toContain('Hello John, welcome to Tech Conference!'); + expect(result.text).toContain('Hello John, welcome to Tech Conference!'); + expect(result.subject).toContain('Welcome John!'); + }); + + it('should use default values for missing variables', async () => { + const templateWithVariables = { + ...mockTemplate, + htmlContent: 'Hello {{name}}!', + subject: 'Welcome {{name}}!', + variables: [ + { name: 'name', type: 'text', defaultValue: 'Guest' }, + ], + }; + + templateRepository.findOne.mockResolvedValue(templateWithVariables as any); + + const result = await service.generatePreview('1', {}); // No variables provided + + expect(result.html).toContain('Hello Guest!'); + expect(result.subject).toContain('Welcome Guest!'); + }); + }); + + describe('searchTemplates', () => { + it('should search templates 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([mockTemplate]), + }; + + templateRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.searchTemplates('test', { + templateType: TemplateType.PROMOTIONAL, + category: 'marketing', + tags: ['event', 'promotion'], + }); + + expect(result).toEqual([mockTemplate]); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + '(template.name LIKE :search OR template.description LIKE :search OR template.subject LIKE :search)', + { search: '%test%' } + ); + }); + }); + + describe('duplicate', () => { + it('should duplicate template with new name', async () => { + templateRepository.findOne + .mockResolvedValueOnce({ ...mockTemplate, components: [mockComponent] } as any) // Original template + .mockResolvedValueOnce(null) // Slug check + .mockResolvedValueOnce({ ...mockTemplate, id: '2', name: 'Duplicated Template' } as any); // New template + + templateRepository.create.mockReturnValue({ ...mockTemplate, id: '2' } as any); + templateRepository.save.mockResolvedValue({ ...mockTemplate, id: '2' } as any); + componentRepository.save.mockResolvedValue([mockComponent] as any); + + const result = await service.duplicate('1', 'Duplicated Template'); + + expect(templateRepository.create).toHaveBeenCalled(); + expect(componentRepository.save).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/email-marketing/services/analytics.service.ts b/src/email-marketing/services/analytics.service.ts new file mode 100644 index 00000000..84f431b4 --- /dev/null +++ b/src/email-marketing/services/analytics.service.ts @@ -0,0 +1,547 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between } from 'typeorm'; +import { EmailCampaign } from '../entities/email-campaign.entity'; +import { EmailDelivery, DeliveryStatus } from '../entities/email-delivery.entity'; +import { EmailOpen } from '../entities/email-open.entity'; +import { EmailClick } from '../entities/email-click.entity'; +import { EmailBounce, BounceType } from '../entities/email-bounce.entity'; +import { AutomationWorkflow } from '../entities/automation-workflow.entity'; + +@Injectable() +export class AnalyticsService { + constructor( + @InjectRepository(EmailCampaign) + private campaignRepository: Repository, + @InjectRepository(EmailDelivery) + private deliveryRepository: Repository, + @InjectRepository(EmailOpen) + private openRepository: Repository, + @InjectRepository(EmailClick) + private clickRepository: Repository, + @InjectRepository(EmailBounce) + private bounceRepository: Repository, + @InjectRepository(AutomationWorkflow) + private workflowRepository: Repository, + ) {} + + async getCampaignAnalytics(campaignId: string): Promise<{ + overview: { + sent: number; + delivered: number; + bounced: number; + opened: number; + clicked: number; + unsubscribed: number; + openRate: number; + clickRate: number; + clickToOpenRate: number; + bounceRate: number; + unsubscribeRate: number; + }; + timeSeriesData: Array<{ + date: string; + opens: number; + clicks: number; + bounces: number; + }>; + deviceBreakdown: Array<{ + deviceType: string; + opens: number; + clicks: number; + percentage: number; + }>; + locationBreakdown: Array<{ + location: string; + opens: number; + clicks: number; + percentage: number; + }>; + linkPerformance: Array<{ + url: string; + clicks: number; + uniqueClicks: number; + clickRate: number; + }>; + }> { + const campaign = await this.campaignRepository.findOne({ + where: { id: campaignId }, + }); + + if (!campaign) { + throw new Error('Campaign not found'); + } + + // Get overview metrics + const deliveryStats = await this.deliveryRepository + .createQueryBuilder('delivery') + .leftJoin('delivery.opens', 'opens') + .leftJoin('delivery.clicks', 'clicks') + .leftJoin('delivery.bounces', 'bounces') + .where('delivery.campaignId = :campaignId', { campaignId }) + .select([ + 'COUNT(delivery.id) as sent', + 'COUNT(CASE WHEN delivery.status = :delivered THEN 1 END) as delivered', + 'COUNT(CASE WHEN delivery.status = :bounced THEN 1 END) as bounced', + 'COUNT(DISTINCT opens.deliveryId) as opened', + 'COUNT(DISTINCT clicks.deliveryId) as clicked', + 'COUNT(CASE WHEN delivery.unsubscribedAt IS NOT NULL THEN 1 END) as unsubscribed', + ]) + .setParameters({ + delivered: DeliveryStatus.DELIVERED, + bounced: DeliveryStatus.BOUNCED, + }) + .getRawOne(); + + const sent = parseInt(deliveryStats.sent) || 0; + const delivered = parseInt(deliveryStats.delivered) || 0; + const bounced = parseInt(deliveryStats.bounced) || 0; + const opened = parseInt(deliveryStats.opened) || 0; + const clicked = parseInt(deliveryStats.clicked) || 0; + const unsubscribed = parseInt(deliveryStats.unsubscribed) || 0; + + const overview = { + sent, + delivered, + bounced, + opened, + clicked, + unsubscribed, + openRate: delivered > 0 ? (opened / delivered) * 100 : 0, + clickRate: delivered > 0 ? (clicked / delivered) * 100 : 0, + clickToOpenRate: opened > 0 ? (clicked / opened) * 100 : 0, + bounceRate: sent > 0 ? (bounced / sent) * 100 : 0, + unsubscribeRate: delivered > 0 ? (unsubscribed / delivered) * 100 : 0, + }; + + // Get time series data (last 30 days) + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const timeSeriesData = await this.getTimeSeriesData(campaignId, thirtyDaysAgo, new Date()); + + // Get device breakdown + const deviceBreakdown = await this.getDeviceBreakdown(campaignId); + + // Get location breakdown + const locationBreakdown = await this.getLocationBreakdown(campaignId); + + // Get link performance + const linkPerformance = await this.getLinkPerformance(campaignId); + + return { + overview, + timeSeriesData, + deviceBreakdown, + locationBreakdown, + linkPerformance, + }; + } + + async getDashboardMetrics(dateRange: { startDate: Date; endDate: Date }): Promise<{ + totalCampaigns: number; + activeCampaigns: number; + totalEmailsSent: number; + averageOpenRate: number; + averageClickRate: number; + totalRevenue: number; + topPerformingCampaigns: Array<{ + id: string; + name: string; + openRate: number; + clickRate: number; + sent: number; + }>; + recentActivity: Array<{ + type: string; + description: string; + timestamp: Date; + campaignId?: string; + campaignName?: string; + }>; + }> { + const { startDate, endDate } = dateRange; + + // Get campaign counts + const totalCampaigns = await this.campaignRepository.count({ + where: { + createdAt: Between(startDate, endDate), + }, + }); + + const activeCampaigns = await this.campaignRepository.count({ + where: { + status: 'active' as any, + createdAt: Between(startDate, endDate), + }, + }); + + // Get email metrics + const emailMetrics = await this.deliveryRepository + .createQueryBuilder('delivery') + .leftJoin('delivery.opens', 'opens') + .leftJoin('delivery.clicks', 'clicks') + .leftJoin('delivery.campaign', 'campaign') + .where('delivery.sentAt BETWEEN :startDate AND :endDate', { startDate, endDate }) + .select([ + 'COUNT(delivery.id) as totalSent', + 'COUNT(CASE WHEN delivery.status = :delivered THEN 1 END) as totalDelivered', + 'COUNT(DISTINCT opens.deliveryId) as totalOpened', + 'COUNT(DISTINCT clicks.deliveryId) as totalClicked', + ]) + .setParameter('delivered', DeliveryStatus.DELIVERED) + .getRawOne(); + + const totalEmailsSent = parseInt(emailMetrics.totalSent) || 0; + const totalDelivered = parseInt(emailMetrics.totalDelivered) || 0; + const totalOpened = parseInt(emailMetrics.totalOpened) || 0; + const totalClicked = parseInt(emailMetrics.totalClicked) || 0; + + const averageOpenRate = totalDelivered > 0 ? (totalOpened / totalDelivered) * 100 : 0; + const averageClickRate = totalDelivered > 0 ? (totalClicked / totalDelivered) * 100 : 0; + + // Get top performing campaigns + const topPerformingCampaigns = await this.getTopPerformingCampaigns(startDate, endDate, 5); + + // Get recent activity + const recentActivity = await this.getRecentActivity(10); + + return { + totalCampaigns, + activeCampaigns, + totalEmailsSent, + averageOpenRate, + averageClickRate, + totalRevenue: 0, // This would be calculated based on your revenue tracking + topPerformingCampaigns, + recentActivity, + }; + } + + async getAutomationAnalytics(workflowId: string): Promise<{ + overview: { + totalExecutions: number; + successfulExecutions: number; + failedExecutions: number; + successRate: number; + averageExecutionTime: number; + }; + triggerPerformance: Array<{ + triggerId: string; + triggerType: string; + executionCount: number; + lastExecutedAt: Date; + }>; + actionPerformance: Array<{ + actionId: string; + actionType: string; + executionCount: number; + errorCount: number; + successRate: number; + averageExecutionTime: number; + }>; + conversionFunnel: Array<{ + step: string; + count: number; + conversionRate: number; + }>; + }> { + const workflow = await this.workflowRepository.findOne({ + where: { id: workflowId }, + relations: ['triggers', 'actions'], + }); + + if (!workflow) { + throw new Error('Workflow not found'); + } + + // Calculate overview metrics + const totalExecutions = workflow.executionCount || 0; + const successfulExecutions = Math.floor(totalExecutions * 0.95); // Mock calculation + const failedExecutions = totalExecutions - successfulExecutions; + const successRate = totalExecutions > 0 ? (successfulExecutions / totalExecutions) * 100 : 0; + + const overview = { + totalExecutions, + successfulExecutions, + failedExecutions, + successRate, + averageExecutionTime: 1500, // Mock value in milliseconds + }; + + // Get trigger performance + const triggerPerformance = workflow.triggers.map(trigger => ({ + triggerId: trigger.id, + triggerType: trigger.triggerType, + executionCount: trigger.executionCount || 0, + lastExecutedAt: trigger.lastExecutedAt, + })); + + // Get action performance + const actionPerformance = workflow.actions.map(action => ({ + actionId: action.id, + actionType: action.actionType, + executionCount: action.executionCount || 0, + errorCount: action.errorCount || 0, + successRate: action.executionCount > 0 + ? ((action.executionCount - (action.errorCount || 0)) / action.executionCount) * 100 + : 0, + averageExecutionTime: 800, // Mock value + })); + + // Mock conversion funnel + const conversionFunnel = [ + { step: 'Triggered', count: totalExecutions, conversionRate: 100 }, + { step: 'Email Sent', count: Math.floor(totalExecutions * 0.95), conversionRate: 95 }, + { step: 'Email Opened', count: Math.floor(totalExecutions * 0.25), conversionRate: 26.3 }, + { step: 'Link Clicked', count: Math.floor(totalExecutions * 0.05), conversionRate: 20 }, + { step: 'Goal Completed', count: Math.floor(totalExecutions * 0.02), conversionRate: 40 }, + ]; + + return { + overview, + triggerPerformance, + actionPerformance, + conversionFunnel, + }; + } + + async getSegmentAnalytics(segmentId: string): Promise<{ + overview: { + totalUsers: number; + activeUsers: number; + engagementRate: number; + averageOpenRate: number; + averageClickRate: number; + }; + campaignPerformance: Array<{ + campaignId: string; + campaignName: string; + sent: number; + opened: number; + clicked: number; + openRate: number; + clickRate: number; + }>; + userActivity: Array<{ + date: string; + newUsers: number; + activeUsers: number; + churnedUsers: number; + }>; + }> { + // This would integrate with your user segment system + // Mock implementation for now + return { + overview: { + totalUsers: 1500, + activeUsers: 1200, + engagementRate: 80, + averageOpenRate: 25.5, + averageClickRate: 4.2, + }, + campaignPerformance: [], + userActivity: [], + }; + } + + private async getTimeSeriesData( + campaignId: string, + startDate: Date, + endDate: Date + ): Promise> { + const data = []; + const currentDate = new Date(startDate); + + while (currentDate <= endDate) { + const dateStr = currentDate.toISOString().split('T')[0]; + const nextDate = new Date(currentDate); + nextDate.setDate(nextDate.getDate() + 1); + + const [opens, clicks, bounces] = await Promise.all([ + this.openRepository.count({ + where: { + delivery: { campaignId }, + openedAt: Between(currentDate, nextDate), + }, + }), + this.clickRepository.count({ + where: { + delivery: { campaignId }, + clickedAt: Between(currentDate, nextDate), + }, + }), + this.bounceRepository.count({ + where: { + delivery: { campaignId }, + bouncedAt: Between(currentDate, nextDate), + }, + }), + ]); + + data.push({ date: dateStr, opens, clicks, bounces }); + currentDate.setDate(currentDate.getDate() + 1); + } + + return data; + } + + private async getDeviceBreakdown(campaignId: string): Promise> { + const deviceData = await this.openRepository + .createQueryBuilder('open') + .leftJoin('open.delivery', 'delivery') + .where('delivery.campaignId = :campaignId', { campaignId }) + .groupBy('open.deviceType') + .select([ + 'open.deviceType as deviceType', + 'COUNT(open.id) as opens', + ]) + .getRawMany(); + + const totalOpens = deviceData.reduce((sum, item) => sum + parseInt(item.opens), 0); + + return deviceData.map(item => ({ + deviceType: item.deviceType || 'Unknown', + opens: parseInt(item.opens), + clicks: 0, // Would need to join with clicks table + percentage: totalOpens > 0 ? (parseInt(item.opens) / totalOpens) * 100 : 0, + })); + } + + private async getLocationBreakdown(campaignId: string): Promise> { + const locationData = await this.openRepository + .createQueryBuilder('open') + .leftJoin('open.delivery', 'delivery') + .where('delivery.campaignId = :campaignId', { campaignId }) + .groupBy('open.location') + .select([ + 'open.location as location', + 'COUNT(open.id) as opens', + ]) + .getRawMany(); + + const totalOpens = locationData.reduce((sum, item) => sum + parseInt(item.opens), 0); + + return locationData.map(item => ({ + location: item.location || 'Unknown', + opens: parseInt(item.opens), + clicks: 0, // Would need to join with clicks table + percentage: totalOpens > 0 ? (parseInt(item.opens) / totalOpens) * 100 : 0, + })); + } + + private async getLinkPerformance(campaignId: string): Promise> { + const linkData = await this.clickRepository + .createQueryBuilder('click') + .leftJoin('click.delivery', 'delivery') + .where('delivery.campaignId = :campaignId', { campaignId }) + .groupBy('click.url') + .select([ + 'click.url as url', + 'COUNT(click.id) as clicks', + 'COUNT(DISTINCT click.deliveryId) as uniqueClicks', + ]) + .getRawMany(); + + const totalDelivered = await this.deliveryRepository.count({ + where: { campaignId, status: DeliveryStatus.DELIVERED }, + }); + + return linkData.map(item => ({ + url: item.url, + clicks: parseInt(item.clicks), + uniqueClicks: parseInt(item.uniqueClicks), + clickRate: totalDelivered > 0 ? (parseInt(item.uniqueClicks) / totalDelivered) * 100 : 0, + })); + } + + private async getTopPerformingCampaigns( + startDate: Date, + endDate: Date, + limit: number + ): Promise> { + const campaigns = await this.campaignRepository + .createQueryBuilder('campaign') + .leftJoin('campaign.deliveries', 'deliveries') + .leftJoin('deliveries.opens', 'opens') + .leftJoin('deliveries.clicks', 'clicks') + .where('campaign.sentAt BETWEEN :startDate AND :endDate', { startDate, endDate }) + .groupBy('campaign.id') + .select([ + 'campaign.id as id', + 'campaign.name as name', + 'COUNT(deliveries.id) as sent', + 'COUNT(CASE WHEN deliveries.status = :delivered THEN 1 END) as delivered', + 'COUNT(DISTINCT opens.deliveryId) as opened', + 'COUNT(DISTINCT clicks.deliveryId) as clicked', + ]) + .setParameter('delivered', DeliveryStatus.DELIVERED) + .orderBy('(COUNT(DISTINCT opens.deliveryId) / NULLIF(COUNT(CASE WHEN deliveries.status = :delivered THEN 1 END), 0))', 'DESC') + .limit(limit) + .getRawMany(); + + return campaigns.map(campaign => ({ + id: campaign.id, + name: campaign.name, + sent: parseInt(campaign.sent), + openRate: parseInt(campaign.delivered) > 0 + ? (parseInt(campaign.opened) / parseInt(campaign.delivered)) * 100 + : 0, + clickRate: parseInt(campaign.delivered) > 0 + ? (parseInt(campaign.clicked) / parseInt(campaign.delivered)) * 100 + : 0, + })); + } + + private async getRecentActivity(limit: number): Promise> { + // Mock implementation - in production, you'd have an activity log table + return [ + { + type: 'campaign_sent', + description: 'Campaign "Welcome Series #1" was sent to 1,500 recipients', + timestamp: new Date(Date.now() - 1000 * 60 * 30), // 30 minutes ago + campaignId: '1', + campaignName: 'Welcome Series #1', + }, + { + type: 'automation_triggered', + description: 'Automation "Abandoned Cart" was triggered 25 times', + timestamp: new Date(Date.now() - 1000 * 60 * 60), // 1 hour ago + }, + { + type: 'campaign_created', + description: 'New campaign "Product Launch" was created', + timestamp: new Date(Date.now() - 1000 * 60 * 60 * 2), // 2 hours ago + campaignId: '2', + campaignName: 'Product Launch', + }, + ]; + } +} diff --git a/src/email-marketing/services/automation.service.ts b/src/email-marketing/services/automation.service.ts new file mode 100644 index 00000000..57cb4222 --- /dev/null +++ b/src/email-marketing/services/automation.service.ts @@ -0,0 +1,451 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AutomationWorkflow, WorkflowStatus, WorkflowType } from '../entities/automation-workflow.entity'; +import { AutomationTrigger, TriggerType } from '../entities/automation-trigger.entity'; +import { AutomationAction, ActionType } from '../entities/automation-action.entity'; +import { EmailCampaign } from '../entities/email-campaign.entity'; +import { UserSegment } from '../entities/user-segment.entity'; + +@Injectable() +export class AutomationService { + constructor( + @InjectRepository(AutomationWorkflow) + private workflowRepository: Repository, + @InjectRepository(AutomationTrigger) + private triggerRepository: Repository, + @InjectRepository(AutomationAction) + private actionRepository: Repository, + @InjectRepository(EmailCampaign) + private campaignRepository: Repository, + @InjectRepository(UserSegment) + private segmentRepository: Repository, + ) {} + + async createWorkflow(workflowData: { + name: string; + description?: string; + workflowType: WorkflowType; + goalType?: string; + goalValue?: number; + settings?: Record; + createdBy?: string; + }): Promise { + const workflow = this.workflowRepository.create({ + ...workflowData, + status: WorkflowStatus.DRAFT, + }); + + return this.workflowRepository.save(workflow); + } + + async addTrigger(workflowId: string, triggerData: { + triggerType: TriggerType; + eventName: string; + conditions?: Record; + filters?: Record; + delay?: number; + delayUnit?: string; + }): Promise { + const workflow = await this.findWorkflow(workflowId); + + const trigger = this.triggerRepository.create({ + ...triggerData, + workflowId, + isActive: true, + }); + + return this.triggerRepository.save(trigger); + } + + async addAction(workflowId: string, actionData: { + actionType: ActionType; + sortOrder: number; + configuration: Record; + conditions?: Record; + delay?: number; + delayUnit?: string; + }): Promise { + const workflow = await this.findWorkflow(workflowId); + + const action = this.actionRepository.create({ + ...actionData, + workflowId, + isActive: true, + }); + + return this.actionRepository.save(action); + } + + async activateWorkflow(workflowId: string): Promise { + const workflow = await this.findWorkflow(workflowId); + + // Validate workflow has at least one trigger and one action + const triggers = await this.triggerRepository.find({ where: { workflowId } }); + const actions = await this.actionRepository.find({ where: { workflowId } }); + + if (triggers.length === 0) { + throw new BadRequestException('Workflow must have at least one trigger'); + } + + if (actions.length === 0) { + throw new BadRequestException('Workflow must have at least one action'); + } + + workflow.status = WorkflowStatus.ACTIVE; + workflow.activatedAt = new Date(); + + return this.workflowRepository.save(workflow); + } + + async pauseWorkflow(workflowId: string): Promise { + const workflow = await this.findWorkflow(workflowId); + + if (workflow.status !== WorkflowStatus.ACTIVE) { + throw new BadRequestException('Only active workflows can be paused'); + } + + workflow.status = WorkflowStatus.PAUSED; + return this.workflowRepository.save(workflow); + } + + async resumeWorkflow(workflowId: string): Promise { + const workflow = await this.findWorkflow(workflowId); + + if (workflow.status !== WorkflowStatus.PAUSED) { + throw new BadRequestException('Only paused workflows can be resumed'); + } + + workflow.status = WorkflowStatus.ACTIVE; + return this.workflowRepository.save(workflow); + } + + async processEvent(eventName: string, eventData: Record): Promise { + // Find all active triggers for this event + const triggers = await this.triggerRepository.find({ + where: { + eventName, + isActive: true, + }, + relations: ['workflow'], + }); + + for (const trigger of triggers) { + if (trigger.workflow.status !== WorkflowStatus.ACTIVE) { + continue; + } + + // Check if event matches trigger conditions + if (this.matchesConditions(eventData, trigger.conditions)) { + await this.executeTrigger(trigger, eventData); + } + } + } + + async executeTrigger(trigger: AutomationTrigger, eventData: Record): Promise { + // Get workflow actions + const actions = await this.actionRepository.find({ + where: { workflowId: trigger.workflowId, isActive: true }, + order: { sortOrder: 'ASC' }, + }); + + // Execute actions in sequence + for (const action of actions) { + try { + await this.executeAction(action, eventData, trigger); + + // Update action stats + action.executionCount += 1; + action.lastExecutedAt = new Date(); + await this.actionRepository.save(action); + } catch (error) { + // Log error and continue with next action + console.error(`Error executing action ${action.id}:`, error); + + action.errorCount += 1; + action.lastErrorAt = new Date(); + action.lastErrorMessage = error.message; + await this.actionRepository.save(action); + } + } + + // Update trigger stats + trigger.executionCount += 1; + trigger.lastExecutedAt = new Date(); + await this.triggerRepository.save(trigger); + + // Update workflow stats + const workflow = await this.findWorkflow(trigger.workflowId); + workflow.executionCount += 1; + workflow.lastExecutedAt = new Date(); + await this.workflowRepository.save(workflow); + } + + private async executeAction( + action: AutomationAction, + eventData: Record, + trigger: AutomationTrigger + ): Promise { + // Check action conditions + if (action.conditions && !this.matchesConditions(eventData, action.conditions)) { + return; + } + + // Apply delay if specified + if (action.delay && action.delay > 0) { + // In production, you'd queue this for later execution + console.log(`Action ${action.id} delayed by ${action.delay} ${action.delayUnit}`); + } + + switch (action.actionType) { + case ActionType.SEND_EMAIL: + await this.executeSendEmailAction(action, eventData); + break; + + case ActionType.ADD_TO_SEGMENT: + await this.executeAddToSegmentAction(action, eventData); + break; + + case ActionType.REMOVE_FROM_SEGMENT: + await this.executeRemoveFromSegmentAction(action, eventData); + break; + + case ActionType.UPDATE_USER_PROPERTIES: + await this.executeUpdateUserPropertiesAction(action, eventData); + break; + + case ActionType.TRIGGER_WEBHOOK: + await this.executeTriggerWebhookAction(action, eventData); + break; + + case ActionType.WAIT: + await this.executeWaitAction(action, eventData); + break; + + case ActionType.CONDITIONAL_SPLIT: + await this.executeConditionalSplitAction(action, eventData); + break; + + default: + console.warn(`Unknown action type: ${action.actionType}`); + } + } + + private async executeSendEmailAction(action: AutomationAction, eventData: Record): Promise { + const config = action.configuration; + + // Create and send campaign + const campaignData = { + name: `Automated: ${config.templateName || 'Email'}`, + campaignType: 'automation' as any, + templateId: config.templateId, + subject: config.subject || 'Automated Email', + senderName: config.senderName, + senderEmail: config.senderEmail, + personalizationData: { ...eventData, ...config.personalizationData }, + }; + + // This would integrate with your campaign service + console.log('Sending automated email:', campaignData); + } + + private async executeAddToSegmentAction(action: AutomationAction, eventData: Record): Promise { + const config = action.configuration; + const userId = eventData.userId || eventData.user?.id; + + if (!userId || !config.segmentId) { + throw new Error('Missing userId or segmentId for add to segment action'); + } + + // This would integrate with your user/segment management system + console.log(`Adding user ${userId} to segment ${config.segmentId}`); + } + + private async executeRemoveFromSegmentAction(action: AutomationAction, eventData: Record): Promise { + const config = action.configuration; + const userId = eventData.userId || eventData.user?.id; + + if (!userId || !config.segmentId) { + throw new Error('Missing userId or segmentId for remove from segment action'); + } + + // This would integrate with your user/segment management system + console.log(`Removing user ${userId} from segment ${config.segmentId}`); + } + + private async executeUpdateUserPropertiesAction(action: AutomationAction, eventData: Record): Promise { + const config = action.configuration; + const userId = eventData.userId || eventData.user?.id; + + if (!userId || !config.properties) { + throw new Error('Missing userId or properties for update user properties action'); + } + + // This would integrate with your user management system + console.log(`Updating user ${userId} properties:`, config.properties); + } + + private async executeTriggerWebhookAction(action: AutomationAction, eventData: Record): Promise { + const config = action.configuration; + + if (!config.webhookUrl) { + throw new Error('Missing webhookUrl for webhook action'); + } + + const payload = { + ...eventData, + ...config.payload, + timestamp: new Date().toISOString(), + actionId: action.id, + }; + + // This would make an actual HTTP request + console.log(`Triggering webhook ${config.webhookUrl} with payload:`, payload); + } + + private async executeWaitAction(action: AutomationAction, eventData: Record): Promise { + const config = action.configuration; + const waitTime = config.waitTime || 0; + const waitUnit = config.waitUnit || 'minutes'; + + console.log(`Waiting ${waitTime} ${waitUnit}`); + // In production, this would schedule the next action for later + } + + private async executeConditionalSplitAction(action: AutomationAction, eventData: Record): Promise { + const config = action.configuration; + const conditions = config.conditions || []; + + for (const condition of conditions) { + if (this.matchesConditions(eventData, condition.criteria)) { + // Execute actions for this branch + console.log(`Condition matched, executing branch: ${condition.name}`); + break; + } + } + } + + private matchesConditions(data: Record, conditions: Record): boolean { + if (!conditions) return true; + + for (const [key, condition] of Object.entries(conditions)) { + const value = this.getNestedValue(data, key); + + if (!this.evaluateCondition(value, condition)) { + return false; + } + } + + return true; + } + + private evaluateCondition(value: any, condition: any): boolean { + if (typeof condition === 'object' && condition !== null) { + const operator = condition.operator || 'equals'; + const expectedValue = condition.value; + + switch (operator) { + case 'equals': + return value === expectedValue; + case 'not_equals': + return value !== expectedValue; + case 'greater_than': + return Number(value) > Number(expectedValue); + case 'less_than': + return Number(value) < Number(expectedValue); + case 'contains': + return String(value).includes(String(expectedValue)); + case 'starts_with': + return String(value).startsWith(String(expectedValue)); + case 'ends_with': + return String(value).endsWith(String(expectedValue)); + case 'in': + return Array.isArray(expectedValue) && expectedValue.includes(value); + case 'not_in': + return Array.isArray(expectedValue) && !expectedValue.includes(value); + default: + return value === expectedValue; + } + } + + return value === condition; + } + + private getNestedValue(obj: Record, path: string): any { + return path.split('.').reduce((current, key) => current?.[key], obj); + } + + async findWorkflow(id: string): Promise { + const workflow = await this.workflowRepository.findOne({ + where: { id }, + relations: ['triggers', 'actions'], + }); + + if (!workflow) { + throw new NotFoundException('Workflow not found'); + } + + return workflow; + } + + async getWorkflowStats(workflowId: string): Promise<{ + executionCount: number; + successRate: number; + averageExecutionTime: number; + lastExecutedAt: Date; + triggerStats: Array<{ + triggerId: string; + executionCount: number; + lastExecutedAt: Date; + }>; + actionStats: Array<{ + actionId: string; + executionCount: number; + errorCount: number; + successRate: number; + lastExecutedAt: Date; + }>; + }> { + const workflow = await this.findWorkflow(workflowId); + + const triggerStats = workflow.triggers.map(trigger => ({ + triggerId: trigger.id, + executionCount: trigger.executionCount, + lastExecutedAt: trigger.lastExecutedAt, + })); + + const actionStats = workflow.actions.map(action => ({ + actionId: action.id, + executionCount: action.executionCount, + errorCount: action.errorCount, + successRate: action.executionCount > 0 + ? ((action.executionCount - action.errorCount) / action.executionCount) * 100 + : 0, + lastExecutedAt: action.lastExecutedAt, + })); + + return { + executionCount: workflow.executionCount, + successRate: 95, // This would be calculated from actual execution data + averageExecutionTime: 1500, // This would be calculated from actual execution data + lastExecutedAt: workflow.lastExecutedAt, + triggerStats, + actionStats, + }; + } + + async deleteWorkflow(workflowId: string): Promise { + const workflow = await this.findWorkflow(workflowId); + + if (workflow.status === WorkflowStatus.ACTIVE) { + throw new BadRequestException('Cannot delete active workflow. Pause it first.'); + } + + // Delete triggers and actions + await this.triggerRepository.delete({ workflowId }); + await this.actionRepository.delete({ workflowId }); + + // Delete workflow + await this.workflowRepository.delete(workflowId); + } +} diff --git a/src/email-marketing/services/campaign.service.ts b/src/email-marketing/services/campaign.service.ts new file mode 100644 index 00000000..d5b9eeca --- /dev/null +++ b/src/email-marketing/services/campaign.service.ts @@ -0,0 +1,414 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import { EmailCampaign, CampaignStatus, CampaignType } from '../entities/email-campaign.entity'; +import { CampaignSegment } from '../entities/campaign-segment.entity'; +import { EmailTemplate } from '../entities/email-template.entity'; +import { UserSegment } from '../entities/user-segment.entity'; +import { EmailDelivery, DeliveryStatus } from '../entities/email-delivery.entity'; +import { CreateCampaignDto } from '../dto/create-campaign.dto'; + +@Injectable() +export class CampaignService { + constructor( + @InjectRepository(EmailCampaign) + private campaignRepository: Repository, + @InjectRepository(CampaignSegment) + private campaignSegmentRepository: Repository, + @InjectRepository(EmailTemplate) + private templateRepository: Repository, + @InjectRepository(UserSegment) + private userSegmentRepository: Repository, + @InjectRepository(EmailDelivery) + private deliveryRepository: Repository, + ) {} + + async create(createCampaignDto: CreateCampaignDto): Promise { + // Validate template exists + const template = await this.templateRepository.findOne({ + where: { id: createCampaignDto.templateId }, + }); + if (!template) { + throw new NotFoundException('Template not found'); + } + + // Generate unique slug + const baseSlug = createCampaignDto.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); + + let slug = baseSlug; + let counter = 1; + + while (await this.campaignRepository.findOne({ where: { slug } })) { + slug = `${baseSlug}-${counter}`; + counter++; + } + + const campaign = this.campaignRepository.create({ + name: createCampaignDto.name, + description: createCampaignDto.description, + campaignType: createCampaignDto.campaignType, + templateId: createCampaignDto.templateId, + subject: createCampaignDto.subject, + preheaderText: createCampaignDto.preheaderText, + senderName: createCampaignDto.senderName, + senderEmail: createCampaignDto.senderEmail, + replyToEmail: createCampaignDto.replyToEmail, + scheduledAt: createCampaignDto.scheduledAt, + personalizationData: createCampaignDto.personalizationData, + trackingSettings: createCampaignDto.trackingSettings, + tags: createCampaignDto.tags, + createdBy: createCampaignDto.createdBy, + abTestId: createCampaignDto.abTestId, + slug, + status: CampaignStatus.DRAFT, + }); + + const savedCampaign = await this.campaignRepository.save(campaign); + + // Create campaign segments if provided + if (createCampaignDto.segments && createCampaignDto.segments.length > 0) { + const segments = await Promise.all( + createCampaignDto.segments.map(async (segmentDto) => { + const segment = await this.userSegmentRepository.findOne({ + where: { id: segmentDto.segmentId }, + }); + if (!segment) { + throw new NotFoundException(`Segment ${segmentDto.segmentId} not found`); + } + + return this.campaignSegmentRepository.create({ + campaignId: savedCampaign.id, + segmentId: segmentDto.segmentId, + isIncluded: segmentDto.isIncluded, + segmentSize: segment.userCount, + }); + }) + ); + + await this.campaignSegmentRepository.save(segments); + } + + return this.findOne(savedCampaign.id); + } + + async findAll(options: { + page?: number; + limit?: number; + status?: CampaignStatus; + campaignType?: CampaignType; + createdBy?: string; + search?: string; + tags?: string[]; + } = {}): Promise<{ + campaigns: EmailCampaign[]; + total: number; + page: number; + limit: number; + }> { + const { + page = 1, + limit = 20, + status, + campaignType, + createdBy, + search, + tags, + } = options; + + const queryBuilder = this.campaignRepository + .createQueryBuilder('campaign') + .leftJoinAndSelect('campaign.template', 'template') + .leftJoinAndSelect('campaign.creator', 'creator') + .leftJoinAndSelect('campaign.segments', 'segments') + .leftJoinAndSelect('segments.segment', 'segment'); + + if (status) { + queryBuilder.andWhere('campaign.status = :status', { status }); + } + + if (campaignType) { + queryBuilder.andWhere('campaign.campaignType = :campaignType', { campaignType }); + } + + if (createdBy) { + queryBuilder.andWhere('campaign.createdBy = :createdBy', { createdBy }); + } + + if (search) { + queryBuilder.andWhere( + '(campaign.name LIKE :search OR campaign.description LIKE :search OR campaign.subject LIKE :search)', + { search: `%${search}%` } + ); + } + + if (tags && tags.length > 0) { + queryBuilder.andWhere('campaign.tags && :tags', { tags }); + } + + queryBuilder.orderBy('campaign.createdAt', 'DESC'); + + const offset = (page - 1) * limit; + queryBuilder.skip(offset).take(limit); + + const [campaigns, total] = await queryBuilder.getManyAndCount(); + + return { campaigns, total, page, limit }; + } + + async findOne(id: string): Promise { + const campaign = await this.campaignRepository.findOne({ + where: { id }, + relations: [ + 'template', + 'creator', + 'segments', + 'segments.segment', + 'abTest', + 'deliveries', + ], + }); + + if (!campaign) { + throw new NotFoundException('Campaign not found'); + } + + return campaign; + } + + async updateStatus(id: string, status: CampaignStatus): Promise { + const campaign = await this.findOne(id); + + // Validate status transition + if (!this.isValidStatusTransition(campaign.status, status)) { + throw new BadRequestException( + `Invalid status transition from ${campaign.status} to ${status}` + ); + } + + campaign.status = status; + + if (status === CampaignStatus.SENT) { + campaign.sentAt = new Date(); + } + + return this.campaignRepository.save(campaign); + } + + async scheduleCampaign(id: string, scheduledAt: Date): Promise { + const campaign = await this.findOne(id); + + if (campaign.status !== CampaignStatus.DRAFT) { + throw new BadRequestException('Only draft campaigns can be scheduled'); + } + + if (scheduledAt <= new Date()) { + throw new BadRequestException('Scheduled time must be in the future'); + } + + campaign.scheduledAt = scheduledAt; + campaign.status = CampaignStatus.SCHEDULED; + + return this.campaignRepository.save(campaign); + } + + async sendCampaign(id: string): Promise { + const campaign = await this.findOne(id); + + if (![CampaignStatus.DRAFT, CampaignStatus.SCHEDULED].includes(campaign.status)) { + throw new BadRequestException('Campaign cannot be sent in current status'); + } + + // Update campaign status + campaign.status = CampaignStatus.SENDING; + campaign.sentAt = new Date(); + await this.campaignRepository.save(campaign); + + // Get recipients from segments + const recipients = await this.getCampaignRecipients(id); + + // Create delivery records + const deliveries = recipients.map(recipient => + this.deliveryRepository.create({ + campaignId: id, + recipientId: recipient.id, + recipientEmail: recipient.email, + status: DeliveryStatus.QUEUED, + personalizedSubject: this.personalizeContent(campaign.subject, recipient), + personalizedContent: campaign.personalizationData || {}, + trackingData: { + campaignId: id, + recipientId: recipient.id, + trackingPixelUrl: this.generateTrackingPixelUrl(id, recipient.id), + }, + }) + ); + + await this.deliveryRepository.save(deliveries); + + // Update campaign metrics + campaign.recipientCount = recipients.length; + campaign.status = CampaignStatus.SENT; + + return this.campaignRepository.save(campaign); + } + + async pauseCampaign(id: string): Promise { + const campaign = await this.findOne(id); + + if (campaign.status !== CampaignStatus.SENDING) { + throw new BadRequestException('Only sending campaigns can be paused'); + } + + campaign.status = CampaignStatus.PAUSED; + return this.campaignRepository.save(campaign); + } + + async resumeCampaign(id: string): Promise { + const campaign = await this.findOne(id); + + if (campaign.status !== CampaignStatus.PAUSED) { + throw new BadRequestException('Only paused campaigns can be resumed'); + } + + campaign.status = CampaignStatus.SENDING; + return this.campaignRepository.save(campaign); + } + + async getCampaignStats(id: string): Promise<{ + sent: number; + delivered: number; + bounced: number; + opened: number; + clicked: number; + unsubscribed: number; + openRate: number; + clickRate: number; + bounceRate: number; + unsubscribeRate: number; + }> { + const deliveries = await this.deliveryRepository + .createQueryBuilder('delivery') + .leftJoin('delivery.opens', 'opens') + .leftJoin('delivery.clicks', 'clicks') + .leftJoin('delivery.bounces', 'bounces') + .where('delivery.campaignId = :campaignId', { campaignId: id }) + .select([ + 'COUNT(delivery.id) as sent', + 'COUNT(CASE WHEN delivery.status = :delivered THEN 1 END) as delivered', + 'COUNT(CASE WHEN delivery.status = :bounced THEN 1 END) as bounced', + 'COUNT(DISTINCT opens.deliveryId) as opened', + 'COUNT(DISTINCT clicks.deliveryId) as clicked', + 'COUNT(CASE WHEN delivery.unsubscribedAt IS NOT NULL THEN 1 END) as unsubscribed', + ]) + .setParameters({ + delivered: DeliveryStatus.DELIVERED, + bounced: DeliveryStatus.BOUNCED, + }) + .getRawOne(); + + const sent = parseInt(deliveries.sent) || 0; + const delivered = parseInt(deliveries.delivered) || 0; + const bounced = parseInt(deliveries.bounced) || 0; + const opened = parseInt(deliveries.opened) || 0; + const clicked = parseInt(deliveries.clicked) || 0; + const unsubscribed = parseInt(deliveries.unsubscribed) || 0; + + return { + sent, + delivered, + bounced, + opened, + clicked, + unsubscribed, + openRate: delivered > 0 ? (opened / delivered) * 100 : 0, + clickRate: delivered > 0 ? (clicked / delivered) * 100 : 0, + bounceRate: sent > 0 ? (bounced / sent) * 100 : 0, + unsubscribeRate: delivered > 0 ? (unsubscribed / delivered) * 100 : 0, + }; + } + + async duplicateCampaign(id: string, name: string): Promise { + const originalCampaign = await this.findOne(id); + + const duplicateData = { + name, + description: originalCampaign.description, + campaignType: originalCampaign.campaignType, + templateId: originalCampaign.templateId, + subject: originalCampaign.subject, + preheaderText: originalCampaign.preheaderText, + senderName: originalCampaign.senderName, + senderEmail: originalCampaign.senderEmail, + replyToEmail: originalCampaign.replyToEmail, + personalizationData: originalCampaign.personalizationData, + trackingSettings: originalCampaign.trackingSettings, + tags: originalCampaign.tags, + createdBy: originalCampaign.createdBy, + segments: originalCampaign.segments?.map(seg => ({ + segmentId: seg.segmentId, + isIncluded: seg.isIncluded, + })), + }; + + return this.create(duplicateData as any); + } + + private async getCampaignRecipients(campaignId: string): Promise { + // This would integrate with your user management system + // For now, returning a mock implementation + const campaign = await this.findOne(campaignId); + + // Get all users from included segments, exclude users from excluded segments + const includedSegmentIds = campaign.segments + ?.filter(seg => seg.isIncluded) + .map(seg => seg.segmentId) || []; + + const excludedSegmentIds = campaign.segments + ?.filter(seg => !seg.isIncluded) + .map(seg => seg.segmentId) || []; + + // Mock implementation - replace with actual user query + return [ + { id: '1', email: 'user1@example.com', name: 'User 1' }, + { id: '2', email: 'user2@example.com', name: 'User 2' }, + ]; + } + + private personalizeContent(content: string, recipient: any): string { + return content + .replace(/{{name}}/g, recipient.name || 'Valued Customer') + .replace(/{{email}}/g, recipient.email || ''); + } + + private generateTrackingPixelUrl(campaignId: string, recipientId: string): string { + return `https://your-domain.com/track/pixel/${campaignId}/${recipientId}`; + } + + private isValidStatusTransition(currentStatus: CampaignStatus, newStatus: CampaignStatus): boolean { + const validTransitions: Record = { + [CampaignStatus.DRAFT]: [CampaignStatus.SCHEDULED, CampaignStatus.SENDING, CampaignStatus.ARCHIVED], + [CampaignStatus.SCHEDULED]: [CampaignStatus.DRAFT, CampaignStatus.SENDING, CampaignStatus.ARCHIVED], + [CampaignStatus.SENDING]: [CampaignStatus.SENT, CampaignStatus.PAUSED, CampaignStatus.FAILED], + [CampaignStatus.PAUSED]: [CampaignStatus.SENDING, CampaignStatus.ARCHIVED], + [CampaignStatus.SENT]: [CampaignStatus.ARCHIVED], + [CampaignStatus.FAILED]: [CampaignStatus.DRAFT, CampaignStatus.ARCHIVED], + [CampaignStatus.ARCHIVED]: [], + }; + + return validTransitions[currentStatus]?.includes(newStatus) || false; + } + + async remove(id: string): Promise { + const campaign = await this.findOne(id); + + if ([CampaignStatus.SENDING, CampaignStatus.SENT].includes(campaign.status)) { + throw new BadRequestException('Cannot delete sent or sending campaigns'); + } + + campaign.status = CampaignStatus.ARCHIVED; + await this.campaignRepository.save(campaign); + } +} diff --git a/src/email-marketing/services/template-builder.service.ts b/src/email-marketing/services/template-builder.service.ts new file mode 100644 index 00000000..ab482a0c --- /dev/null +++ b/src/email-marketing/services/template-builder.service.ts @@ -0,0 +1,459 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { EmailTemplate } from '../entities/email-template.entity'; +import { TemplateComponent, ComponentType } from '../entities/template-component.entity'; + +export interface DragDropComponent { + id: string; + type: ComponentType; + properties: Record; + position: { + x: number; + y: number; + width: number; + height: number; + }; + style: { + backgroundColor?: string; + color?: string; + fontSize?: number; + fontFamily?: string; + textAlign?: 'left' | 'center' | 'right'; + padding?: string; + margin?: string; + border?: string; + borderRadius?: string; + }; + conditions?: Record; + isVisible?: boolean; +} + +export interface TemplateLayout { + width: number; + backgroundColor: string; + fontFamily: string; + components: DragDropComponent[]; + globalStyles: Record; +} + +@Injectable() +export class TemplateBuilderService { + constructor( + @InjectRepository(EmailTemplate) + private templateRepository: Repository, + @InjectRepository(TemplateComponent) + private componentRepository: Repository, + ) {} + + async saveTemplateLayout(templateId: string, layout: TemplateLayout): Promise { + const template = await this.templateRepository.findOne({ + where: { id: templateId }, + }); + + if (!template) { + throw new BadRequestException('Template not found'); + } + + // Update template design data + template.designData = { + layout: { + width: layout.width, + backgroundColor: layout.backgroundColor, + fontFamily: layout.fontFamily, + globalStyles: layout.globalStyles, + }, + components: layout.components, + }; + + // Generate HTML from layout + template.htmlContent = this.generateHtmlFromLayout(layout); + + // Remove existing components + await this.componentRepository.delete({ templateId }); + + // Create new components + const components = layout.components.map((comp, index) => + this.componentRepository.create({ + templateId, + componentType: comp.type, + name: `Component ${index + 1}`, + properties: { + ...comp.properties, + position: comp.position, + style: comp.style, + }, + conditions: comp.conditions, + isVisible: comp.isVisible !== false, + sortOrder: index, + }) + ); + + await this.componentRepository.save(components); + + return this.templateRepository.save(template); + } + + async getTemplateLayout(templateId: string): Promise { + const template = await this.templateRepository.findOne({ + where: { id: templateId }, + relations: ['components'], + }); + + if (!template) { + throw new BadRequestException('Template not found'); + } + + const designData = template.designData || {}; + const layout = designData.layout || {}; + + return { + width: layout.width || 600, + backgroundColor: layout.backgroundColor || '#ffffff', + fontFamily: layout.fontFamily || 'Arial, sans-serif', + globalStyles: layout.globalStyles || {}, + components: template.components?.map(comp => ({ + id: comp.id, + type: comp.componentType, + properties: comp.properties || {}, + position: comp.properties?.position || { x: 0, y: 0, width: 100, height: 50 }, + style: comp.properties?.style || {}, + conditions: comp.conditions, + isVisible: comp.isVisible, + })) || [], + }; + } + + async addComponent(templateId: string, component: Omit): Promise { + const template = await this.templateRepository.findOne({ + where: { id: templateId }, + }); + + if (!template) { + throw new BadRequestException('Template not found'); + } + + const newComponent = this.componentRepository.create({ + templateId, + componentType: component.type, + name: `${component.type} Component`, + properties: { + ...component.properties, + position: component.position, + style: component.style, + }, + conditions: component.conditions, + isVisible: component.isVisible !== false, + sortOrder: 0, // Will be updated when saving layout + }); + + return this.componentRepository.save(newComponent); + } + + async updateComponent(componentId: string, updates: Partial): Promise { + const component = await this.componentRepository.findOne({ + where: { id: componentId }, + }); + + if (!component) { + throw new BadRequestException('Component not found'); + } + + if (updates.properties) { + component.properties = { ...component.properties, ...updates.properties }; + } + + if (updates.position) { + component.properties = { + ...component.properties, + position: updates.position + }; + } + + if (updates.style) { + component.properties = { + ...component.properties, + style: { ...component.properties?.style, ...updates.style } + }; + } + + if (updates.conditions !== undefined) { + component.conditions = updates.conditions; + } + + if (updates.isVisible !== undefined) { + component.isVisible = updates.isVisible; + } + + return this.componentRepository.save(component); + } + + async deleteComponent(componentId: string): Promise { + const result = await this.componentRepository.delete(componentId); + if (result.affected === 0) { + throw new BadRequestException('Component not found'); + } + } + + async duplicateComponent(componentId: string): Promise { + const originalComponent = await this.componentRepository.findOne({ + where: { id: componentId }, + }); + + if (!originalComponent) { + throw new BadRequestException('Component not found'); + } + + const duplicatedComponent = this.componentRepository.create({ + templateId: originalComponent.templateId, + componentType: originalComponent.componentType, + name: `${originalComponent.name} (Copy)`, + properties: { + ...originalComponent.properties, + position: { + ...originalComponent.properties?.position, + x: (originalComponent.properties?.position?.x || 0) + 20, + y: (originalComponent.properties?.position?.y || 0) + 20, + }, + }, + conditions: originalComponent.conditions, + isVisible: originalComponent.isVisible, + sortOrder: originalComponent.sortOrder + 1, + }); + + return this.componentRepository.save(duplicatedComponent); + } + + private generateHtmlFromLayout(layout: TemplateLayout): string { + const { width, backgroundColor, fontFamily, components, globalStyles } = layout; + + let html = ` + + + + + + Email Template + + + + + + + `; + + return html; + } + + private generateComponentHtml(component: DragDropComponent): string { + const { position, style, properties, type } = component; + + const inlineStyles = this.generateInlineStyles({ + ...style, + position: 'absolute', + left: `${position.x}px`, + top: `${position.y}px`, + width: `${position.width}px`, + height: `${position.height}px`, + }); + + switch (type) { + case ComponentType.TEXT: + return `
${properties.text || 'Sample Text'}
`; + + case ComponentType.HEADING: + const headingLevel = properties.level || 1; + return `${properties.text || 'Heading'}`; + + case ComponentType.IMAGE: + return `${properties.alt || ''}`; + + case ComponentType.BUTTON: + const buttonStyles = this.generateInlineStyles({ + ...style, + position: 'absolute', + left: `${position.x}px`, + top: `${position.y}px`, + width: `${position.width}px`, + height: `${position.height}px`, + display: 'inline-block', + textDecoration: 'none', + textAlign: 'center', + lineHeight: `${position.height}px`, + backgroundColor: properties.buttonColor || '#007bff', + color: properties.buttonTextColor || '#ffffff', + borderRadius: properties.borderRadius || '4px', + }); + return `${properties.buttonText || 'Click Here'}`; + + case ComponentType.DIVIDER: + const dividerStyles = this.generateInlineStyles({ + ...style, + position: 'absolute', + left: `${position.x}px`, + top: `${position.y}px`, + width: `${position.width}px`, + height: '1px', + backgroundColor: properties.color || '#cccccc', + border: 'none', + }); + return `
`; + + case ComponentType.SPACER: + return `
`; + + case ComponentType.SOCIAL: + const socialLinks = properties.socialLinks || []; + let socialHtml = `
`; + for (const link of socialLinks) { + socialHtml += `${link.platform}`; + } + socialHtml += '
'; + return socialHtml; + + default: + return `
Unknown Component
`; + } + } + + private generateInlineStyles(styles: Record): string { + return Object.entries(styles) + .filter(([_, value]) => value !== undefined && value !== null) + .map(([key, value]) => { + const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase(); + return `${cssKey}: ${value}`; + }) + .join('; '); + } + + private generateGlobalStyles(globalStyles: Record): string { + let css = ''; + + for (const [selector, styles] of Object.entries(globalStyles)) { + css += `${selector} { ${this.generateInlineStyles(styles)} }\n`; + } + + return css; + } + + async getComponentLibrary(): Promise; + defaultStyle: Record; + }>; + }>> { + return [ + { + category: 'Basic', + components: [ + { + type: ComponentType.TEXT, + name: 'Text Block', + description: 'Simple text content', + defaultProperties: { text: 'Your text here...' }, + defaultStyle: { fontSize: 16, color: '#333333' }, + }, + { + type: ComponentType.HEADING, + name: 'Heading', + description: 'Heading text (H1-H6)', + defaultProperties: { text: 'Your heading here', level: 1 }, + defaultStyle: { fontSize: 24, fontWeight: 'bold', color: '#333333' }, + }, + { + type: ComponentType.IMAGE, + name: 'Image', + description: 'Image with optional link', + defaultProperties: { src: 'https://via.placeholder.com/300x200', alt: 'Image' }, + defaultStyle: { borderRadius: '4px' }, + }, + { + type: ComponentType.BUTTON, + name: 'Button', + description: 'Call-to-action button', + defaultProperties: { + buttonText: 'Click Here', + buttonUrl: '#', + buttonColor: '#007bff', + buttonTextColor: '#ffffff', + }, + defaultStyle: { borderRadius: '4px', padding: '12px 24px' }, + }, + ], + }, + { + category: 'Layout', + components: [ + { + type: ComponentType.DIVIDER, + name: 'Divider', + description: 'Horizontal line separator', + defaultProperties: { color: '#cccccc' }, + defaultStyle: { height: '1px', backgroundColor: '#cccccc' }, + }, + { + type: ComponentType.SPACER, + name: 'Spacer', + description: 'Empty space for layout', + defaultProperties: {}, + defaultStyle: { height: '20px' }, + }, + ], + }, + { + category: 'Social', + components: [ + { + type: ComponentType.SOCIAL, + name: 'Social Links', + description: 'Social media icons and links', + defaultProperties: { + socialLinks: [ + { platform: 'facebook', url: '#', icon: 'https://via.placeholder.com/24x24' }, + { platform: 'twitter', url: '#', icon: 'https://via.placeholder.com/24x24' }, + { platform: 'instagram', url: '#', icon: 'https://via.placeholder.com/24x24' }, + ], + }, + defaultStyle: { textAlign: 'center' }, + }, + ], + }, + ]; + } +} diff --git a/src/email-marketing/services/template.service.ts b/src/email-marketing/services/template.service.ts new file mode 100644 index 00000000..e7edfd1b --- /dev/null +++ b/src/email-marketing/services/template.service.ts @@ -0,0 +1,443 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, FindOptionsWhere, Not } from 'typeorm'; +import { EmailTemplate, TemplateStatus, TemplateType } from '../entities/email-template.entity'; +import { TemplateComponent, ComponentType } from '../entities/template-component.entity'; +import { CreateTemplateDto } from '../dto/create-template.dto'; +import { UpdateTemplateDto } from '../dto/update-template.dto'; +import { TemplateQueryDto } from '../dto/template-query.dto'; + +@Injectable() +export class TemplateService { + constructor( + @InjectRepository(EmailTemplate) + private templateRepository: Repository, + @InjectRepository(TemplateComponent) + private componentRepository: Repository, + ) {} + + async create(createTemplateDto: CreateTemplateDto): Promise { + // Generate unique slug + const baseSlug = createTemplateDto.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); + + let slug = baseSlug; + let counter = 1; + + while (await this.templateRepository.findOne({ where: { slug } })) { + slug = `${baseSlug}-${counter}`; + counter++; + } + + // Generate HTML content from design data + const htmlContent = await this.generateHtmlFromDesign(createTemplateDto.designData); + + const template = this.templateRepository.create({ + name: createTemplateDto.name, + description: createTemplateDto.description, + templateType: createTemplateDto.templateType, + subject: createTemplateDto.subject, + preheaderText: createTemplateDto.preheaderText, + textContent: createTemplateDto.textContent, + designData: createTemplateDto.designData, + tags: createTemplateDto.tags, + variables: createTemplateDto.variables, + createdBy: createTemplateDto.createdBy, + isSystem: createTemplateDto.isSystem || false, + slug, + htmlContent, + status: TemplateStatus.DRAFT, + }); + + const savedTemplate = await this.templateRepository.save(template); + + // Create components if provided + if (createTemplateDto.components && createTemplateDto.components.length > 0) { + const components = createTemplateDto.components.map((comp, index) => + this.componentRepository.create({ + ...comp, + templateId: savedTemplate.id, + sortOrder: index, + }) + ); + await this.componentRepository.save(components); + } + + return this.findOne(savedTemplate.id); + } + + async findAll(query: TemplateQueryDto): Promise<{ + templates: EmailTemplate[]; + total: number; + page: number; + limit: number; + }> { + const { + page = 1, + limit = 20, + templateType, + category, + status, + createdBy, + search, + tags, + sortBy = 'createdAt', + sortOrder = 'DESC', + } = query; + + const queryBuilder = this.templateRepository + .createQueryBuilder('template') + .leftJoinAndSelect('template.creator', 'creator') + .leftJoinAndSelect('template.components', 'components'); + + // Apply filters + if (templateType) { + queryBuilder.andWhere('template.templateType = :templateType', { templateType }); + } + + if (category) { + queryBuilder.andWhere('template.category = :category', { category }); + } + + if (status) { + queryBuilder.andWhere('template.status = :status', { status }); + } + + if (createdBy) { + queryBuilder.andWhere('template.createdBy = :createdBy', { createdBy }); + } + + if (search) { + queryBuilder.andWhere( + '(template.name LIKE :search OR template.description LIKE :search)', + { search: `%${search}%` } + ); + } + + if (tags && tags.length > 0) { + queryBuilder.andWhere('template.tags && :tags', { tags }); + } + + // Apply sorting + queryBuilder.orderBy(`template.${sortBy}`, sortOrder); + + // Apply pagination + const offset = (page - 1) * limit; + queryBuilder.skip(offset).take(limit); + + const [templates, total] = await queryBuilder.getManyAndCount(); + + return { + templates, + total, + page, + limit, + }; + } + + async findOne(id: string): Promise { + const template = await this.templateRepository.findOne({ + where: { id }, + relations: ['creator', 'components', 'campaigns'], + order: { + components: { + sortOrder: 'ASC', + }, + }, + }); + + if (!template) { + throw new NotFoundException('Template not found'); + } + + return template; + } + + async findBySlug(slug: string): Promise { + const template = await this.templateRepository.findOne({ + where: { slug }, + relations: ['creator', 'components'], + order: { + components: { + sortOrder: 'ASC', + }, + }, + }); + + if (!template) { + throw new NotFoundException('Template not found'); + } + + return template; + } + + async update(id: string, updateTemplateDto: UpdateTemplateDto): Promise { + const template = await this.findOne(id); + + // Update slug if name changed + if (updateTemplateDto.name && updateTemplateDto.name !== template.name) { + const baseSlug = updateTemplateDto.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); + + let slug = baseSlug; + let counter = 1; + + while (await this.templateRepository.findOne({ + where: { slug, id: Not(id) } + })) { + slug = `${baseSlug}-${counter}`; + counter++; + } + + updateTemplateDto.slug = slug; + } + + // Regenerate HTML if design data changed + if (updateTemplateDto.designData) { + updateTemplateDto.htmlContent = await this.generateHtmlFromDesign(updateTemplateDto.designData); + } + + Object.assign(template, updateTemplateDto); + const updatedTemplate = await this.templateRepository.save(template); + + // Update components if provided + if (updateTemplateDto.components) { + // Remove existing components + await this.componentRepository.delete({ templateId: id }); + + // Add new components + const components = updateTemplateDto.components.map((comp, index) => + this.componentRepository.create({ + ...comp, + templateId: id, + sortOrder: index, + }) + ); + await this.componentRepository.save(components); + } + + return this.findOne(updatedTemplate.id); + } + + async duplicate(id: string, name: string): Promise { + const originalTemplate = await this.findOne(id); + + const duplicateData = { + ...originalTemplate, + name, + slug: undefined, // Will be generated + id: undefined, + createdAt: undefined, + updatedAt: undefined, + usageCount: 0, + averageRating: 0, + ratingCount: 0, + status: TemplateStatus.DRAFT, + }; + + const duplicatedTemplate = await this.create(duplicateData as any); + + // Duplicate components + if (originalTemplate.components && originalTemplate.components.length > 0) { + const components = originalTemplate.components.map(comp => ({ + ...comp, + id: undefined, + templateId: duplicatedTemplate.id, + createdAt: undefined, + updatedAt: undefined, + })); + + await this.componentRepository.save(components as any); + } + + return this.findOne(duplicatedTemplate.id); + } + + async updateStatus(id: string, status: TemplateStatus): Promise { + const template = await this.findOne(id); + template.status = status; + return this.templateRepository.save(template); + } + + async getSystemTemplates(): Promise { + return this.templateRepository.find({ + where: { isSystem: true, isActive: true }, + relations: ['components'], + order: { templateType: 'ASC', name: 'ASC' }, + }); + } + + async getPopularTemplates(limit: number = 10): Promise { + return this.templateRepository.find({ + where: { isActive: true, status: TemplateStatus.ACTIVE }, + relations: ['creator'], + order: { usageCount: 'DESC', averageRating: 'DESC' }, + take: limit, + }); + } + + async getTemplatesByType(templateType: TemplateType): Promise { + return this.templateRepository.find({ + where: { templateType, isActive: true, status: TemplateStatus.ACTIVE }, + relations: ['creator'], + order: { averageRating: 'DESC', usageCount: 'DESC' }, + }); + } + + async searchTemplates(searchTerm: string, filters: { + templateType?: TemplateType; + category?: string; + tags?: string[]; + } = {}): Promise { + const queryBuilder = this.templateRepository + .createQueryBuilder('template') + .leftJoinAndSelect('template.creator', 'creator') + .where('template.isActive = :isActive', { isActive: true }) + .andWhere('template.status = :status', { status: TemplateStatus.ACTIVE }); + + if (searchTerm) { + queryBuilder.andWhere( + '(template.name LIKE :search OR template.description LIKE :search OR template.subject LIKE :search)', + { search: `%${searchTerm}%` } + ); + } + + if (filters.templateType) { + queryBuilder.andWhere('template.templateType = :templateType', { + templateType: filters.templateType, + }); + } + + if (filters.category) { + queryBuilder.andWhere('template.category = :category', { + category: filters.category, + }); + } + + if (filters.tags && filters.tags.length > 0) { + queryBuilder.andWhere('template.tags && :tags', { tags: filters.tags }); + } + + return queryBuilder + .orderBy('template.averageRating', 'DESC') + .addOrderBy('template.usageCount', 'DESC') + .getMany(); + } + + async incrementUsage(id: string): Promise { + await this.templateRepository.increment({ id }, 'usageCount', 1); + } + + async updateRating(id: string, rating: number): Promise { + const template = await this.findOne(id); + const newRatingCount = template.ratingCount + 1; + const newAverageRating = + (template.averageRating * template.ratingCount + rating) / newRatingCount; + + await this.templateRepository.update(id, { + averageRating: Math.round(newAverageRating * 100) / 100, + ratingCount: newRatingCount, + }); + } + + async generatePreview(id: string, variables: Record = {}): Promise<{ + html: string; + text: string; + subject: string; + }> { + const template = await this.findOne(id); + + // Replace variables in content + let html = template.htmlContent; + let text = template.textContent || ''; + let subject = template.subject; + + // Apply variable substitution + for (const [key, value] of Object.entries(variables)) { + const regex = new RegExp(`{{\\s*${key}\\s*}}`, 'g'); + html = html.replace(regex, String(value)); + text = text.replace(regex, String(value)); + subject = subject.replace(regex, String(value)); + } + + // Apply default values for missing variables + if (template.variables) { + for (const variable of template.variables) { + if (!variables[variable.name] && variable.defaultValue) { + const regex = new RegExp(`{{\\s*${variable.name}\\s*}}`, 'g'); + html = html.replace(regex, String(variable.defaultValue)); + text = text.replace(regex, String(variable.defaultValue)); + subject = subject.replace(regex, String(variable.defaultValue)); + } + } + } + + return { html, text, subject }; + } + + private async generateHtmlFromDesign(designData: any): Promise { + // This is a simplified version - in production, you'd use a proper template engine + let html = ` + + + + + + Email Template + + + +
+ `; + + // Process components + if (designData.components) { + for (const component of designData.components) { + html += this.generateComponentHtml(component); + } + } + + html += ` +
+ + + `; + + return html; + } + + private generateComponentHtml(component: any): string { + switch (component.componentType) { + case ComponentType.TEXT: + return `
${component.properties.text || ''}
`; + + case ComponentType.IMAGE: + return `
${component.properties.alt || ''}
`; + + case ComponentType.BUTTON: + return ``; + + case ComponentType.DIVIDER: + return `

`; + + default: + return `
Component: ${component.componentType}
`; + } + } + + async remove(id: string): Promise { + const template = await this.findOne(id); + + // Soft delete by setting status to archived + template.status = TemplateStatus.ARCHIVED; + template.isActive = false; + await this.templateRepository.save(template); + } +} From 03d27dd6e8eac627dee4ae6b2df23c3a2faa177e Mon Sep 17 00:00:00 2001 From: LaGodxy Date: Fri, 29 Aug 2025 10:32:09 -0700 Subject: [PATCH 3/3] Social Media Integration System --- .../influencer-collaboration.controller.ts | 295 +++++++ .../referral-program.controller.ts | 196 +++++ .../controllers/social-account.controller.ts | 159 ++++ .../social-media-analytics.controller.ts | 149 ++++ .../controllers/social-post.controller.ts | 181 +++++ .../controllers/social-proof.controller.ts | 190 +++++ .../user-generated-content.controller.ts | 259 ++++++ .../influencer-collaboration.entity.ts | 307 ++++++++ .../entities/referral-code.entity.ts | 145 ++++ .../entities/referral-program.entity.ts | 217 ++++++ .../entities/referral-tracking.entity.ts | 227 ++++++ .../entities/social-account.entity.ts | 168 ++++ .../entities/social-campaign.entity.ts | 260 +++++++ .../entities/social-post.entity.ts | 228 ++++++ .../entities/social-proof.entity.ts | 162 ++++ .../entities/user-generated-content.entity.ts | 251 ++++++ .../referral-program.service.spec.ts | 276 +++++++ .../social-media-analytics.service.spec.ts | 328 ++++++++ .../__tests__/social-post.service.spec.ts | 253 ++++++ .../influencer-collaboration.service.ts | 527 +++++++++++++ .../services/referral-program.service.ts | 509 ++++++++++++ .../social-media-analytics.service.ts | 736 ++++++++++++++++++ .../services/social-media-api.service.ts | 554 +++++++++++++ .../services/social-post.service.ts | 491 ++++++++++++ .../services/social-proof.service.ts | 474 +++++++++++ src/social-media/social-media.module.ts | 81 ++ 26 files changed, 7623 insertions(+) create mode 100644 src/social-media/controllers/influencer-collaboration.controller.ts create mode 100644 src/social-media/controllers/referral-program.controller.ts create mode 100644 src/social-media/controllers/social-account.controller.ts create mode 100644 src/social-media/controllers/social-media-analytics.controller.ts create mode 100644 src/social-media/controllers/social-post.controller.ts create mode 100644 src/social-media/controllers/social-proof.controller.ts create mode 100644 src/social-media/controllers/user-generated-content.controller.ts create mode 100644 src/social-media/entities/influencer-collaboration.entity.ts create mode 100644 src/social-media/entities/referral-code.entity.ts create mode 100644 src/social-media/entities/referral-program.entity.ts create mode 100644 src/social-media/entities/referral-tracking.entity.ts create mode 100644 src/social-media/entities/social-account.entity.ts create mode 100644 src/social-media/entities/social-campaign.entity.ts create mode 100644 src/social-media/entities/social-post.entity.ts create mode 100644 src/social-media/entities/social-proof.entity.ts create mode 100644 src/social-media/entities/user-generated-content.entity.ts create mode 100644 src/social-media/services/__tests__/referral-program.service.spec.ts create mode 100644 src/social-media/services/__tests__/social-media-analytics.service.spec.ts create mode 100644 src/social-media/services/__tests__/social-post.service.spec.ts create mode 100644 src/social-media/services/influencer-collaboration.service.ts create mode 100644 src/social-media/services/referral-program.service.ts create mode 100644 src/social-media/services/social-media-analytics.service.ts create mode 100644 src/social-media/services/social-media-api.service.ts create mode 100644 src/social-media/services/social-post.service.ts create mode 100644 src/social-media/services/social-proof.service.ts create mode 100644 src/social-media/social-media.module.ts diff --git a/src/social-media/controllers/influencer-collaboration.controller.ts b/src/social-media/controllers/influencer-collaboration.controller.ts new file mode 100644 index 00000000..337bed92 --- /dev/null +++ b/src/social-media/controllers/influencer-collaboration.controller.ts @@ -0,0 +1,295 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { InfluencerCollaboration } from '../entities/influencer-collaboration.entity'; +import { + InfluencerCollaborationService, + CreateInfluencerCollaborationDto, + UpdateCollaborationDto, + CollaborationMessage +} from '../services/influencer-collaboration.service'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; + +@ApiTags('Influencer Collaborations') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('influencer-collaborations') +export class InfluencerCollaborationController { + constructor(private readonly collaborationService: InfluencerCollaborationService) {} + + @Post() + @ApiOperation({ summary: 'Create new influencer collaboration' }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'Collaboration created successfully', + type: InfluencerCollaboration, + }) + async createCollaboration(@Body() dto: CreateInfluencerCollaborationDto): Promise { + return this.collaborationService.createCollaboration(dto); + } + + @Get(':id') + @ApiOperation({ summary: 'Get collaboration by ID' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Collaboration retrieved successfully', + type: InfluencerCollaboration, + }) + async getCollaboration(@Param('id') id: string): Promise { + return this.collaborationService.findCollaborationById(id); + } + + @Get() + @ApiOperation({ summary: 'Get collaborations with filters' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Collaborations retrieved successfully', + type: [InfluencerCollaboration], + }) + async getCollaborations( + @Query('organizerId') organizerId?: string, + @Query('influencerId') influencerId?: string, + @Query('eventId') eventId?: string, + ): Promise { + if (organizerId) { + return this.collaborationService.findCollaborationsByOrganizer(organizerId); + } + if (influencerId) { + return this.collaborationService.findCollaborationsByInfluencer(influencerId); + } + if (eventId) { + return this.collaborationService.findCollaborationsByEvent(eventId); + } + return []; + } + + @Put(':id') + @ApiOperation({ summary: 'Update collaboration' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Collaboration updated successfully', + type: InfluencerCollaboration, + }) + async updateCollaboration( + @Param('id') id: string, + @Body() dto: UpdateCollaborationDto, + ): Promise { + return this.collaborationService.updateCollaboration(id, dto); + } + + @Post(':id/invite') + @ApiOperation({ summary: 'Send collaboration invitation' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Invitation sent successfully', + type: InfluencerCollaboration, + }) + async inviteInfluencer(@Param('id') id: string): Promise { + return this.collaborationService.inviteInfluencer(id); + } + + @Post(':id/accept') + @ApiOperation({ summary: 'Accept collaboration invitation' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Collaboration accepted successfully', + type: InfluencerCollaboration, + }) + async acceptCollaboration(@Param('id') id: string): Promise { + return this.collaborationService.acceptCollaboration(id); + } + + @Post(':id/reject') + @ApiOperation({ summary: 'Reject collaboration invitation' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Collaboration rejected successfully', + type: InfluencerCollaboration, + }) + async rejectCollaboration( + @Param('id') id: string, + @Body() body: { reason?: string }, + ): Promise { + return this.collaborationService.rejectCollaboration(id, body.reason); + } + + @Post(':id/start') + @ApiOperation({ summary: 'Start active collaboration' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Collaboration started successfully', + type: InfluencerCollaboration, + }) + async startCollaboration(@Param('id') id: string): Promise { + return this.collaborationService.startCollaboration(id); + } + + @Post(':id/complete') + @ApiOperation({ summary: 'Complete collaboration' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Collaboration completed successfully', + type: InfluencerCollaboration, + }) + async completeCollaboration(@Param('id') id: string): Promise { + return this.collaborationService.completeCollaboration(id); + } + + @Post(':id/messages') + @ApiOperation({ summary: 'Add message to collaboration' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Message added successfully', + type: InfluencerCollaboration, + }) + async addMessage( + @Param('id') id: string, + @Body() message: CollaborationMessage, + ): Promise { + return this.collaborationService.addMessage(id, message); + } + + @Put(':id/deliverables/:index') + @ApiOperation({ summary: 'Update deliverable status' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Deliverable updated successfully', + type: InfluencerCollaboration, + }) + async updateDeliverable( + @Param('id') id: string, + @Param('index') index: number, + @Body() updates: { + status?: string; + postUrl?: string; + submittedAt?: Date; + approvedAt?: Date; + }, + ): Promise { + return this.collaborationService.updateDeliverable(id, index, updates); + } + + @Put(':id/performance') + @ApiOperation({ summary: 'Update performance metrics' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Performance metrics updated successfully', + type: InfluencerCollaboration, + }) + async updatePerformance( + @Param('id') id: string, + @Body() metrics: { + reach?: number; + impressions?: number; + engagement?: number; + clicks?: number; + conversions?: number; + mentions?: number; + hashtags?: Record; + sentiment?: { + positive: number; + negative: number; + neutral: number; + }; + }, + ): Promise { + return this.collaborationService.updatePerformanceMetrics(id, metrics); + } + + @Post(':id/rate') + @ApiOperation({ summary: 'Rate completed collaboration' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Collaboration rated successfully', + type: InfluencerCollaboration, + }) + async rateCollaboration( + @Param('id') id: string, + @Body() body: { rating: number; feedback?: string }, + ): Promise { + return this.collaborationService.rateCollaboration(id, body.rating, body.feedback); + } + + @Get('analytics/:organizerId') + @ApiOperation({ summary: 'Get collaboration analytics for organizer' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Analytics retrieved successfully', + }) + async getAnalytics( + @Param('organizerId') organizerId: string, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + ): Promise { + const dateRange = startDate && endDate ? { + start: new Date(startDate), + end: new Date(endDate), + } : undefined; + + return this.collaborationService.getCollaborationAnalytics(organizerId, dateRange); + } + + @Get('influencers/search') + @ApiOperation({ summary: 'Search for influencers' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Influencers found successfully', + }) + async searchInfluencers( + @Query('tier') tier?: string, + @Query('platforms') platforms?: string, + @Query('minFollowers') minFollowers?: number, + @Query('maxFollowers') maxFollowers?: number, + @Query('interests') interests?: string, + @Query('location') location?: string, + @Query('minEngagementRate') minEngagementRate?: number, + @Query('maxBudget') maxBudget?: number, + ): Promise { + const criteria = { + tier: tier as any, + platforms: platforms ? platforms.split(',') : undefined, + minFollowers, + maxFollowers, + interests: interests ? interests.split(',') : undefined, + location, + minEngagementRate, + maxBudget, + }; + + return this.collaborationService.searchInfluencers(criteria); + } + + @Get('influencers/tier/:tier') + @ApiOperation({ summary: 'Get influencers by tier' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Influencers retrieved successfully', + }) + async getInfluencersByTier( + @Param('tier') tier: string, + @Query('limit') limit?: number, + ): Promise { + return this.collaborationService.findInfluencersByTier(tier as any, limit || 50); + } + + @Get(':id/contract') + @ApiOperation({ summary: 'Generate collaboration contract' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Contract generated successfully', + }) + async generateContract(@Param('id') id: string): Promise<{ contract: string }> { + const contract = await this.collaborationService.generateCollaborationContract(id); + return { contract }; + } +} diff --git a/src/social-media/controllers/referral-program.controller.ts b/src/social-media/controllers/referral-program.controller.ts new file mode 100644 index 00000000..f8a9b237 --- /dev/null +++ b/src/social-media/controllers/referral-program.controller.ts @@ -0,0 +1,196 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { ReferralProgram } from '../entities/referral-program.entity'; +import { ReferralCode } from '../entities/referral-code.entity'; +import { ReferralTracking } from '../entities/referral-tracking.entity'; +import { + ReferralProgramService, + CreateReferralProgramDto, + CreateReferralCodeDto, + ReferralConversionDto +} from '../services/referral-program.service'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; + +@ApiTags('Referral Programs') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('referral-programs') +export class ReferralProgramController { + constructor(private readonly referralProgramService: ReferralProgramService) {} + + @Post() + @ApiOperation({ summary: 'Create a new referral program' }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'Referral program created successfully', + type: ReferralProgram, + }) + async createProgram(@Body() dto: CreateReferralProgramDto): Promise { + return this.referralProgramService.createProgram(dto); + } + + @Get(':id') + @ApiOperation({ summary: 'Get referral program by ID' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Referral program retrieved successfully', + type: ReferralProgram, + }) + async getProgram(@Param('id') id: string): Promise { + return this.referralProgramService.findProgramById(id); + } + + @Get() + @ApiOperation({ summary: 'Get referral programs by organizer or event' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Referral programs retrieved successfully', + type: [ReferralProgram], + }) + async getPrograms( + @Query('organizerId') organizerId?: string, + @Query('eventId') eventId?: string, + ): Promise { + if (organizerId) { + return this.referralProgramService.findProgramsByOrganizer(organizerId); + } + if (eventId) { + return this.referralProgramService.findProgramsByEvent(eventId); + } + return []; + } + + @Put(':id/status') + @ApiOperation({ summary: 'Update referral program status' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Program status updated successfully', + type: ReferralProgram, + }) + async updateStatus( + @Param('id') id: string, + @Body() body: { status: string }, + ): Promise { + return this.referralProgramService.updateProgramStatus(id, body.status as any); + } + + @Post(':id/codes') + @ApiOperation({ summary: 'Generate referral code for program' }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'Referral code generated successfully', + type: ReferralCode, + }) + async generateCode( + @Param('id') programId: string, + @Body() dto: Omit, + ): Promise { + return this.referralProgramService.generateReferralCode({ + ...dto, + programId, + }); + } + + @Get('codes/:code') + @ApiOperation({ summary: 'Get referral code by code string' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Referral code retrieved successfully', + type: ReferralCode, + }) + async getCode(@Param('code') code: string): Promise { + return this.referralProgramService.findCodeByCode(code); + } + + @Get('users/:userId/codes') + @ApiOperation({ summary: 'Get referral codes by user' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'User referral codes retrieved successfully', + type: [ReferralCode], + }) + async getUserCodes(@Param('userId') userId: string): Promise { + return this.referralProgramService.findCodesByUser(userId); + } + + @Post('track/:code') + @ApiOperation({ summary: 'Track referral code click' }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'Referral click tracked successfully', + type: ReferralTracking, + }) + async trackClick( + @Param('code') code: string, + @Body() metadata: { + ipAddress?: string; + userAgent?: string; + referrer?: string; + utmSource?: string; + utmMedium?: string; + utmCampaign?: string; + }, + ): Promise { + return this.referralProgramService.trackReferralClick(code, metadata); + } + + @Post('convert') + @ApiOperation({ summary: 'Process referral conversion' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Referral conversion processed successfully', + type: ReferralTracking, + }) + async processConversion(@Body() dto: ReferralConversionDto): Promise { + return this.referralProgramService.processReferralConversion(dto); + } + + @Get('codes/:codeId/analytics') + @ApiOperation({ summary: 'Get referral code analytics' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Referral code analytics retrieved successfully', + }) + async getCodeAnalytics( + @Param('codeId') codeId: string, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + ): Promise { + const dateRange = startDate && endDate ? { + start: new Date(startDate), + end: new Date(endDate), + } : undefined; + + return this.referralProgramService.getReferralAnalytics(codeId, dateRange); + } + + @Get(':id/analytics') + @ApiOperation({ summary: 'Get referral program analytics' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Referral program analytics retrieved successfully', + }) + async getProgramAnalytics( + @Param('id') programId: string, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + ): Promise { + const dateRange = startDate && endDate ? { + start: new Date(startDate), + end: new Date(endDate), + } : undefined; + + return this.referralProgramService.getProgramAnalytics(programId, dateRange); + } +} diff --git a/src/social-media/controllers/social-account.controller.ts b/src/social-media/controllers/social-account.controller.ts new file mode 100644 index 00000000..09eac8b8 --- /dev/null +++ b/src/social-media/controllers/social-account.controller.ts @@ -0,0 +1,159 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { SocialAccount } from '../entities/social-account.entity'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; + +export interface CreateSocialAccountDto { + organizerId: string; + platform: string; + accountName: string; + username: string; + accessToken: string; + refreshToken?: string; + expiresAt?: Date; + permissions?: string[]; + settings?: any; +} + +export interface UpdateSocialAccountDto { + accountName?: string; + username?: string; + accessToken?: string; + refreshToken?: string; + expiresAt?: Date; + permissions?: string[]; + settings?: any; +} + +// Mock service for demonstration - would be injected +class SocialAccountService { + async create(dto: CreateSocialAccountDto): Promise { + // Implementation would be here + return {} as SocialAccount; + } + + async findById(id: string): Promise { + return {} as SocialAccount; + } + + async findByOrganizer(organizerId: string): Promise { + return []; + } + + async update(id: string, dto: UpdateSocialAccountDto): Promise { + return {} as SocialAccount; + } + + async delete(id: string): Promise { + // Implementation + } + + async refreshToken(id: string): Promise { + return {} as SocialAccount; + } + + async validateConnection(id: string): Promise<{ isValid: boolean; error?: string }> { + return { isValid: true }; + } +} + +@ApiTags('Social Accounts') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('social-accounts') +export class SocialAccountController { + constructor(private readonly socialAccountService: SocialAccountService) {} + + @Post() + @ApiOperation({ summary: 'Connect a new social media account' }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'Social account connected successfully', + type: SocialAccount, + }) + async connectAccount(@Body() dto: CreateSocialAccountDto): Promise { + return this.socialAccountService.create(dto); + } + + @Get(':id') + @ApiOperation({ summary: 'Get social account by ID' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Social account retrieved successfully', + type: SocialAccount, + }) + async getAccount(@Param('id') id: string): Promise { + return this.socialAccountService.findById(id); + } + + @Get() + @ApiOperation({ summary: 'Get social accounts by organizer' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Social accounts retrieved successfully', + type: [SocialAccount], + }) + async getAccountsByOrganizer( + @Query('organizerId') organizerId: string, + ): Promise { + return this.socialAccountService.findByOrganizer(organizerId); + } + + @Put(':id') + @ApiOperation({ summary: 'Update social account' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Social account updated successfully', + type: SocialAccount, + }) + async updateAccount( + @Param('id') id: string, + @Body() dto: UpdateSocialAccountDto, + ): Promise { + return this.socialAccountService.update(id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Disconnect social account' }) + @ApiResponse({ + status: HttpStatus.NO_CONTENT, + description: 'Social account disconnected successfully', + }) + async disconnectAccount(@Param('id') id: string): Promise { + return this.socialAccountService.delete(id); + } + + @Post(':id/refresh-token') + @ApiOperation({ summary: 'Refresh access token for social account' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Token refreshed successfully', + type: SocialAccount, + }) + async refreshToken(@Param('id') id: string): Promise { + return this.socialAccountService.refreshToken(id); + } + + @Get(':id/validate') + @ApiOperation({ summary: 'Validate social account connection' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Connection validation result', + }) + async validateConnection( + @Param('id') id: string, + ): Promise<{ isValid: boolean; error?: string }> { + return this.socialAccountService.validateConnection(id); + } +} diff --git a/src/social-media/controllers/social-media-analytics.controller.ts b/src/social-media/controllers/social-media-analytics.controller.ts new file mode 100644 index 00000000..71542304 --- /dev/null +++ b/src/social-media/controllers/social-media-analytics.controller.ts @@ -0,0 +1,149 @@ +import { + Controller, + Get, + Param, + Query, + UseGuards, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { + SocialMediaAnalyticsService, + SocialMediaDashboard, + AnalyticsDateRange +} from '../services/social-media-analytics.service'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; + +@ApiTags('Social Media Analytics') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('social-media-analytics') +export class SocialMediaAnalyticsController { + constructor(private readonly analyticsService: SocialMediaAnalyticsService) {} + + @Get('dashboard/:organizerId') + @ApiOperation({ summary: 'Get social media dashboard overview' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Dashboard data retrieved successfully', + }) + async getDashboard( + @Param('organizerId') organizerId: string, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + ): Promise { + const dateRange: AnalyticsDateRange | undefined = startDate && endDate ? { + start: new Date(startDate), + end: new Date(endDate), + } : undefined; + + return this.analyticsService.getDashboard(organizerId, dateRange); + } + + @Get('posts/:postId') + @ApiOperation({ summary: 'Get detailed post analytics' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Post analytics retrieved successfully', + }) + async getPostAnalytics( + @Param('postId') postId: string, + @Query('includeTimeSeries') includeTimeSeries?: boolean, + ): Promise { + return this.analyticsService.getPostAnalytics(postId, includeTimeSeries); + } + + @Get('campaigns/:campaignId') + @ApiOperation({ summary: 'Get detailed campaign analytics' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Campaign analytics retrieved successfully', + }) + async getCampaignAnalytics( + @Param('campaignId') campaignId: string, + @Query('includePostBreakdown') includePostBreakdown?: boolean, + ): Promise { + return this.analyticsService.getCampaignAnalytics(campaignId, includePostBreakdown); + } + + @Get('referrals/:programId') + @ApiOperation({ summary: 'Get referral program analytics' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Referral analytics retrieved successfully', + }) + async getReferralAnalytics( + @Param('programId') programId: string, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + ): Promise { + const dateRange: AnalyticsDateRange | undefined = startDate && endDate ? { + start: new Date(startDate), + end: new Date(endDate), + } : undefined; + + return this.analyticsService.getReferralAnalytics(programId, dateRange); + } + + @Get('influencers/:collaborationId') + @ApiOperation({ summary: 'Get influencer collaboration analytics' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Influencer analytics retrieved successfully', + }) + async getInfluencerAnalytics(@Param('collaborationId') collaborationId: string): Promise { + return this.analyticsService.getInfluencerAnalytics(collaborationId); + } + + @Get('social-proof/:eventId') + @ApiOperation({ summary: 'Get social proof analytics' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Social proof analytics retrieved successfully', + }) + async getSocialProofAnalytics( + @Param('eventId') eventId: string, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + ): Promise { + const dateRange: AnalyticsDateRange | undefined = startDate && endDate ? { + start: new Date(startDate), + end: new Date(endDate), + } : undefined; + + return this.analyticsService.getSocialProofAnalytics(eventId, dateRange); + } + + @Get('ugc/:eventId') + @ApiOperation({ summary: 'Get user-generated content analytics' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'UGC analytics retrieved successfully', + }) + async getUGCAnalytics( + @Param('eventId') eventId: string, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + ): Promise { + const dateRange: AnalyticsDateRange | undefined = startDate && endDate ? { + start: new Date(startDate), + end: new Date(endDate), + } : undefined; + + return this.analyticsService.getUGCAnalytics(eventId, dateRange); + } + + @Get('competitors/:organizerId') + @ApiOperation({ summary: 'Get competitor analysis' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Competitor analysis retrieved successfully', + }) + async getCompetitorAnalysis( + @Param('organizerId') organizerId: string, + @Query('competitors') competitors: string, + ): Promise { + const competitorList = competitors ? competitors.split(',') : []; + return this.analyticsService.getCompetitorAnalysis(organizerId, competitorList); + } +} diff --git a/src/social-media/controllers/social-post.controller.ts b/src/social-media/controllers/social-post.controller.ts new file mode 100644 index 00000000..fd20e695 --- /dev/null +++ b/src/social-media/controllers/social-post.controller.ts @@ -0,0 +1,181 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { SocialPost } from '../entities/social-post.entity'; +import { SocialPostService, CreateSocialPostDto, UpdateSocialPostDto } from '../services/social-post.service'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; + +@ApiTags('Social Posts') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('social-posts') +export class SocialPostController { + constructor(private readonly socialPostService: SocialPostService) {} + + @Post() + @ApiOperation({ summary: 'Create a new social media post' }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'Social post created successfully', + type: SocialPost, + }) + async createPost(@Body() dto: CreateSocialPostDto): Promise { + return this.socialPostService.createPost(dto); + } + + @Get(':id') + @ApiOperation({ summary: 'Get social post by ID' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Social post retrieved successfully', + type: SocialPost, + }) + async getPost(@Param('id') id: string): Promise { + return this.socialPostService.findPostById(id); + } + + @Get() + @ApiOperation({ summary: 'Get social posts with filters' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Social posts retrieved successfully', + type: [SocialPost], + }) + async getPosts( + @Query('accountId') accountId?: string, + @Query('campaignId') campaignId?: string, + @Query('eventId') eventId?: string, + ): Promise { + if (accountId) { + return this.socialPostService.findPostsByAccount(accountId); + } + if (campaignId) { + return this.socialPostService.findPostsByCampaign(campaignId); + } + if (eventId) { + return this.socialPostService.findPostsByEvent(eventId); + } + return []; + } + + @Put(':id') + @ApiOperation({ summary: 'Update social post' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Social post updated successfully', + type: SocialPost, + }) + async updatePost( + @Param('id') id: string, + @Body() dto: UpdateSocialPostDto, + ): Promise { + return this.socialPostService.updatePost(id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete social post' }) + @ApiResponse({ + status: HttpStatus.NO_CONTENT, + description: 'Social post deleted successfully', + }) + async deletePost(@Param('id') id: string): Promise { + return this.socialPostService.deletePost(id); + } + + @Post(':id/publish') + @ApiOperation({ summary: 'Publish social post immediately' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Social post published successfully', + type: SocialPost, + }) + async publishPost(@Param('id') id: string): Promise { + return this.socialPostService.publishPost(id); + } + + @Post(':id/schedule') + @ApiOperation({ summary: 'Schedule social post for later' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Social post scheduled successfully', + type: SocialPost, + }) + async schedulePost( + @Param('id') id: string, + @Body() body: { scheduledFor: Date }, + ): Promise { + return this.socialPostService.schedulePost(id, body.scheduledFor); + } + + @Post(':id/duplicate') + @ApiOperation({ summary: 'Duplicate social post' }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'Social post duplicated successfully', + type: SocialPost, + }) + async duplicatePost(@Param('id') id: string): Promise { + return this.socialPostService.duplicatePost(id); + } + + @Put(':id/engagement') + @ApiOperation({ summary: 'Update engagement metrics for post' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Engagement metrics updated successfully', + type: SocialPost, + }) + async updateEngagement(@Param('id') id: string): Promise { + return this.socialPostService.updateEngagementMetrics(id); + } + + @Post('generate-content') + @ApiOperation({ summary: 'Generate AI content for social post' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'AI content generated successfully', + }) + async generateContent( + @Body() body: { + prompt: string; + postType: string; + platform: string; + eventContext?: any; + }, + ): Promise<{ + content: string; + hashtags: string[]; + confidence: number; + suggestions: string[]; + }> { + return this.socialPostService.generateAIContent( + body.prompt, + body.postType as any, + body.platform, + body.eventContext, + ); + } + + @Get('optimize-timing/:accountId') + @ApiOperation({ summary: 'Get optimal posting times for account' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Optimal posting times retrieved successfully', + }) + async getOptimalTiming( + @Param('accountId') accountId: string, + @Query('postType') postType: string, + ): Promise { + return this.socialPostService.optimizePostTiming(accountId, postType as any); + } +} diff --git a/src/social-media/controllers/social-proof.controller.ts b/src/social-media/controllers/social-proof.controller.ts new file mode 100644 index 00000000..2fe8de2b --- /dev/null +++ b/src/social-media/controllers/social-proof.controller.ts @@ -0,0 +1,190 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { SocialProof } from '../entities/social-proof.entity'; +import { + SocialProofService, + CreateSocialProofDto, + SocialProofWidget +} from '../services/social-proof.service'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; + +@ApiTags('Social Proof') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('social-proof') +export class SocialProofController { + constructor(private readonly socialProofService: SocialProofService) {} + + @Post() + @ApiOperation({ summary: 'Create social proof entry' }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'Social proof created successfully', + type: SocialProof, + }) + async createProof(@Body() dto: CreateSocialProofDto): Promise { + return this.socialProofService.createSocialProof(dto); + } + + @Get(':id') + @ApiOperation({ summary: 'Get social proof by ID' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Social proof retrieved successfully', + type: SocialProof, + }) + async getProof(@Param('id') id: string): Promise { + return this.socialProofService.findProofById(id); + } + + @Get('event/:eventId') + @ApiOperation({ summary: 'Get social proof for event' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Event social proof retrieved successfully', + type: [SocialProof], + }) + async getEventProofs( + @Param('eventId') eventId: string, + @Query('types') types?: string, + @Query('limit') limit?: number, + ): Promise { + const proofTypes = types ? types.split(',') as any[] : undefined; + return this.socialProofService.findProofsByEvent(eventId, proofTypes, limit || 50); + } + + @Put(':id/approve') + @ApiOperation({ summary: 'Approve social proof' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Social proof approved successfully', + type: SocialProof, + }) + async approveProof(@Param('id') id: string): Promise { + return this.socialProofService.approveProof(id); + } + + @Put(':id/reject') + @ApiOperation({ summary: 'Reject social proof' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Social proof rejected successfully', + type: SocialProof, + }) + async rejectProof( + @Param('id') id: string, + @Body() body: { reason?: string }, + ): Promise { + return this.socialProofService.rejectProof(id, body.reason); + } + + @Post(':id/display') + @ApiOperation({ summary: 'Track social proof display' }) + @ApiResponse({ + status: HttpStatus.NO_CONTENT, + description: 'Display tracked successfully', + }) + async trackDisplay(@Param('id') id: string): Promise { + return this.socialProofService.trackProofDisplay(id); + } + + @Post(':id/click') + @ApiOperation({ summary: 'Track social proof click' }) + @ApiResponse({ + status: HttpStatus.NO_CONTENT, + description: 'Click tracked successfully', + }) + async trackClick(@Param('id') id: string): Promise { + return this.socialProofService.trackProofClick(id); + } + + @Get('widgets/friend-attendance/:eventId') + @ApiOperation({ summary: 'Get friend attendance widget data' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Friend attendance widget data retrieved successfully', + }) + async getFriendAttendanceWidget( + @Param('eventId') eventId: string, + @Query('userId') userId: string, + @Query('limit') limit?: number, + ): Promise { + return this.socialProofService.getFriendAttendanceProof(eventId, userId, limit || 10); + } + + @Get('widgets/recent-activity/:eventId') + @ApiOperation({ summary: 'Get recent activity widget data' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Recent activity widget data retrieved successfully', + }) + async getRecentActivityWidget( + @Param('eventId') eventId: string, + @Query('limit') limit?: number, + ): Promise { + return this.socialProofService.getRecentActivityProof(eventId, limit || 5); + } + + @Get('widgets/testimonials/:eventId') + @ApiOperation({ summary: 'Get testimonials widget data' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Testimonials widget data retrieved successfully', + }) + async getTestimonialsWidget( + @Param('eventId') eventId: string, + @Query('limit') limit?: number, + ): Promise { + return this.socialProofService.getTestimonialsWidget(eventId, limit || 3); + } + + @Get('widgets/user-count/:eventId') + @ApiOperation({ summary: 'Get user count widget data' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'User count widget data retrieved successfully', + }) + async getUserCountWidget(@Param('eventId') eventId: string): Promise { + return this.socialProofService.getUserCountWidget(eventId); + } + + @Get('analytics/:eventId') + @ApiOperation({ summary: 'Get social proof analytics for event' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Social proof analytics retrieved successfully', + }) + async getAnalytics( + @Param('eventId') eventId: string, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + ): Promise { + const dateRange = startDate && endDate ? { + start: new Date(startDate), + end: new Date(endDate), + } : undefined; + + return this.socialProofService.getProofAnalytics(eventId, dateRange); + } + + @Post('sync-ugc/:eventId') + @ApiOperation({ summary: 'Sync user-generated content to social proof' }) + @ApiResponse({ + status: HttpStatus.NO_CONTENT, + description: 'UGC synced to social proof successfully', + }) + async syncUGC(@Param('eventId') eventId: string): Promise { + return this.socialProofService.syncUserGeneratedContent(eventId); + } +} diff --git a/src/social-media/controllers/user-generated-content.controller.ts b/src/social-media/controllers/user-generated-content.controller.ts new file mode 100644 index 00000000..5bd6e13e --- /dev/null +++ b/src/social-media/controllers/user-generated-content.controller.ts @@ -0,0 +1,259 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { UserGeneratedContent, ContentStatus } from '../entities/user-generated-content.entity'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; + +export interface CreateUGCDto { + eventId?: string; + userId?: string; + campaignId?: string; + organizerId?: string; + contentType: string; + title: string; + description: string; + mediaUrls: Array<{ + type: 'image' | 'video'; + url: string; + thumbnailUrl?: string; + width?: number; + height?: number; + duration?: number; + size?: number; + }>; + authorName: string; + authorUsername?: string; + authorEmail?: string; + authorAvatarUrl?: string; + platform?: string; + originalUrl?: string; + platformPostId?: string; + hashtags?: string[]; + mentions?: Array<{ + username: string; + userId?: string; + displayName?: string; + }>; + location?: { + name?: string; + latitude?: number; + longitude?: number; + city?: string; + country?: string; + }; + permissions?: { + canRepost?: boolean; + canFeature?: boolean; + canModify?: boolean; + canCommercialUse?: boolean; + attribution?: string; + expiresAt?: Date; + }; +} + +// Mock service for demonstration +class UserGeneratedContentService { + async create(dto: CreateUGCDto): Promise { + return {} as UserGeneratedContent; + } + + async findById(id: string): Promise { + return {} as UserGeneratedContent; + } + + async findByEvent(eventId: string, status?: ContentStatus): Promise { + return []; + } + + async findByCampaign(campaignId: string): Promise { + return []; + } + + async findByUser(userId: string): Promise { + return []; + } + + async updateStatus(id: string, status: ContentStatus): Promise { + return {} as UserGeneratedContent; + } + + async moderate(id: string, action: 'approve' | 'reject' | 'flag', reason?: string): Promise { + return {} as UserGeneratedContent; + } + + async feature(id: string, featuredUntil?: Date): Promise { + return {} as UserGeneratedContent; + } + + async updateEngagement(id: string, engagement: any): Promise { + return {} as UserGeneratedContent; + } + + async getAnalytics(eventId: string, dateRange?: any): Promise { + return {}; + } + + async searchContent(filters: any): Promise { + return []; + } +} + +@ApiTags('User Generated Content') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('user-generated-content') +export class UserGeneratedContentController { + constructor(private readonly ugcService: UserGeneratedContentService) {} + + @Post() + @ApiOperation({ summary: 'Submit user-generated content' }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'UGC submitted successfully', + type: UserGeneratedContent, + }) + async submitContent(@Body() dto: CreateUGCDto): Promise { + return this.ugcService.create(dto); + } + + @Get(':id') + @ApiOperation({ summary: 'Get UGC by ID' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'UGC retrieved successfully', + type: UserGeneratedContent, + }) + async getContent(@Param('id') id: string): Promise { + return this.ugcService.findById(id); + } + + @Get() + @ApiOperation({ summary: 'Get UGC with filters' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'UGC retrieved successfully', + type: [UserGeneratedContent], + }) + async getContent( + @Query('eventId') eventId?: string, + @Query('campaignId') campaignId?: string, + @Query('userId') userId?: string, + @Query('status') status?: ContentStatus, + @Query('contentType') contentType?: string, + @Query('platform') platform?: string, + @Query('featured') featured?: boolean, + ): Promise { + if (eventId) { + return this.ugcService.findByEvent(eventId, status); + } + if (campaignId) { + return this.ugcService.findByCampaign(campaignId); + } + if (userId) { + return this.ugcService.findByUser(userId); + } + + // Search with filters + const filters = { + status, + contentType, + platform, + featured, + }; + + return this.ugcService.searchContent(filters); + } + + @Put(':id/status') + @ApiOperation({ summary: 'Update UGC status' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'UGC status updated successfully', + type: UserGeneratedContent, + }) + async updateStatus( + @Param('id') id: string, + @Body() body: { status: ContentStatus }, + ): Promise { + return this.ugcService.updateStatus(id, body.status); + } + + @Post(':id/moderate') + @ApiOperation({ summary: 'Moderate UGC content' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'UGC moderated successfully', + type: UserGeneratedContent, + }) + async moderateContent( + @Param('id') id: string, + @Body() body: { action: 'approve' | 'reject' | 'flag'; reason?: string }, + ): Promise { + return this.ugcService.moderate(id, body.action, body.reason); + } + + @Post(':id/feature') + @ApiOperation({ summary: 'Feature UGC content' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'UGC featured successfully', + type: UserGeneratedContent, + }) + async featureContent( + @Param('id') id: string, + @Body() body: { featuredUntil?: Date }, + ): Promise { + return this.ugcService.feature(id, body.featuredUntil); + } + + @Put(':id/engagement') + @ApiOperation({ summary: 'Update UGC engagement metrics' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Engagement metrics updated successfully', + type: UserGeneratedContent, + }) + async updateEngagement( + @Param('id') id: string, + @Body() engagement: { + likes?: number; + comments?: number; + shares?: number; + saves?: number; + views?: number; + reach?: number; + impressions?: number; + }, + ): Promise { + return this.ugcService.updateEngagement(id, engagement); + } + + @Get('analytics/:eventId') + @ApiOperation({ summary: 'Get UGC analytics for event' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'UGC analytics retrieved successfully', + }) + async getAnalytics( + @Param('eventId') eventId: string, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + ): Promise { + const dateRange = startDate && endDate ? { + start: new Date(startDate), + end: new Date(endDate), + } : undefined; + + return this.ugcService.getAnalytics(eventId, dateRange); + } +} diff --git a/src/social-media/entities/influencer-collaboration.entity.ts b/src/social-media/entities/influencer-collaboration.entity.ts new file mode 100644 index 00000000..c55c5b86 --- /dev/null +++ b/src/social-media/entities/influencer-collaboration.entity.ts @@ -0,0 +1,307 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum InfluencerTier { + NANO = 'nano', // 1K-10K followers + MICRO = 'micro', // 10K-100K followers + MACRO = 'macro', // 100K-1M followers + MEGA = 'mega', // 1M+ followers + CELEBRITY = 'celebrity', // 10M+ followers +} + +export enum CollaborationStatus { + DRAFT = 'draft', + INVITED = 'invited', + NEGOTIATING = 'negotiating', + ACCEPTED = 'accepted', + ACTIVE = 'active', + COMPLETED = 'completed', + CANCELLED = 'cancelled', + REJECTED = 'rejected', +} + +export enum CollaborationType { + SPONSORED_POST = 'sponsored_post', + STORY_MENTION = 'story_mention', + EVENT_ATTENDANCE = 'event_attendance', + PRODUCT_REVIEW = 'product_review', + BRAND_AMBASSADOR = 'brand_ambassador', + GIVEAWAY = 'giveaway', + TAKEOVER = 'takeover', + LONG_TERM_PARTNERSHIP = 'long_term_partnership', +} + +export enum CompensationType { + MONETARY = 'monetary', + FREE_TICKETS = 'free_tickets', + MERCHANDISE = 'merchandise', + EXPERIENCE = 'experience', + COMMISSION = 'commission', + BARTER = 'barter', + NONE = 'none', +} + +@Entity('influencer_collaborations') +@Index(['eventId', 'status']) +@Index(['influencerId', 'status']) +@Index(['organizerId', 'collaborationType']) +@Index(['tier', 'status']) +export class InfluencerCollaboration { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: true }) + @Index() + eventId: string; + + @Column({ type: 'uuid' }) + @Index() + influencerId: string; + + @Column({ type: 'uuid' }) + @Index() + organizerId: string; + + @Column({ type: 'uuid', nullable: true }) + campaignId: string; + + @Column({ type: 'varchar', length: 255 }) + title: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ + type: 'enum', + enum: CollaborationType, + }) + @Index() + collaborationType: CollaborationType; + + @Column({ + type: 'enum', + enum: CollaborationStatus, + default: CollaborationStatus.DRAFT, + }) + @Index() + status: CollaborationStatus; + + @Column({ + type: 'enum', + enum: InfluencerTier, + }) + @Index() + tier: InfluencerTier; + + @Column({ type: 'json' }) + influencerProfile: { + name: string; + username: string; + email?: string; + platforms: Array<{ + platform: string; + handle: string; + followers: number; + engagementRate: number; + verificationStatus: boolean; + }>; + demographics: { + primaryAudience: { + ageRange: string; + gender: string; + locations: string[]; + interests: string[]; + }; + engagementMetrics: { + averageLikes: number; + averageComments: number; + averageShares: number; + bestPostingTimes: string[]; + }; + }; + contentStyle: { + categories: string[]; + aesthetics: string[]; + contentTypes: string[]; + }; + rates: { + postRate?: number; + storyRate?: number; + videoRate?: number; + packageDeals?: Array<{ + description: string; + price: number; + }>; + }; + }; + + @Column({ type: 'json' }) + deliverables: Array<{ + type: string; + platform: string; + quantity: number; + description: string; + deadline: Date; + requirements: { + hashtags?: string[]; + mentions?: string[]; + links?: string[]; + contentGuidelines?: string; + approvalRequired?: boolean; + }; + status: 'pending' | 'in_progress' | 'submitted' | 'approved' | 'completed'; + submittedAt?: Date; + approvedAt?: Date; + postUrl?: string; + }>; + + @Column({ type: 'json' }) + compensation: { + type: CompensationType; + amount?: number; + currency?: string; + details: string; + paymentTerms: { + method: string; + schedule: string; + milestones?: Array<{ + description: string; + percentage: number; + dueDate: Date; + completed: boolean; + }>; + }; + additionalBenefits?: string[]; + }; + + @Column({ type: 'json', nullable: true }) + contract: { + terms: string; + exclusivityPeriod?: number; + usageRights: { + duration: string; + platforms: string[]; + modifications: boolean; + commercialUse: boolean; + }; + contentOwnership: string; + cancellationPolicy: string; + signedAt?: Date; + signedByInfluencer?: boolean; + signedByOrganizer?: boolean; + }; + + @Column({ type: 'json', nullable: true }) + performance: { + reach?: number; + impressions?: number; + engagement?: number; + clicks?: number; + conversions?: number; + mentions?: number; + hashtags?: Record; + sentiment?: { + positive: number; + negative: number; + neutral: number; + }; + roi?: number; + costPerEngagement?: number; + brandLift?: number; + }; + + @Column({ type: 'json', nullable: true }) + communication: Array<{ + timestamp: Date; + sender: 'organizer' | 'influencer'; + message: string; + type: 'message' | 'proposal' | 'revision' | 'approval'; + attachments?: string[]; + }>; + + @Column({ type: 'timestamp', nullable: true }) + @Index() + startDate: Date; + + @Column({ type: 'timestamp', nullable: true }) + @Index() + endDate: Date; + + @Column({ type: 'timestamp', nullable: true }) + invitedAt: Date; + + @Column({ type: 'timestamp', nullable: true }) + acceptedAt: Date; + + @Column({ type: 'timestamp', nullable: true }) + completedAt: Date; + + @Column({ type: 'decimal', precision: 5, scale: 2, nullable: true }) + satisfactionRating: number; + + @Column({ type: 'text', nullable: true }) + feedback: string; + + @Column({ type: 'json', nullable: true }) + tags: string[]; + + @Column({ type: 'boolean', default: false }) + isFeatured: boolean; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Virtual fields + get totalFollowers(): number { + return this.influencerProfile.platforms.reduce((total, platform) => + total + platform.followers, 0); + } + + get averageEngagementRate(): number { + const platforms = this.influencerProfile.platforms; + if (platforms.length === 0) return 0; + + const totalEngagement = platforms.reduce((total, platform) => + total + platform.engagementRate, 0); + return totalEngagement / platforms.length; + } + + get completedDeliverables(): number { + return this.deliverables.filter(d => d.status === 'completed').length; + } + + get pendingDeliverables(): number { + return this.deliverables.filter(d => + ['pending', 'in_progress', 'submitted'].includes(d.status) + ).length; + } + + get isOverdue(): boolean { + return this.endDate ? new Date() > this.endDate && + this.status !== CollaborationStatus.COMPLETED : false; + } + + get roi(): number { + const performance = this.performance || {}; + const compensation = this.compensation.amount || 0; + const conversions = performance.conversions || 0; + + // Assuming average conversion value - this would be configurable + const avgConversionValue = 50; + const revenue = conversions * avgConversionValue; + + return compensation > 0 ? ((revenue - compensation) / compensation) * 100 : 0; + } +} diff --git a/src/social-media/entities/referral-code.entity.ts b/src/social-media/entities/referral-code.entity.ts new file mode 100644 index 00000000..20e6b7a8 --- /dev/null +++ b/src/social-media/entities/referral-code.entity.ts @@ -0,0 +1,145 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { ReferralProgram } from './referral-program.entity'; +import { ReferralTracking } from './referral-tracking.entity'; + +export enum CodeStatus { + ACTIVE = 'active', + INACTIVE = 'inactive', + EXPIRED = 'expired', + EXHAUSTED = 'exhausted', + SUSPENDED = 'suspended', +} + +@Entity('referral_codes') +@Index(['programId', 'status']) +@Index(['userId', 'status']) +@Index(['code'], { unique: true }) +export class ReferralCode { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + programId: string; + + @Column({ type: 'uuid' }) + @Index() + userId: string; + + @Column({ type: 'varchar', length: 50, unique: true }) + @Index() + code: string; + + @Column({ + type: 'enum', + enum: CodeStatus, + default: CodeStatus.ACTIVE, + }) + @Index() + status: CodeStatus; + + @Column({ type: 'int', nullable: true }) + maxUses: number; + + @Column({ type: 'int', default: 0 }) + currentUses: number; + + @Column({ type: 'timestamp', nullable: true }) + @Index() + expiresAt: Date; + + @Column({ type: 'json', nullable: true }) + customData: { + source?: string; + campaign?: string; + medium?: string; + content?: string; + term?: string; + customParameters?: Record; + }; + + @Column({ type: 'json', nullable: true }) + restrictions: { + firstTimeUsersOnly?: boolean; + minimumPurchase?: number; + maximumDiscount?: number; + allowedProducts?: string[]; + excludedProducts?: string[]; + allowedCategories?: string[]; + excludedCategories?: string[]; + }; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + totalRevenueGenerated: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + totalRewardsEarned: number; + + @Column({ type: 'int', default: 0 }) + successfulReferrals: number; + + @Column({ type: 'timestamp', nullable: true }) + lastUsedAt: Date; + + @Column({ type: 'varchar', length: 45, nullable: true }) + lastUsedIp: string; + + @Column({ type: 'varchar', length: 500, nullable: true }) + lastUsedUserAgent: string; + + @Column({ type: 'json', nullable: true }) + socialSharing: { + shareCount?: number; + platforms?: Record; + lastSharedAt?: Date; + shareUrls?: Record; + }; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @ManyToOne(() => ReferralProgram, (program) => program.codes) + @JoinColumn({ name: 'programId' }) + program: ReferralProgram; + + @OneToMany(() => ReferralTracking, (tracking) => tracking.referralCode) + trackings: ReferralTracking[]; + + // Virtual fields + get isExpired(): boolean { + return this.expiresAt ? new Date() > this.expiresAt : false; + } + + get isExhausted(): boolean { + return this.maxUses ? this.currentUses >= this.maxUses : false; + } + + get remainingUses(): number { + return this.maxUses ? Math.max(0, this.maxUses - this.currentUses) : Infinity; + } + + get conversionRate(): number { + return this.currentUses > 0 ? (this.successfulReferrals / this.currentUses) * 100 : 0; + } + + get averageOrderValue(): number { + return this.successfulReferrals > 0 ? this.totalRevenueGenerated / this.successfulReferrals : 0; + } +} diff --git a/src/social-media/entities/referral-program.entity.ts b/src/social-media/entities/referral-program.entity.ts new file mode 100644 index 00000000..3fd479ed --- /dev/null +++ b/src/social-media/entities/referral-program.entity.ts @@ -0,0 +1,217 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + Index, +} from 'typeorm'; +import { ReferralCode } from './referral-code.entity'; +import { ReferralTracking } from './referral-tracking.entity'; + +export enum ProgramStatus { + DRAFT = 'draft', + ACTIVE = 'active', + PAUSED = 'paused', + EXPIRED = 'expired', + ARCHIVED = 'archived', +} + +export enum RewardType { + DISCOUNT_PERCENTAGE = 'discount_percentage', + DISCOUNT_FIXED = 'discount_fixed', + FREE_TICKET = 'free_ticket', + CASHBACK = 'cashback', + POINTS = 'points', + MERCHANDISE = 'merchandise', + VIP_ACCESS = 'vip_access', +} + +@Entity('referral_programs') +@Index(['organizerId', 'status']) +@Index(['eventId', 'status']) +export class ReferralProgram { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + organizerId: string; + + @Column({ type: 'uuid', nullable: true }) + @Index() + eventId: string; + + @Column({ type: 'varchar', length: 255 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ + type: 'enum', + enum: ProgramStatus, + default: ProgramStatus.DRAFT, + }) + @Index() + status: ProgramStatus; + + @Column({ + type: 'enum', + enum: RewardType, + }) + rewardType: RewardType; + + @Column({ type: 'decimal', precision: 10, scale: 2 }) + rewardValue: number; + + @Column({ type: 'varchar', length: 10, nullable: true }) + rewardCurrency: string; + + @Column({ type: 'json', nullable: true }) + referrerReward: { + type: RewardType; + value: number; + currency?: string; + description?: string; + }; + + @Column({ type: 'json', nullable: true }) + refereeReward: { + type: RewardType; + value: number; + currency?: string; + description?: string; + }; + + @Column({ type: 'int', nullable: true }) + maxRedemptions: number; + + @Column({ type: 'int', default: 0 }) + currentRedemptions: number; + + @Column({ type: 'int', nullable: true }) + maxRedemptionsPerUser: number; + + @Column({ type: 'decimal', precision: 10, scale: 2, nullable: true }) + minimumPurchaseAmount: number; + + @Column({ type: 'timestamp', nullable: true }) + @Index() + startDate: Date; + + @Column({ type: 'timestamp', nullable: true }) + @Index() + endDate: Date; + + @Column({ type: 'json', nullable: true }) + eligibilityRules: { + newUsersOnly?: boolean; + excludeExistingCustomers?: boolean; + requiredUserTags?: string[]; + excludedUserTags?: string[]; + geographicRestrictions?: string[]; + deviceRestrictions?: string[]; + }; + + @Column({ type: 'json', nullable: true }) + trackingSettings: { + cookieLifetime?: number; // days + attributionWindow?: number; // days + trackingMethods?: string[]; + conversionEvents?: string[]; + customParameters?: Record; + }; + + @Column({ type: 'json', nullable: true }) + socialSharing: { + enableSocialSharing?: boolean; + platforms?: string[]; + shareMessage?: string; + shareImageUrl?: string; + customHashtags?: string[]; + incentivizeSharing?: boolean; + sharingReward?: { + type: RewardType; + value: number; + }; + }; + + @Column({ type: 'json', nullable: true }) + fraudPrevention: { + enableFraudDetection?: boolean; + maxReferralsPerDay?: number; + requireEmailVerification?: boolean; + requirePhoneVerification?: boolean; + blockSelfReferrals?: boolean; + ipRestrictions?: boolean; + deviceFingerprinting?: boolean; + }; + + @Column({ type: 'json', nullable: true }) + analytics: { + totalReferrals?: number; + successfulReferrals?: number; + conversionRate?: number; + totalRevenueGenerated?: number; + averageOrderValue?: number; + customerLifetimeValue?: number; + costPerAcquisition?: number; + returnOnInvestment?: number; + }; + + @Column({ type: 'json', nullable: true }) + customization: { + brandColors?: { + primary?: string; + secondary?: string; + accent?: string; + }; + logoUrl?: string; + customCss?: string; + emailTemplates?: { + referrerInvite?: string; + refereeWelcome?: string; + rewardNotification?: string; + }; + landingPageUrl?: string; + termsAndConditions?: string; + }; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @OneToMany(() => ReferralCode, (code) => code.program) + codes: ReferralCode[]; + + @OneToMany(() => ReferralTracking, (tracking) => tracking.program) + trackings: ReferralTracking[]; + + // Virtual fields + get isExpired(): boolean { + return this.endDate ? new Date() > this.endDate : false; + } + + get isStarted(): boolean { + return this.startDate ? new Date() >= this.startDate : true; + } + + get remainingRedemptions(): number { + return this.maxRedemptions ? this.maxRedemptions - this.currentRedemptions : Infinity; + } + + get conversionRate(): number { + const analytics = this.analytics || {}; + const totalReferrals = analytics.totalReferrals || 0; + const successfulReferrals = analytics.successfulReferrals || 0; + return totalReferrals > 0 ? (successfulReferrals / totalReferrals) * 100 : 0; + } +} diff --git a/src/social-media/entities/referral-tracking.entity.ts b/src/social-media/entities/referral-tracking.entity.ts new file mode 100644 index 00000000..30e78e10 --- /dev/null +++ b/src/social-media/entities/referral-tracking.entity.ts @@ -0,0 +1,227 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { ReferralProgram } from './referral-program.entity'; +import { ReferralCode } from './referral-code.entity'; + +export enum TrackingStatus { + PENDING = 'pending', + CLICKED = 'clicked', + REGISTERED = 'registered', + CONVERTED = 'converted', + REWARDED = 'rewarded', + FAILED = 'failed', + FRAUDULENT = 'fraudulent', +} + +export enum ConversionType { + REGISTRATION = 'registration', + PURCHASE = 'purchase', + SUBSCRIPTION = 'subscription', + BOOKING = 'booking', + DOWNLOAD = 'download', + CUSTOM = 'custom', +} + +@Entity('referral_tracking') +@Index(['programId', 'status']) +@Index(['referralCodeId', 'status']) +@Index(['referrerId', 'refereeId']) +@Index(['sessionId']) +export class ReferralTracking { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + programId: string; + + @Column({ type: 'uuid' }) + @Index() + referralCodeId: string; + + @Column({ type: 'uuid' }) + @Index() + referrerId: string; + + @Column({ type: 'uuid', nullable: true }) + @Index() + refereeId: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + @Index() + sessionId: string; + + @Column({ + type: 'enum', + enum: TrackingStatus, + default: TrackingStatus.PENDING, + }) + @Index() + status: TrackingStatus; + + @Column({ + type: 'enum', + enum: ConversionType, + nullable: true, + }) + conversionType: ConversionType; + + @Column({ type: 'varchar', length: 45 }) + ipAddress: string; + + @Column({ type: 'varchar', length: 500, nullable: true }) + userAgent: string; + + @Column({ type: 'varchar', length: 500, nullable: true }) + referrer: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + source: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + medium: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + campaign: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + content: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + term: string; + + @Column({ type: 'timestamp', nullable: true }) + clickedAt: Date; + + @Column({ type: 'timestamp', nullable: true }) + registeredAt: Date; + + @Column({ type: 'timestamp', nullable: true }) + convertedAt: Date; + + @Column({ type: 'timestamp', nullable: true }) + rewardedAt: Date; + + @Column({ type: 'decimal', precision: 15, scale: 2, nullable: true }) + conversionValue: number; + + @Column({ type: 'varchar', length: 10, nullable: true }) + conversionCurrency: string; + + @Column({ type: 'decimal', precision: 15, scale: 2, nullable: true }) + rewardAmount: number; + + @Column({ type: 'varchar', length: 10, nullable: true }) + rewardCurrency: string; + + @Column({ type: 'json', nullable: true }) + deviceInfo: { + deviceType?: string; + browser?: string; + browserVersion?: string; + os?: string; + osVersion?: string; + screenResolution?: string; + language?: string; + timezone?: string; + }; + + @Column({ type: 'json', nullable: true }) + locationInfo: { + country?: string; + region?: string; + city?: string; + latitude?: number; + longitude?: number; + timezone?: string; + }; + + @Column({ type: 'json', nullable: true }) + fraudDetection: { + riskScore?: number; + flags?: string[]; + ipReputation?: string; + deviceFingerprint?: string; + behaviorAnalysis?: { + timeOnSite?: number; + pageViews?: number; + bounceRate?: number; + suspiciousActivity?: boolean; + }; + }; + + @Column({ type: 'json', nullable: true }) + conversionData: { + orderId?: string; + productIds?: string[]; + categoryIds?: string[]; + quantity?: number; + discountAmount?: number; + taxAmount?: number; + shippingAmount?: number; + customFields?: Record; + }; + + @Column({ type: 'json', nullable: true }) + attributionData: { + firstTouch?: boolean; + lastTouch?: boolean; + touchpointSequence?: number; + attributionWeight?: number; + crossDevice?: boolean; + assistedConversion?: boolean; + }; + + @Column({ type: 'varchar', length: 500, nullable: true }) + errorMessage: string; + + @Column({ type: 'int', default: 0 }) + retryCount: number; + + @Column({ type: 'timestamp', nullable: true }) + lastRetryAt: Date; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @ManyToOne(() => ReferralProgram, (program) => program.trackings) + @JoinColumn({ name: 'programId' }) + program: ReferralProgram; + + @ManyToOne(() => ReferralCode, (code) => code.trackings) + @JoinColumn({ name: 'referralCodeId' }) + referralCode: ReferralCode; + + // Virtual fields + get timeToConversion(): number { + if (!this.clickedAt || !this.convertedAt) return 0; + return this.convertedAt.getTime() - this.clickedAt.getTime(); + } + + get isConverted(): boolean { + return this.status === TrackingStatus.CONVERTED || this.status === TrackingStatus.REWARDED; + } + + get isFraudulent(): boolean { + return this.status === TrackingStatus.FRAUDULENT; + } + + get riskScore(): number { + return this.fraudDetection?.riskScore || 0; + } +} diff --git a/src/social-media/entities/social-account.entity.ts b/src/social-media/entities/social-account.entity.ts new file mode 100644 index 00000000..47de72b8 --- /dev/null +++ b/src/social-media/entities/social-account.entity.ts @@ -0,0 +1,168 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { SocialPost } from './social-post.entity'; +import { SocialCampaign } from './social-campaign.entity'; + +export enum SocialPlatform { + FACEBOOK = 'facebook', + INSTAGRAM = 'instagram', + TWITTER = 'twitter', + LINKEDIN = 'linkedin', + TIKTOK = 'tiktok', + YOUTUBE = 'youtube', +} + +export enum AccountStatus { + ACTIVE = 'active', + INACTIVE = 'inactive', + SUSPENDED = 'suspended', + EXPIRED = 'expired', + ERROR = 'error', +} + +@Entity('social_accounts') +@Index(['organizerId', 'platform']) +@Index(['platform', 'status']) +export class SocialAccount { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + organizerId: string; + + @Column({ + type: 'enum', + enum: SocialPlatform, + }) + @Index() + platform: SocialPlatform; + + @Column({ type: 'varchar', length: 255 }) + platformUserId: string; + + @Column({ type: 'varchar', length: 255 }) + username: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + displayName: string; + + @Column({ type: 'varchar', length: 500, nullable: true }) + profileImageUrl: string; + + @Column({ type: 'text', nullable: true }) + bio: string; + + @Column({ type: 'int', default: 0 }) + followersCount: number; + + @Column({ type: 'int', default: 0 }) + followingCount: number; + + @Column({ type: 'text' }) + accessToken: string; // Encrypted + + @Column({ type: 'text', nullable: true }) + refreshToken: string; // Encrypted + + @Column({ type: 'timestamp', nullable: true }) + tokenExpiresAt: Date; + + @Column({ + type: 'enum', + enum: AccountStatus, + default: AccountStatus.ACTIVE, + }) + @Index() + status: AccountStatus; + + @Column({ type: 'json', nullable: true }) + permissions: { + canPost?: boolean; + canReadInsights?: boolean; + canManageAds?: boolean; + canAccessPages?: boolean; + scopes?: string[]; + }; + + @Column({ type: 'json', nullable: true }) + platformData: { + pageId?: string; + businessAccountId?: string; + adAccountId?: string; + verificationStatus?: string; + accountType?: string; + category?: string; + website?: string; + location?: { + city?: string; + country?: string; + timezone?: string; + }; + }; + + @Column({ type: 'json', nullable: true }) + settings: { + autoPost?: boolean; + defaultHashtags?: string[]; + postingSchedule?: { + timezone?: string; + preferredTimes?: string[]; + avoidDays?: string[]; + }; + contentFilters?: { + requireApproval?: boolean; + blockedWords?: string[]; + allowedContentTypes?: string[]; + }; + }; + + @Column({ type: 'timestamp', nullable: true }) + lastSyncAt: Date; + + @Column({ type: 'timestamp', nullable: true }) + lastPostAt: Date; + + @Column({ type: 'varchar', length: 500, nullable: true }) + lastError: string; + + @Column({ type: 'int', default: 0 }) + totalPosts: number; + + @Column({ type: 'int', default: 0 }) + totalEngagement: number; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @OneToMany(() => SocialPost, (post) => post.account) + posts: SocialPost[]; + + @OneToMany(() => SocialCampaign, (campaign) => campaign.account) + campaigns: SocialCampaign[]; + + // Virtual fields + get engagementRate(): number { + return this.followersCount > 0 ? (this.totalEngagement / this.followersCount) * 100 : 0; + } + + get isTokenExpired(): boolean { + return this.tokenExpiresAt ? new Date() > this.tokenExpiresAt : false; + } +} diff --git a/src/social-media/entities/social-campaign.entity.ts b/src/social-media/entities/social-campaign.entity.ts new file mode 100644 index 00000000..9858652e --- /dev/null +++ b/src/social-media/entities/social-campaign.entity.ts @@ -0,0 +1,260 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { SocialAccount } from './social-account.entity'; +import { SocialPost } from './social-post.entity'; + +export enum CampaignType { + EVENT_PROMOTION = 'event_promotion', + BRAND_AWARENESS = 'brand_awareness', + ENGAGEMENT = 'engagement', + LEAD_GENERATION = 'lead_generation', + TRAFFIC = 'traffic', + CONVERSIONS = 'conversions', + INFLUENCER = 'influencer', + UGC = 'ugc', +} + +export enum CampaignStatus { + DRAFT = 'draft', + SCHEDULED = 'scheduled', + ACTIVE = 'active', + PAUSED = 'paused', + COMPLETED = 'completed', + CANCELLED = 'cancelled', +} + +@Entity('social_campaigns') +@Index(['accountId', 'status']) +@Index(['eventId', 'status']) +@Index(['organizerId', 'campaignType']) +export class SocialCampaign { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + accountId: string; + + @Column({ type: 'uuid', nullable: true }) + @Index() + eventId: string; + + @Column({ type: 'uuid' }) + @Index() + organizerId: string; + + @Column({ type: 'varchar', length: 255 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ + type: 'enum', + enum: CampaignType, + }) + campaignType: CampaignType; + + @Column({ + type: 'enum', + enum: CampaignStatus, + default: CampaignStatus.DRAFT, + }) + @Index() + status: CampaignStatus; + + @Column({ type: 'timestamp', nullable: true }) + @Index() + startDate: Date; + + @Column({ type: 'timestamp', nullable: true }) + @Index() + endDate: Date; + + @Column({ type: 'decimal', precision: 15, scale: 2, nullable: true }) + budget: number; + + @Column({ type: 'varchar', length: 10, nullable: true }) + currency: string; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + spentAmount: number; + + @Column({ type: 'json', nullable: true }) + objectives: { + primary?: string; + secondary?: string[]; + kpis?: Array<{ + metric: string; + target: number; + current?: number; + }>; + }; + + @Column({ type: 'json', nullable: true }) + targeting: { + demographics?: { + ageMin?: number; + ageMax?: number; + genders?: string[]; + languages?: string[]; + }; + interests?: string[]; + behaviors?: string[]; + locations?: Array<{ + type: 'country' | 'region' | 'city'; + name: string; + code?: string; + }>; + customAudiences?: string[]; + lookalikeSources?: string[]; + exclusions?: { + audiences?: string[]; + interests?: string[]; + behaviors?: string[]; + }; + }; + + @Column({ type: 'json', nullable: true }) + contentStrategy: { + themes?: string[]; + toneOfVoice?: string; + visualStyle?: string; + postingFrequency?: string; + optimalTimes?: string[]; + hashtags?: { + branded?: string[]; + trending?: string[]; + niche?: string[]; + }; + contentMix?: { + promotional?: number; + educational?: number; + entertaining?: number; + userGenerated?: number; + }; + }; + + @Column({ type: 'json', nullable: true }) + analytics: { + impressions?: number; + reach?: number; + engagement?: number; + clicks?: number; + conversions?: number; + costPerClick?: number; + costPerConversion?: number; + returnOnAdSpend?: number; + engagementRate?: number; + clickThroughRate?: number; + conversionRate?: number; + lastUpdated?: Date; + }; + + @Column({ type: 'json', nullable: true }) + automation: { + autoPost?: boolean; + autoRespond?: boolean; + autoOptimize?: boolean; + rules?: Array<{ + condition: string; + action: string; + parameters?: Record; + }>; + aiOptimization?: { + enabled?: boolean; + optimizeFor?: string; + learningPhase?: boolean; + confidence?: number; + }; + }; + + @Column({ type: 'json', nullable: true }) + collaboration: { + teamMembers?: Array<{ + userId: string; + role: string; + permissions: string[]; + }>; + approvalWorkflow?: { + required?: boolean; + approvers?: string[]; + stages?: Array<{ + name: string; + approvers: string[]; + required: boolean; + }>; + }; + externalCollaborators?: Array<{ + email: string; + role: string; + accessLevel: string; + }>; + }; + + @Column({ type: 'int', default: 0 }) + totalPosts: number; + + @Column({ type: 'int', default: 0 }) + publishedPosts: number; + + @Column({ type: 'int', default: 0 }) + scheduledPosts: number; + + @Column({ type: 'timestamp', nullable: true }) + lastPostAt: Date; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @ManyToOne(() => SocialAccount, (account) => account.campaigns) + @JoinColumn({ name: 'accountId' }) + account: SocialAccount; + + @OneToMany(() => SocialPost, (post) => post.campaign) + posts: SocialPost[]; + + // Virtual fields + get isActive(): boolean { + const now = new Date(); + return this.status === CampaignStatus.ACTIVE && + (!this.startDate || now >= this.startDate) && + (!this.endDate || now <= this.endDate); + } + + get budgetUtilization(): number { + return this.budget ? (this.spentAmount / this.budget) * 100 : 0; + } + + get remainingBudget(): number { + return this.budget ? Math.max(0, this.budget - this.spentAmount) : 0; + } + + get engagementRate(): number { + const analytics = this.analytics || {}; + const reach = analytics.reach || 0; + const engagement = analytics.engagement || 0; + return reach > 0 ? (engagement / reach) * 100 : 0; + } + + get roi(): number { + const analytics = this.analytics || {}; + return analytics.returnOnAdSpend || 0; + } +} diff --git a/src/social-media/entities/social-post.entity.ts b/src/social-media/entities/social-post.entity.ts new file mode 100644 index 00000000..833f9fa8 --- /dev/null +++ b/src/social-media/entities/social-post.entity.ts @@ -0,0 +1,228 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { SocialAccount } from './social-account.entity'; +import { SocialCampaign } from './social-campaign.entity'; + +export enum PostType { + TEXT = 'text', + IMAGE = 'image', + VIDEO = 'video', + CAROUSEL = 'carousel', + STORY = 'story', + REEL = 'reel', + LIVE = 'live', +} + +export enum PostStatus { + DRAFT = 'draft', + SCHEDULED = 'scheduled', + PUBLISHED = 'published', + FAILED = 'failed', + DELETED = 'deleted', +} + +@Entity('social_posts') +@Index(['accountId', 'status']) +@Index(['campaignId', 'publishedAt']) +@Index(['eventId', 'status']) +export class SocialPost { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + accountId: string; + + @Column({ type: 'uuid', nullable: true }) + @Index() + campaignId: string; + + @Column({ type: 'uuid', nullable: true }) + @Index() + eventId: string; + + @Column({ type: 'uuid', nullable: true }) + createdBy: string; + + @Column({ + type: 'enum', + enum: PostType, + default: PostType.TEXT, + }) + postType: PostType; + + @Column({ + type: 'enum', + enum: PostStatus, + default: PostStatus.DRAFT, + }) + @Index() + status: PostStatus; + + @Column({ type: 'text' }) + content: string; + + @Column({ type: 'json', nullable: true }) + media: Array<{ + type: 'image' | 'video'; + url: string; + thumbnailUrl?: string; + altText?: string; + width?: number; + height?: number; + duration?: number; + size?: number; + }>; + + @Column({ type: 'json', nullable: true }) + hashtags: string[]; + + @Column({ type: 'json', nullable: true }) + mentions: Array<{ + username: string; + userId?: string; + displayName?: string; + }>; + + @Column({ type: 'varchar', length: 500, nullable: true }) + link: string; + + @Column({ type: 'json', nullable: true }) + linkPreview: { + title?: string; + description?: string; + imageUrl?: string; + domain?: string; + }; + + @Column({ type: 'timestamp', nullable: true }) + @Index() + scheduledAt: Date; + + @Column({ type: 'timestamp', nullable: true }) + @Index() + publishedAt: Date; + + @Column({ type: 'varchar', length: 255, nullable: true }) + platformPostId: string; + + @Column({ type: 'varchar', length: 500, nullable: true }) + platformUrl: string; + + @Column({ type: 'json', nullable: true }) + engagement: { + likes?: number; + comments?: number; + shares?: number; + saves?: number; + clicks?: number; + impressions?: number; + reach?: number; + videoViews?: number; + engagementRate?: number; + lastUpdated?: Date; + }; + + @Column({ type: 'json', nullable: true }) + targeting: { + locations?: string[]; + interests?: string[]; + demographics?: { + ageMin?: number; + ageMax?: number; + genders?: string[]; + }; + customAudiences?: string[]; + }; + + @Column({ type: 'json', nullable: true }) + settings: { + allowComments?: boolean; + allowSharing?: boolean; + crossPostToPlatforms?: string[]; + boostPost?: boolean; + boostBudget?: number; + boostDuration?: number; + }; + + @Column({ type: 'varchar', length: 500, nullable: true }) + errorMessage: string; + + @Column({ type: 'int', default: 0 }) + retryCount: number; + + @Column({ type: 'timestamp', nullable: true }) + lastRetryAt: Date; + + @Column({ type: 'json', nullable: true }) + analytics: { + clickThroughRate?: number; + costPerClick?: number; + costPerEngagement?: number; + conversionRate?: number; + revenue?: number; + roi?: number; + }; + + @Column({ type: 'json', nullable: true }) + aiGenerated: { + isAiGenerated?: boolean; + prompt?: string; + model?: string; + confidence?: number; + alternatives?: string[]; + }; + + @Column({ type: 'boolean', default: false }) + isPromoted: boolean; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Relations + @ManyToOne(() => SocialAccount, (account) => account.posts) + @JoinColumn({ name: 'accountId' }) + account: SocialAccount; + + @ManyToOne(() => SocialCampaign, (campaign) => campaign.posts) + @JoinColumn({ name: 'campaignId' }) + campaign: SocialCampaign; + + // Virtual fields + get totalEngagement(): number { + const engagement = this.engagement || {}; + return (engagement.likes || 0) + + (engagement.comments || 0) + + (engagement.shares || 0) + + (engagement.saves || 0); + } + + get engagementRate(): number { + const engagement = this.engagement || {}; + const reach = engagement.reach || engagement.impressions || 0; + return reach > 0 ? (this.totalEngagement / reach) * 100 : 0; + } + + get isScheduled(): boolean { + return this.status === PostStatus.SCHEDULED && this.scheduledAt > new Date(); + } + + get isPastDue(): boolean { + return this.status === PostStatus.SCHEDULED && this.scheduledAt <= new Date(); + } +} diff --git a/src/social-media/entities/social-proof.entity.ts b/src/social-media/entities/social-proof.entity.ts new file mode 100644 index 00000000..ea60b659 --- /dev/null +++ b/src/social-media/entities/social-proof.entity.ts @@ -0,0 +1,162 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum ProofType { + FRIEND_ATTENDANCE = 'friend_attendance', + SOCIAL_MENTION = 'social_mention', + REVIEW = 'review', + TESTIMONIAL = 'testimonial', + MEDIA_COVERAGE = 'media_coverage', + INFLUENCER_ENDORSEMENT = 'influencer_endorsement', + USER_COUNT = 'user_count', + RECENT_ACTIVITY = 'recent_activity', +} + +export enum ProofStatus { + ACTIVE = 'active', + PENDING = 'pending', + APPROVED = 'approved', + REJECTED = 'rejected', + EXPIRED = 'expired', +} + +@Entity('social_proof') +@Index(['eventId', 'proofType']) +@Index(['userId', 'proofType']) +@Index(['status', 'createdAt']) +export class SocialProof { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: true }) + @Index() + eventId: string; + + @Column({ type: 'uuid', nullable: true }) + @Index() + userId: string; + + @Column({ type: 'uuid', nullable: true }) + organizerId: string; + + @Column({ + type: 'enum', + enum: ProofType, + }) + @Index() + proofType: ProofType; + + @Column({ + type: 'enum', + enum: ProofStatus, + default: ProofStatus.PENDING, + }) + @Index() + status: ProofStatus; + + @Column({ type: 'text' }) + content: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + authorName: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + authorUsername: string; + + @Column({ type: 'varchar', length: 500, nullable: true }) + authorAvatarUrl: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + platform: string; + + @Column({ type: 'varchar', length: 500, nullable: true }) + sourceUrl: string; + + @Column({ type: 'json', nullable: true }) + mediaUrls: string[]; + + @Column({ type: 'int', nullable: true }) + rating: number; + + @Column({ type: 'int', default: 0 }) + likesCount: number; + + @Column({ type: 'int', default: 0 }) + sharesCount: number; + + @Column({ type: 'int', default: 0 }) + commentsCount: number; + + @Column({ type: 'json', nullable: true }) + metrics: { + reach?: number; + impressions?: number; + engagement?: number; + clickThroughRate?: number; + conversionRate?: number; + }; + + @Column({ type: 'json', nullable: true }) + friendsData: Array<{ + userId: string; + name: string; + avatarUrl?: string; + mutualFriends?: number; + attendanceStatus?: string; + }>; + + @Column({ type: 'json', nullable: true }) + metadata: { + location?: string; + timestamp?: Date; + deviceType?: string; + verified?: boolean; + influencerTier?: string; + followerCount?: number; + engagementRate?: number; + }; + + @Column({ type: 'decimal', precision: 5, scale: 2, nullable: true }) + credibilityScore: number; + + @Column({ type: 'int', default: 0 }) + displayCount: number; + + @Column({ type: 'int', default: 0 }) + clickCount: number; + + @Column({ type: 'timestamp', nullable: true }) + @Index() + expiresAt: Date; + + @Column({ type: 'timestamp', nullable: true }) + lastDisplayedAt: Date; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Virtual fields + get isExpired(): boolean { + return this.expiresAt ? new Date() > this.expiresAt : false; + } + + get clickThroughRate(): number { + return this.displayCount > 0 ? (this.clickCount / this.displayCount) * 100 : 0; + } + + get totalEngagement(): number { + return this.likesCount + this.sharesCount + this.commentsCount; + } +} diff --git a/src/social-media/entities/user-generated-content.entity.ts b/src/social-media/entities/user-generated-content.entity.ts new file mode 100644 index 00000000..ff8d5458 --- /dev/null +++ b/src/social-media/entities/user-generated-content.entity.ts @@ -0,0 +1,251 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum ContentType { + IMAGE = 'image', + VIDEO = 'video', + TEXT = 'text', + STORY = 'story', + REVIEW = 'review', + TESTIMONIAL = 'testimonial', +} + +export enum ContentStatus { + PENDING = 'pending', + APPROVED = 'approved', + REJECTED = 'rejected', + FEATURED = 'featured', + ARCHIVED = 'archived', +} + +export enum ModerationFlag { + INAPPROPRIATE = 'inappropriate', + SPAM = 'spam', + COPYRIGHT = 'copyright', + FAKE = 'fake', + OFFENSIVE = 'offensive', + LOW_QUALITY = 'low_quality', +} + +@Entity('user_generated_content') +@Index(['eventId', 'status']) +@Index(['userId', 'contentType']) +@Index(['campaignId', 'status']) +@Index(['platform', 'createdAt']) +export class UserGeneratedContent { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: true }) + @Index() + eventId: string; + + @Column({ type: 'uuid', nullable: true }) + @Index() + userId: string; + + @Column({ type: 'uuid', nullable: true }) + @Index() + campaignId: string; + + @Column({ type: 'uuid', nullable: true }) + organizerId: string; + + @Column({ + type: 'enum', + enum: ContentType, + }) + @Index() + contentType: ContentType; + + @Column({ + type: 'enum', + enum: ContentStatus, + default: ContentStatus.PENDING, + }) + @Index() + status: ContentStatus; + + @Column({ type: 'varchar', length: 255 }) + title: string; + + @Column({ type: 'text' }) + description: string; + + @Column({ type: 'json' }) + mediaUrls: Array<{ + type: 'image' | 'video'; + url: string; + thumbnailUrl?: string; + width?: number; + height?: number; + duration?: number; + size?: number; + }>; + + @Column({ type: 'varchar', length: 255 }) + authorName: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + authorUsername: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + authorEmail: string; + + @Column({ type: 'varchar', length: 500, nullable: true }) + authorAvatarUrl: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + @Index() + platform: string; + + @Column({ type: 'varchar', length: 500, nullable: true }) + originalUrl: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + platformPostId: string; + + @Column({ type: 'json', nullable: true }) + hashtags: string[]; + + @Column({ type: 'json', nullable: true }) + mentions: Array<{ + username: string; + userId?: string; + displayName?: string; + }>; + + @Column({ type: 'json', nullable: true }) + engagement: { + likes?: number; + comments?: number; + shares?: number; + saves?: number; + views?: number; + reach?: number; + impressions?: number; + lastUpdated?: Date; + }; + + @Column({ type: 'json', nullable: true }) + location: { + name?: string; + latitude?: number; + longitude?: number; + city?: string; + country?: string; + }; + + @Column({ type: 'json', nullable: true }) + permissions: { + canRepost?: boolean; + canFeature?: boolean; + canModify?: boolean; + canCommercialUse?: boolean; + attribution?: string; + expiresAt?: Date; + }; + + @Column({ type: 'decimal', precision: 5, scale: 2, nullable: true }) + qualityScore: number; + + @Column({ type: 'decimal', precision: 5, scale: 2, nullable: true }) + sentimentScore: number; + + @Column({ type: 'json', nullable: true }) + aiAnalysis: { + objectDetection?: string[]; + sceneAnalysis?: string[]; + textAnalysis?: { + keywords?: string[]; + sentiment?: string; + topics?: string[]; + language?: string; + }; + brandMentions?: string[]; + logoDetection?: boolean; + inappropriateContent?: boolean; + confidenceScore?: number; + }; + + @Column({ type: 'json', nullable: true }) + moderation: { + flags?: ModerationFlag[]; + reviewedBy?: string; + reviewedAt?: Date; + reviewNotes?: string; + autoModerated?: boolean; + humanReviewRequired?: boolean; + }; + + @Column({ type: 'json', nullable: true }) + rewards: { + points?: number; + badges?: string[]; + prizes?: Array<{ + type: string; + value: number; + description: string; + claimedAt?: Date; + }>; + featured?: { + startDate: Date; + endDate: Date; + platforms: string[]; + }; + }; + + @Column({ type: 'int', default: 0 }) + repostCount: number; + + @Column({ type: 'int', default: 0 }) + featureCount: number; + + @Column({ type: 'int', default: 0 }) + reportCount: number; + + @Column({ type: 'timestamp', nullable: true }) + featuredAt: Date; + + @Column({ type: 'timestamp', nullable: true }) + lastModerationAt: Date; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Virtual fields + get totalEngagement(): number { + const engagement = this.engagement || {}; + return (engagement.likes || 0) + + (engagement.comments || 0) + + (engagement.shares || 0) + + (engagement.saves || 0); + } + + get engagementRate(): number { + const engagement = this.engagement || {}; + const reach = engagement.reach || engagement.impressions || 0; + return reach > 0 ? (this.totalEngagement / reach) * 100 : 0; + } + + get isFeatured(): boolean { + return this.status === ContentStatus.FEATURED; + } + + get needsReview(): boolean { + return this.status === ContentStatus.PENDING || + (this.moderation?.humanReviewRequired === true); + } +} diff --git a/src/social-media/services/__tests__/referral-program.service.spec.ts b/src/social-media/services/__tests__/referral-program.service.spec.ts new file mode 100644 index 00000000..2258aee8 --- /dev/null +++ b/src/social-media/services/__tests__/referral-program.service.spec.ts @@ -0,0 +1,276 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import { ReferralProgramService } from '../referral-program.service'; +import { ReferralProgram, ProgramStatus } from '../../entities/referral-program.entity'; +import { ReferralCode, CodeStatus } from '../../entities/referral-code.entity'; +import { ReferralTracking, TrackingStatus, ConversionType } from '../../entities/referral-tracking.entity'; + +describe('ReferralProgramService', () => { + let service: ReferralProgramService; + let programRepository: Repository; + let codeRepository: Repository; + let trackingRepository: Repository; + + const mockProgram = { + id: 'program-1', + organizerId: 'organizer-1', + name: 'Test Referral Program', + rewardType: 'percentage', + rewardValue: 10, + status: ProgramStatus.ACTIVE, + analytics: { + totalCodes: 0, + activeCodes: 0, + totalClicks: 0, + totalConversions: 0, + totalRevenue: 0, + conversionRate: 0, + averageOrderValue: 0, + }, + }; + + const mockCode = { + id: 'code-1', + programId: 'program-1', + userId: 'user-1', + code: 'REF12345', + status: CodeStatus.ACTIVE, + usedCount: 0, + analytics: { + totalClicks: 0, + uniqueClicks: 0, + conversions: 0, + revenue: 0, + conversionRate: 0, + }, + }; + + const mockTracking = { + id: 'tracking-1', + codeId: 'code-1', + programId: 'program-1', + status: TrackingStatus.CLICKED, + deviceInfo: { ipAddress: '127.0.0.1' }, + fraudDetection: { riskScore: 10, flags: [], isBlocked: false }, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ReferralProgramService, + { + provide: getRepositoryToken(ReferralProgram), + useValue: { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + }, + }, + { + provide: getRepositoryToken(ReferralCode), + useValue: { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + }, + }, + { + provide: getRepositoryToken(ReferralTracking), + useValue: { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + count: jest.fn(), + }, + }, + { + provide: ConfigService, + useValue: { + get: jest.fn().mockReturnValue('REF'), + }, + }, + ], + }).compile(); + + service = module.get(ReferralProgramService); + programRepository = module.get>(getRepositoryToken(ReferralProgram)); + codeRepository = module.get>(getRepositoryToken(ReferralCode)); + trackingRepository = module.get>(getRepositoryToken(ReferralTracking)); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('createProgram', () => { + it('should create a new referral program', async () => { + const createDto = { + organizerId: 'organizer-1', + name: 'Test Referral Program', + rewardType: 'percentage', + rewardValue: 10, + }; + + jest.spyOn(programRepository, 'create').mockReturnValue(mockProgram as any); + jest.spyOn(programRepository, 'save').mockResolvedValue(mockProgram as any); + + const result = await service.createProgram(createDto); + + expect(programRepository.create).toHaveBeenCalledWith({ + ...createDto, + status: ProgramStatus.DRAFT, + analytics: expect.any(Object), + }); + expect(result).toEqual(mockProgram); + }); + }); + + describe('generateReferralCode', () => { + it('should generate a new referral code', async () => { + const codeDto = { + programId: 'program-1', + userId: 'user-1', + }; + + jest.spyOn(service, 'findProgramById').mockResolvedValue(mockProgram as any); + jest.spyOn(codeRepository, 'findOne').mockResolvedValue(null); // No existing code + jest.spyOn(codeRepository, 'create').mockReturnValue(mockCode as any); + jest.spyOn(codeRepository, 'save').mockResolvedValue(mockCode as any); + jest.spyOn(service, 'updateProgramAnalytics').mockResolvedValue(undefined); + + const result = await service.generateReferralCode(codeDto); + + expect(codeRepository.create).toHaveBeenCalledWith({ + ...codeDto, + code: expect.any(String), + status: CodeStatus.ACTIVE, + analytics: expect.any(Object), + socialSharing: expect.any(Object), + }); + expect(result).toEqual(mockCode); + }); + + it('should return existing active code for user', async () => { + const codeDto = { + programId: 'program-1', + userId: 'user-1', + }; + + jest.spyOn(service, 'findProgramById').mockResolvedValue(mockProgram as any); + jest.spyOn(codeRepository, 'findOne').mockResolvedValue(mockCode as any); + + const result = await service.generateReferralCode(codeDto); + + expect(result).toEqual(mockCode); + expect(codeRepository.create).not.toHaveBeenCalled(); + }); + }); + + describe('trackReferralClick', () => { + it('should track a referral click', async () => { + const metadata = { + ipAddress: '127.0.0.1', + userAgent: 'Mozilla/5.0', + referrer: 'https://google.com', + }; + + jest.spyOn(service, 'findCodeByCode').mockResolvedValue(mockCode as any); + jest.spyOn(trackingRepository, 'create').mockReturnValue(mockTracking as any); + jest.spyOn(trackingRepository, 'save').mockResolvedValue(mockTracking as any); + jest.spyOn(service, 'updateCodeAnalytics').mockResolvedValue(undefined); + + const result = await service.trackReferralClick('REF12345', metadata); + + expect(trackingRepository.create).toHaveBeenCalledWith({ + codeId: mockCode.id, + programId: mockCode.programId, + status: TrackingStatus.CLICKED, + deviceInfo: expect.any(Object), + sourceInfo: expect.any(Object), + fraudDetection: expect.any(Object), + }); + expect(result).toEqual(mockTracking); + }); + + it('should reject expired codes', async () => { + const expiredCode = { + ...mockCode, + expiresAt: new Date(Date.now() - 24 * 60 * 60 * 1000), // Yesterday + }; + + jest.spyOn(service, 'findCodeByCode').mockResolvedValue(expiredCode as any); + + await expect(service.trackReferralClick('REF12345', {})) + .rejects.toThrow('Referral code has expired'); + }); + }); + + describe('processReferralConversion', () => { + it('should process a referral conversion', async () => { + const conversionDto = { + codeId: 'code-1', + convertedUserId: 'user-2', + conversionType: ConversionType.PURCHASE, + conversionValue: 100, + }; + + const codeWithProgram = { ...mockCode, program: mockProgram }; + const convertedTracking = { + ...mockTracking, + status: TrackingStatus.CONVERTED, + convertedUserId: 'user-2', + conversionValue: 100, + }; + + jest.spyOn(codeRepository, 'findOne').mockResolvedValue(codeWithProgram as any); + jest.spyOn(trackingRepository, 'findOne').mockResolvedValue(mockTracking as any); + jest.spyOn(trackingRepository, 'save').mockResolvedValue(convertedTracking as any); + jest.spyOn(service, 'updateCodeAnalytics').mockResolvedValue(undefined); + jest.spyOn(service, 'updateProgramAnalytics').mockResolvedValue(undefined); + jest.spyOn(service, 'processRewards').mockResolvedValue(undefined); + + const result = await service.processReferralConversion(conversionDto); + + expect(result.status).toBe(TrackingStatus.CONVERTED); + expect(result.convertedUserId).toBe('user-2'); + expect(result.conversionValue).toBe(100); + }); + }); + + describe('getReferralAnalytics', () => { + it('should return analytics for a referral code', async () => { + const trackingRecords = [ + { ...mockTracking, status: TrackingStatus.CLICKED }, + { ...mockTracking, status: TrackingStatus.CONVERTED, conversionValue: 50 }, + ]; + + jest.spyOn(codeRepository, 'findOne').mockResolvedValue(mockCode as any); + jest.spyOn(trackingRepository, 'find').mockResolvedValue(trackingRecords as any); + + const result = await service.getReferralAnalytics('code-1'); + + expect(result.totalClicks).toBe(1); + expect(result.totalConversions).toBe(1); + expect(result.conversionRate).toBe(100); + expect(result.totalRevenue).toBe(50); + }); + }); + + describe('updateProgramStatus', () => { + it('should update program status to active', async () => { + const updatedProgram = { ...mockProgram, status: ProgramStatus.ACTIVE, startDate: new Date() }; + + jest.spyOn(service, 'findProgramById').mockResolvedValue(mockProgram as any); + jest.spyOn(programRepository, 'save').mockResolvedValue(updatedProgram as any); + + const result = await service.updateProgramStatus('program-1', ProgramStatus.ACTIVE); + + expect(result.status).toBe(ProgramStatus.ACTIVE); + expect(result.startDate).toBeDefined(); + }); + }); +}); diff --git a/src/social-media/services/__tests__/social-media-analytics.service.spec.ts b/src/social-media/services/__tests__/social-media-analytics.service.spec.ts new file mode 100644 index 00000000..cbc55a49 --- /dev/null +++ b/src/social-media/services/__tests__/social-media-analytics.service.spec.ts @@ -0,0 +1,328 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { SocialMediaAnalyticsService } from '../social-media-analytics.service'; +import { SocialPost, PostStatus } from '../../entities/social-post.entity'; +import { SocialCampaign, CampaignStatus } from '../../entities/social-campaign.entity'; +import { ReferralProgram } from '../../entities/referral-program.entity'; +import { ReferralTracking, TrackingStatus } from '../../entities/referral-tracking.entity'; +import { SocialProof, ProofStatus } from '../../entities/social-proof.entity'; +import { InfluencerCollaboration, CollaborationStatus } from '../../entities/influencer-collaboration.entity'; +import { UserGeneratedContent, ContentStatus } from '../../entities/user-generated-content.entity'; + +describe('SocialMediaAnalyticsService', () => { + let service: SocialMediaAnalyticsService; + let postRepository: Repository; + let campaignRepository: Repository; + + const mockPosts = [ + { + id: 'post-1', + status: PostStatus.PUBLISHED, + engagement: { likes: 50, comments: 10, shares: 5, reach: 1000, engagement: 65 }, + account: { platform: 'facebook' }, + }, + { + id: 'post-2', + status: PostStatus.PUBLISHED, + engagement: { likes: 30, comments: 8, shares: 3, reach: 800, engagement: 41 }, + account: { platform: 'instagram' }, + }, + ]; + + const mockCampaigns = [ + { + id: 'campaign-1', + status: CampaignStatus.ACTIVE, + budget: 1000, + spentAmount: 500, + analytics: { returnOnAdSpend: 2.5 }, + }, + { + id: 'campaign-2', + status: CampaignStatus.COMPLETED, + budget: 2000, + spentAmount: 1800, + analytics: { returnOnAdSpend: 3.2 }, + }, + ]; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SocialMediaAnalyticsService, + { + provide: getRepositoryToken(SocialPost), + useValue: { + find: jest.fn(), + findOne: jest.fn(), + }, + }, + { + provide: getRepositoryToken(SocialCampaign), + useValue: { + find: jest.fn(), + findOne: jest.fn(), + }, + }, + { + provide: getRepositoryToken(ReferralProgram), + useValue: { + find: jest.fn(), + findOne: jest.fn(), + }, + }, + { + provide: getRepositoryToken(ReferralTracking), + useValue: { + find: jest.fn(), + }, + }, + { + provide: getRepositoryToken(SocialProof), + useValue: { + find: jest.fn(), + }, + }, + { + provide: getRepositoryToken(InfluencerCollaboration), + useValue: { + find: jest.fn(), + findOne: jest.fn(), + }, + }, + { + provide: getRepositoryToken(UserGeneratedContent), + useValue: { + find: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(SocialMediaAnalyticsService); + postRepository = module.get>(getRepositoryToken(SocialPost)); + campaignRepository = module.get>(getRepositoryToken(SocialCampaign)); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getDashboard', () => { + it('should return comprehensive dashboard data', async () => { + // Mock all repository calls + jest.spyOn(postRepository, 'find').mockResolvedValue(mockPosts as any); + jest.spyOn(campaignRepository, 'find').mockResolvedValue(mockCampaigns as any); + + // Mock other repositories to return empty arrays + const mockRepositories = [ + 'referralProgramRepository', + 'referralTrackingRepository', + 'socialProofRepository', + 'influencerRepository', + 'ugcRepository', + ]; + + mockRepositories.forEach(repo => { + const repository = service[repo] || { find: jest.fn() }; + if (repository.find) { + jest.spyOn(repository, 'find').mockResolvedValue([]); + } + }); + + const result = await service.getDashboard('organizer-1'); + + expect(result).toHaveProperty('overview'); + expect(result).toHaveProperty('campaigns'); + expect(result).toHaveProperty('referrals'); + expect(result).toHaveProperty('influencers'); + expect(result).toHaveProperty('socialProof'); + expect(result).toHaveProperty('ugc'); + + expect(result.overview.totalPosts).toBe(2); + expect(result.overview.totalEngagement).toBe(106); + expect(result.overview.topPlatform).toBe('facebook'); + + expect(result.campaigns.activeCampaigns).toBe(1); + expect(result.campaigns.totalCampaigns).toBe(2); + }); + }); + + describe('getPostAnalytics', () => { + it('should return detailed post analytics', async () => { + const mockPost = { + id: 'post-1', + content: 'Test post content for analytics', + status: PostStatus.PUBLISHED, + publishedAt: new Date(), + engagement: { + likes: 50, + comments: 10, + shares: 5, + clicks: 20, + impressions: 1000, + reach: 800, + }, + account: { platform: 'facebook' }, + }; + + jest.spyOn(postRepository, 'findOne').mockResolvedValue(mockPost as any); + jest.spyOn(postRepository, 'find').mockResolvedValue([mockPost] as any); + + const result = await service.getPostAnalytics('post-1', true); + + expect(result).toHaveProperty('post'); + expect(result).toHaveProperty('engagement'); + expect(result).toHaveProperty('performance'); + expect(result).toHaveProperty('comparison'); + expect(result).toHaveProperty('timeSeries'); + + expect(result.post.id).toBe('post-1'); + expect(result.engagement.likes).toBe(50); + expect(result.performance).toHaveProperty('engagementRate'); + expect(result.performance).toHaveProperty('clickThroughRate'); + expect(result.performance).toHaveProperty('viralityScore'); + }); + + it('should return null for non-existent post', async () => { + jest.spyOn(postRepository, 'findOne').mockResolvedValue(null); + + const result = await service.getPostAnalytics('non-existent'); + + expect(result).toBeNull(); + }); + }); + + describe('getCampaignAnalytics', () => { + it('should return detailed campaign analytics', async () => { + const mockCampaign = { + id: 'campaign-1', + name: 'Test Campaign', + status: CampaignStatus.ACTIVE, + budget: 1000, + spentAmount: 500, + startDate: new Date(), + endDate: new Date(), + targeting: { demographics: { ageRange: '25-34' } }, + contentStrategy: { themes: ['event', 'promotion'] }, + }; + + const campaignPosts = [ + { + status: PostStatus.PUBLISHED, + engagement: { engagement: 50, reach: 500, clicks: 25 }, + }, + { + status: PostStatus.PUBLISHED, + engagement: { engagement: 30, reach: 300, clicks: 15 }, + }, + ]; + + jest.spyOn(campaignRepository, 'findOne').mockResolvedValue(mockCampaign as any); + jest.spyOn(postRepository, 'find').mockResolvedValue(campaignPosts as any); + + const result = await service.getCampaignAnalytics('campaign-1', true); + + expect(result).toHaveProperty('campaign'); + expect(result).toHaveProperty('performance'); + expect(result).toHaveProperty('targeting'); + expect(result).toHaveProperty('contentStrategy'); + expect(result).toHaveProperty('postBreakdown'); + + expect(result.campaign.id).toBe('campaign-1'); + expect(result.performance.totalPosts).toBe(2); + expect(result.performance.publishedPosts).toBe(2); + expect(result.performance.totalEngagement).toBe(80); + }); + }); + + describe('getCompetitorAnalysis', () => { + it('should return competitor analysis data', async () => { + const competitors = ['competitor1', 'competitor2']; + + const result = await service.getCompetitorAnalysis('organizer-1', competitors); + + expect(result).toHaveProperty('organizer'); + expect(result).toHaveProperty('competitors'); + expect(result).toHaveProperty('insights'); + + expect(result.organizer.id).toBe('organizer-1'); + expect(result.competitors).toHaveLength(2); + expect(Array.isArray(result.insights)).toBe(true); + + // Check competitor data structure + result.competitors.forEach(competitor => { + expect(competitor).toHaveProperty('name'); + expect(competitor).toHaveProperty('totalFollowers'); + expect(competitor).toHaveProperty('averageEngagement'); + expect(competitor).toHaveProperty('postFrequency'); + }); + }); + }); + + describe('calculateEngagementRate', () => { + it('should calculate engagement rate correctly', () => { + const engagement = { + likes: 50, + comments: 10, + shares: 5, + reach: 1000, + }; + + const rate = service['calculateEngagementRate'](engagement); + + expect(rate).toBe(6.5); // (50 + 10 + 5) / 1000 * 100 + }); + + it('should return 0 for no reach', () => { + const engagement = { + likes: 50, + comments: 10, + shares: 5, + reach: 0, + }; + + const rate = service['calculateEngagementRate'](engagement); + + expect(rate).toBe(0); + }); + }); + + describe('calculateCTR', () => { + it('should calculate click-through rate correctly', () => { + const engagement = { + clicks: 25, + impressions: 1000, + }; + + const ctr = service['calculateCTR'](engagement); + + expect(ctr).toBe(2.5); // 25 / 1000 * 100 + }); + + it('should return 0 for no impressions', () => { + const engagement = { + clicks: 25, + impressions: 0, + }; + + const ctr = service['calculateCTR'](engagement); + + expect(ctr).toBe(0); + }); + }); + + describe('calculateViralityScore', () => { + it('should calculate virality score correctly', () => { + const engagement = { + shares: 10, + reach: 1000, + }; + + const score = service['calculateViralityScore'](engagement); + + expect(score).toBe(1); // 10 / 1000 * 100 + }); + }); +}); diff --git a/src/social-media/services/__tests__/social-post.service.spec.ts b/src/social-media/services/__tests__/social-post.service.spec.ts new file mode 100644 index 00000000..09a6d13c --- /dev/null +++ b/src/social-media/services/__tests__/social-post.service.spec.ts @@ -0,0 +1,253 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import { SocialPostService } from '../social-post.service'; +import { SocialPost, PostStatus, PostType } from '../../entities/social-post.entity'; +import { SocialAccount } from '../../entities/social-account.entity'; +import { SocialMediaApiService } from '../social-media-api.service'; + +describe('SocialPostService', () => { + let service: SocialPostService; + let postRepository: Repository; + let accountRepository: Repository; + let socialMediaApiService: SocialMediaApiService; + + const mockPost = { + id: '1', + accountId: 'account-1', + content: 'Test post content', + postType: PostType.TEXT, + status: PostStatus.DRAFT, + engagement: { + likes: 0, + comments: 0, + shares: 0, + clicks: 0, + impressions: 0, + reach: 0, + saves: 0, + lastUpdated: new Date(), + }, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockAccount = { + id: 'account-1', + platform: 'facebook', + accessToken: 'token', + refreshToken: 'refresh-token', + isActive: true, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SocialPostService, + { + provide: getRepositoryToken(SocialPost), + useValue: { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + remove: jest.fn(), + }, + }, + { + provide: getRepositoryToken(SocialAccount), + useValue: { + findOne: jest.fn(), + }, + }, + { + provide: SocialMediaApiService, + useValue: { + publishPost: jest.fn(), + getPostEngagement: jest.fn(), + }, + }, + { + provide: ConfigService, + useValue: { + get: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(SocialPostService); + postRepository = module.get>(getRepositoryToken(SocialPost)); + accountRepository = module.get>(getRepositoryToken(SocialAccount)); + socialMediaApiService = module.get(SocialMediaApiService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('createPost', () => { + it('should create a new social post', async () => { + const createDto = { + accountId: 'account-1', + postType: PostType.TEXT, + content: 'Test post content', + }; + + jest.spyOn(accountRepository, 'findOne').mockResolvedValue(mockAccount as any); + jest.spyOn(postRepository, 'create').mockReturnValue(mockPost as any); + jest.spyOn(postRepository, 'save').mockResolvedValue(mockPost as any); + + const result = await service.createPost(createDto); + + expect(postRepository.create).toHaveBeenCalledWith({ + ...createDto, + status: PostStatus.DRAFT, + engagement: expect.any(Object), + aiGenerated: expect.any(Object), + }); + expect(postRepository.save).toHaveBeenCalled(); + expect(result).toEqual(mockPost); + }); + + it('should throw error if account not found', async () => { + const createDto = { + accountId: 'invalid-account', + postType: PostType.TEXT, + content: 'Test post content', + }; + + jest.spyOn(accountRepository, 'findOne').mockResolvedValue(null); + + await expect(service.createPost(createDto)).rejects.toThrow('Social account with ID invalid-account not found'); + }); + }); + + describe('publishPost', () => { + it('should publish a post successfully', async () => { + const publishedPost = { ...mockPost, status: PostStatus.PUBLISHED, platformPostId: 'platform-123' }; + + jest.spyOn(service, 'findPostById').mockResolvedValue(mockPost as any); + jest.spyOn(accountRepository, 'findOne').mockResolvedValue(mockAccount as any); + jest.spyOn(socialMediaApiService, 'publishPost').mockResolvedValue({ + success: true, + platformPostId: 'platform-123', + platformUrl: 'https://facebook.com/123', + }); + jest.spyOn(postRepository, 'save').mockResolvedValue(publishedPost as any); + + const result = await service.publishPost('1'); + + expect(socialMediaApiService.publishPost).toHaveBeenCalled(); + expect(result.status).toBe(PostStatus.PUBLISHED); + expect(result.platformPostId).toBe('platform-123'); + }); + + it('should handle publish failure', async () => { + const failedPost = { ...mockPost, status: PostStatus.FAILED, errorMessage: 'API Error' }; + + jest.spyOn(service, 'findPostById').mockResolvedValue(mockPost as any); + jest.spyOn(accountRepository, 'findOne').mockResolvedValue(mockAccount as any); + jest.spyOn(socialMediaApiService, 'publishPost').mockResolvedValue({ + success: false, + error: 'API Error', + }); + jest.spyOn(postRepository, 'save').mockResolvedValue(failedPost as any); + + const result = await service.publishPost('1'); + + expect(result.status).toBe(PostStatus.FAILED); + expect(result.errorMessage).toBe('API Error'); + }); + }); + + describe('schedulePost', () => { + it('should schedule a post for future publishing', async () => { + const futureDate = new Date(Date.now() + 24 * 60 * 60 * 1000); + const scheduledPost = { ...mockPost, status: PostStatus.SCHEDULED, scheduledFor: futureDate }; + + jest.spyOn(service, 'findPostById').mockResolvedValue(mockPost as any); + jest.spyOn(postRepository, 'save').mockResolvedValue(scheduledPost as any); + + const result = await service.schedulePost('1', futureDate); + + expect(result.status).toBe(PostStatus.SCHEDULED); + expect(result.scheduledFor).toBe(futureDate); + }); + + it('should throw error for past date', async () => { + const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000); + + jest.spyOn(service, 'findPostById').mockResolvedValue(mockPost as any); + + await expect(service.schedulePost('1', pastDate)).rejects.toThrow('Scheduled time must be in the future'); + }); + }); + + describe('updateEngagementMetrics', () => { + it('should update engagement metrics from platform', async () => { + const publishedPost = { + ...mockPost, + status: PostStatus.PUBLISHED, + platformPostId: 'platform-123', + }; + + const engagementData = { + likes: 50, + comments: 10, + shares: 5, + impressions: 1000, + }; + + jest.spyOn(service, 'findPostById').mockResolvedValue(publishedPost as any); + jest.spyOn(accountRepository, 'findOne').mockResolvedValue(mockAccount as any); + jest.spyOn(socialMediaApiService, 'getPostEngagement').mockResolvedValue(engagementData); + jest.spyOn(postRepository, 'save').mockResolvedValue({ + ...publishedPost, + engagement: { ...publishedPost.engagement, ...engagementData }, + } as any); + + const result = await service.updateEngagementMetrics('1'); + + expect(socialMediaApiService.getPostEngagement).toHaveBeenCalledWith( + mockAccount.platform, + expect.any(Object), + 'platform-123', + ); + expect(postRepository.save).toHaveBeenCalled(); + }); + }); + + describe('generateAIContent', () => { + it('should generate AI content for social post', async () => { + const result = await service.generateAIContent( + 'Promote our amazing event', + PostType.TEXT, + 'facebook', + ); + + expect(result).toHaveProperty('content'); + expect(result).toHaveProperty('hashtags'); + expect(result).toHaveProperty('confidence'); + expect(result).toHaveProperty('suggestions'); + expect(Array.isArray(result.hashtags)).toBe(true); + expect(Array.isArray(result.suggestions)).toBe(true); + }); + }); + + describe('duplicatePost', () => { + it('should create a duplicate of existing post', async () => { + const duplicatedPost = { ...mockPost, id: '2', content: 'Test post content (Copy)' }; + + jest.spyOn(service, 'findPostById').mockResolvedValue(mockPost as any); + jest.spyOn(postRepository, 'create').mockReturnValue(duplicatedPost as any); + jest.spyOn(postRepository, 'save').mockResolvedValue(duplicatedPost as any); + + const result = await service.duplicatePost('1'); + + expect(result.content).toContain('(Copy)'); + expect(result.status).toBe(PostStatus.DRAFT); + }); + }); +}); diff --git a/src/social-media/services/influencer-collaboration.service.ts b/src/social-media/services/influencer-collaboration.service.ts new file mode 100644 index 00000000..0240fdd1 --- /dev/null +++ b/src/social-media/services/influencer-collaboration.service.ts @@ -0,0 +1,527 @@ +import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between, In } from 'typeorm'; +import { InfluencerCollaboration, CollaborationStatus, CollaborationType, InfluencerTier, CompensationType } from '../entities/influencer-collaboration.entity'; +import { ConfigService } from '@nestjs/config'; + +export interface CreateInfluencerCollaborationDto { + eventId?: string; + influencerId: string; + organizerId: string; + campaignId?: string; + title: string; + description?: string; + collaborationType: CollaborationType; + tier: InfluencerTier; + influencerProfile: any; + deliverables: any[]; + compensation: any; + startDate?: Date; + endDate?: Date; +} + +export interface UpdateCollaborationDto { + title?: string; + description?: string; + deliverables?: any[]; + compensation?: any; + startDate?: Date; + endDate?: Date; + contract?: any; +} + +export interface CollaborationMessage { + sender: 'organizer' | 'influencer'; + message: string; + type: 'message' | 'proposal' | 'revision' | 'approval'; + attachments?: string[]; +} + +@Injectable() +export class InfluencerCollaborationService { + private readonly logger = new Logger(InfluencerCollaborationService.name); + + constructor( + @InjectRepository(InfluencerCollaboration) + private readonly collaborationRepository: Repository, + private readonly configService: ConfigService, + ) {} + + async createCollaboration(dto: CreateInfluencerCollaborationDto): Promise { + const collaboration = this.collaborationRepository.create({ + ...dto, + status: CollaborationStatus.DRAFT, + communication: [], + performance: { + reach: 0, + impressions: 0, + engagement: 0, + clicks: 0, + conversions: 0, + mentions: 0, + hashtags: {}, + sentiment: { + positive: 0, + negative: 0, + neutral: 0, + }, + roi: 0, + costPerEngagement: 0, + brandLift: 0, + }, + }); + + const savedCollaboration = await this.collaborationRepository.save(collaboration); + this.logger.log(`Created influencer collaboration: ${savedCollaboration.id}`); + return savedCollaboration; + } + + async findCollaborationById(id: string): Promise { + const collaboration = await this.collaborationRepository.findOne({ + where: { id }, + }); + + if (!collaboration) { + throw new NotFoundException(`Influencer collaboration with ID ${id} not found`); + } + + return collaboration; + } + + async findCollaborationsByOrganizer(organizerId: string): Promise { + return this.collaborationRepository.find({ + where: { organizerId }, + order: { createdAt: 'DESC' }, + }); + } + + async findCollaborationsByInfluencer(influencerId: string): Promise { + return this.collaborationRepository.find({ + where: { influencerId }, + order: { createdAt: 'DESC' }, + }); + } + + async findCollaborationsByEvent(eventId: string): Promise { + return this.collaborationRepository.find({ + where: { eventId }, + order: { createdAt: 'DESC' }, + }); + } + + async updateCollaboration(id: string, dto: UpdateCollaborationDto): Promise { + const collaboration = await this.findCollaborationById(id); + + if (collaboration.status === CollaborationStatus.COMPLETED) { + throw new BadRequestException('Cannot update completed collaborations'); + } + + Object.assign(collaboration, dto); + return this.collaborationRepository.save(collaboration); + } + + async inviteInfluencer(id: string): Promise { + const collaboration = await this.findCollaborationById(id); + + if (collaboration.status !== CollaborationStatus.DRAFT) { + throw new BadRequestException('Can only invite from draft status'); + } + + collaboration.status = CollaborationStatus.INVITED; + collaboration.invitedAt = new Date(); + + // Add invitation message to communication + const invitationMessage: CollaborationMessage = { + sender: 'organizer', + message: `You have been invited to collaborate on: ${collaboration.title}`, + type: 'message', + }; + + collaboration.communication = collaboration.communication || []; + collaboration.communication.push({ + ...invitationMessage, + timestamp: new Date(), + }); + + const savedCollaboration = await this.collaborationRepository.save(collaboration); + this.logger.log(`Invited influencer for collaboration: ${id}`); + return savedCollaboration; + } + + async acceptCollaboration(id: string): Promise { + const collaboration = await this.findCollaborationById(id); + + if (collaboration.status !== CollaborationStatus.INVITED) { + throw new BadRequestException('Can only accept invited collaborations'); + } + + collaboration.status = CollaborationStatus.ACCEPTED; + collaboration.acceptedAt = new Date(); + + // Add acceptance message + const acceptanceMessage: CollaborationMessage = { + sender: 'influencer', + message: 'Collaboration accepted! Looking forward to working together.', + type: 'message', + }; + + collaboration.communication.push({ + ...acceptanceMessage, + timestamp: new Date(), + }); + + return this.collaborationRepository.save(collaboration); + } + + async rejectCollaboration(id: string, reason?: string): Promise { + const collaboration = await this.findCollaborationById(id); + + if (![CollaborationStatus.INVITED, CollaborationStatus.NEGOTIATING].includes(collaboration.status)) { + throw new BadRequestException('Cannot reject collaboration in current status'); + } + + collaboration.status = CollaborationStatus.REJECTED; + + // Add rejection message + const rejectionMessage: CollaborationMessage = { + sender: 'influencer', + message: reason || 'Collaboration declined.', + type: 'message', + }; + + collaboration.communication.push({ + ...rejectionMessage, + timestamp: new Date(), + }); + + return this.collaborationRepository.save(collaboration); + } + + async startCollaboration(id: string): Promise { + const collaboration = await this.findCollaborationById(id); + + if (collaboration.status !== CollaborationStatus.ACCEPTED) { + throw new BadRequestException('Collaboration must be accepted before starting'); + } + + collaboration.status = CollaborationStatus.ACTIVE; + if (!collaboration.startDate) { + collaboration.startDate = new Date(); + } + + return this.collaborationRepository.save(collaboration); + } + + async completeCollaboration(id: string): Promise { + const collaboration = await this.findCollaborationById(id); + + if (collaboration.status !== CollaborationStatus.ACTIVE) { + throw new BadRequestException('Only active collaborations can be completed'); + } + + // Check if all deliverables are completed + const incompleteDeliverables = collaboration.deliverables.filter( + d => d.status !== 'completed' + ); + + if (incompleteDeliverables.length > 0) { + throw new BadRequestException('All deliverables must be completed before finishing collaboration'); + } + + collaboration.status = CollaborationStatus.COMPLETED; + collaboration.completedAt = new Date(); + + return this.collaborationRepository.save(collaboration); + } + + async addMessage(id: string, message: CollaborationMessage): Promise { + const collaboration = await this.findCollaborationById(id); + + collaboration.communication = collaboration.communication || []; + collaboration.communication.push({ + ...message, + timestamp: new Date(), + }); + + return this.collaborationRepository.save(collaboration); + } + + async updateDeliverable( + id: string, + deliverableIndex: number, + updates: { + status?: string; + postUrl?: string; + submittedAt?: Date; + approvedAt?: Date; + }, + ): Promise { + const collaboration = await this.findCollaborationById(id); + + if (deliverableIndex >= collaboration.deliverables.length) { + throw new BadRequestException('Invalid deliverable index'); + } + + Object.assign(collaboration.deliverables[deliverableIndex], updates); + + // If deliverable is being submitted, add timestamp + if (updates.status === 'submitted' && !updates.submittedAt) { + collaboration.deliverables[deliverableIndex].submittedAt = new Date(); + } + + // If deliverable is being approved, add timestamp + if (updates.status === 'approved' && !updates.approvedAt) { + collaboration.deliverables[deliverableIndex].approvedAt = new Date(); + } + + return this.collaborationRepository.save(collaboration); + } + + async updatePerformanceMetrics( + id: string, + metrics: { + reach?: number; + impressions?: number; + engagement?: number; + clicks?: number; + conversions?: number; + mentions?: number; + hashtags?: Record; + sentiment?: { + positive: number; + negative: number; + neutral: number; + }; + }, + ): Promise { + const collaboration = await this.findCollaborationById(id); + + collaboration.performance = { + ...collaboration.performance, + ...metrics, + }; + + // Calculate derived metrics + const compensation = collaboration.compensation.amount || 0; + if (metrics.engagement && compensation > 0) { + collaboration.performance.costPerEngagement = compensation / metrics.engagement; + } + + if (metrics.conversions && compensation > 0) { + // Assuming average conversion value - this would be configurable + const avgConversionValue = 50; + const revenue = metrics.conversions * avgConversionValue; + collaboration.performance.roi = ((revenue - compensation) / compensation) * 100; + } + + return this.collaborationRepository.save(collaboration); + } + + async rateCollaboration( + id: string, + rating: number, + feedback?: string, + ): Promise { + const collaboration = await this.findCollaborationById(id); + + if (collaboration.status !== CollaborationStatus.COMPLETED) { + throw new BadRequestException('Can only rate completed collaborations'); + } + + if (rating < 1 || rating > 5) { + throw new BadRequestException('Rating must be between 1 and 5'); + } + + collaboration.satisfactionRating = rating; + collaboration.feedback = feedback; + + return this.collaborationRepository.save(collaboration); + } + + async getCollaborationAnalytics( + organizerId: string, + dateRange?: { start: Date; end: Date }, + ): Promise { + const whereClause: any = { organizerId }; + + if (dateRange) { + whereClause.createdAt = Between(dateRange.start, dateRange.end); + } + + const collaborations = await this.collaborationRepository.find({ + where: whereClause, + }); + + const analytics = { + totalCollaborations: collaborations.length, + statusBreakdown: {}, + tierBreakdown: {}, + typeBreakdown: {}, + totalSpent: 0, + averageRating: 0, + totalReach: 0, + totalEngagement: 0, + averageROI: 0, + topPerformingInfluencers: [], + completionRate: 0, + }; + + // Status breakdown + for (const collab of collaborations) { + analytics.statusBreakdown[collab.status] = + (analytics.statusBreakdown[collab.status] || 0) + 1; + } + + // Tier breakdown + for (const collab of collaborations) { + analytics.tierBreakdown[collab.tier] = + (analytics.tierBreakdown[collab.tier] || 0) + 1; + } + + // Type breakdown + for (const collab of collaborations) { + analytics.typeBreakdown[collab.collaborationType] = + (analytics.typeBreakdown[collab.collaborationType] || 0) + 1; + } + + // Financial metrics + analytics.totalSpent = collaborations.reduce( + (sum, c) => sum + (c.compensation.amount || 0), 0 + ); + + // Performance metrics + const completedCollabs = collaborations.filter( + c => c.status === CollaborationStatus.COMPLETED + ); + + if (completedCollabs.length > 0) { + analytics.completionRate = (completedCollabs.length / collaborations.length) * 100; + + const ratedCollabs = completedCollabs.filter(c => c.satisfactionRating); + if (ratedCollabs.length > 0) { + analytics.averageRating = ratedCollabs.reduce( + (sum, c) => sum + c.satisfactionRating, 0 + ) / ratedCollabs.length; + } + + analytics.totalReach = completedCollabs.reduce( + (sum, c) => sum + (c.performance?.reach || 0), 0 + ); + + analytics.totalEngagement = completedCollabs.reduce( + (sum, c) => sum + (c.performance?.engagement || 0), 0 + ); + + const collabsWithROI = completedCollabs.filter(c => c.performance?.roi); + if (collabsWithROI.length > 0) { + analytics.averageROI = collabsWithROI.reduce( + (sum, c) => sum + c.performance.roi, 0 + ) / collabsWithROI.length; + } + } + + // Top performing influencers + analytics.topPerformingInfluencers = completedCollabs + .filter(c => c.performance?.engagement) + .sort((a, b) => (b.performance?.engagement || 0) - (a.performance?.engagement || 0)) + .slice(0, 10) + .map(c => ({ + influencerId: c.influencerId, + influencerName: c.influencerProfile.name, + tier: c.tier, + engagement: c.performance?.engagement || 0, + reach: c.performance?.reach || 0, + roi: c.performance?.roi || 0, + rating: c.satisfactionRating, + })); + + return analytics; + } + + async findInfluencersByTier(tier: InfluencerTier, limit: number = 50): Promise { + // This would integrate with an influencer database or external service + // For now, returning mock data structure + const mockInfluencers = [ + { + id: 'inf_1', + name: 'Sarah Johnson', + username: '@sarahjohnson', + tier: tier, + platforms: [ + { + platform: 'instagram', + handle: '@sarahjohnson', + followers: 150000, + engagementRate: 3.2, + verificationStatus: true, + }, + ], + demographics: { + primaryAudience: { + ageRange: '25-34', + gender: 'female', + locations: ['United States', 'Canada'], + interests: ['lifestyle', 'fashion', 'events'], + }, + }, + rates: { + postRate: 2500, + storyRate: 1000, + videoRate: 4000, + }, + }, + ]; + + return mockInfluencers.slice(0, limit); + } + + async searchInfluencers(criteria: { + tier?: InfluencerTier; + platforms?: string[]; + minFollowers?: number; + maxFollowers?: number; + interests?: string[]; + location?: string; + minEngagementRate?: number; + maxBudget?: number; + }): Promise { + // This would integrate with influencer discovery platforms + // For now, returning filtered mock data + this.logger.log(`Searching influencers with criteria: ${JSON.stringify(criteria)}`); + + return this.findInfluencersByTier(criteria.tier || InfluencerTier.MICRO, 20); + } + + async generateCollaborationContract(id: string): Promise { + const collaboration = await this.findCollaborationById(id); + + // This would integrate with a contract generation service + const contractTemplate = ` +INFLUENCER COLLABORATION AGREEMENT + +Collaboration: ${collaboration.title} +Influencer: ${collaboration.influencerProfile.name} +Event: ${collaboration.eventId || 'N/A'} + +DELIVERABLES: +${collaboration.deliverables.map((d, i) => + `${i + 1}. ${d.description} - Due: ${d.deadline}` +).join('\n')} + +COMPENSATION: +Type: ${collaboration.compensation.type} +Amount: ${collaboration.compensation.amount} ${collaboration.compensation.currency || 'USD'} +Payment Terms: ${collaboration.compensation.paymentTerms.schedule} + +TERMS: +- Collaboration Period: ${collaboration.startDate} to ${collaboration.endDate} +- Usage Rights: As specified in platform terms +- Content Approval: Required before posting +- Cancellation: As per standard terms + +Generated on: ${new Date().toISOString()} + `; + + return contractTemplate.trim(); + } +} diff --git a/src/social-media/services/referral-program.service.ts b/src/social-media/services/referral-program.service.ts new file mode 100644 index 00000000..b014520a --- /dev/null +++ b/src/social-media/services/referral-program.service.ts @@ -0,0 +1,509 @@ +import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between, MoreThan } from 'typeorm'; +import { ReferralProgram, ProgramStatus } from '../entities/referral-program.entity'; +import { ReferralCode, CodeStatus } from '../entities/referral-code.entity'; +import { ReferralTracking, TrackingStatus, ConversionType } from '../entities/referral-tracking.entity'; +import { ConfigService } from '@nestjs/config'; +import { randomBytes } from 'crypto'; + +export interface CreateReferralProgramDto { + eventId?: string; + organizerId: string; + name: string; + description?: string; + programType: string; + rewardType: string; + rewardValue: number; + currency?: string; + startDate?: Date; + endDate?: Date; + eligibilityRules?: any; + fraudPrevention?: any; + socialSharing?: any; +} + +export interface CreateReferralCodeDto { + programId: string; + userId: string; + customCode?: string; + usageLimit?: number; + expiresAt?: Date; +} + +export interface ReferralConversionDto { + codeId: string; + convertedUserId?: string; + conversionType: ConversionType; + conversionValue?: number; + metadata?: any; +} + +@Injectable() +export class ReferralProgramService { + private readonly logger = new Logger(ReferralProgramService.name); + + constructor( + @InjectRepository(ReferralProgram) + private readonly programRepository: Repository, + @InjectRepository(ReferralCode) + private readonly codeRepository: Repository, + @InjectRepository(ReferralTracking) + private readonly trackingRepository: Repository, + private readonly configService: ConfigService, + ) {} + + async createProgram(dto: CreateReferralProgramDto): Promise { + const program = this.programRepository.create({ + ...dto, + status: ProgramStatus.DRAFT, + analytics: { + totalCodes: 0, + activeCodes: 0, + totalClicks: 0, + totalConversions: 0, + totalRevenue: 0, + conversionRate: 0, + averageOrderValue: 0, + }, + }); + + const savedProgram = await this.programRepository.save(program); + this.logger.log(`Created referral program: ${savedProgram.id}`); + return savedProgram; + } + + async findProgramById(id: string): Promise { + const program = await this.programRepository.findOne({ + where: { id }, + relations: ['codes'], + }); + + if (!program) { + throw new NotFoundException(`Referral program with ID ${id} not found`); + } + + return program; + } + + async findProgramsByOrganizer(organizerId: string): Promise { + return this.programRepository.find({ + where: { organizerId }, + order: { createdAt: 'DESC' }, + }); + } + + async findProgramsByEvent(eventId: string): Promise { + return this.programRepository.find({ + where: { eventId }, + order: { createdAt: 'DESC' }, + }); + } + + async updateProgramStatus(id: string, status: ProgramStatus): Promise { + const program = await this.findProgramById(id); + program.status = status; + + if (status === ProgramStatus.ACTIVE && !program.startDate) { + program.startDate = new Date(); + } + + if (status === ProgramStatus.ENDED && !program.endDate) { + program.endDate = new Date(); + } + + return this.programRepository.save(program); + } + + async generateReferralCode(dto: CreateReferralCodeDto): Promise { + const program = await this.findProgramById(dto.programId); + + if (program.status !== ProgramStatus.ACTIVE) { + throw new BadRequestException('Cannot generate codes for inactive programs'); + } + + // Check if user already has a code for this program + const existingCode = await this.codeRepository.findOne({ + where: { + programId: dto.programId, + userId: dto.userId, + status: CodeStatus.ACTIVE, + }, + }); + + if (existingCode) { + return existingCode; + } + + const code = dto.customCode || this.generateUniqueCode(); + + // Ensure code is unique + const codeExists = await this.codeRepository.findOne({ + where: { code }, + }); + + if (codeExists) { + throw new BadRequestException('Referral code already exists'); + } + + const referralCode = this.codeRepository.create({ + ...dto, + code, + status: CodeStatus.ACTIVE, + analytics: { + totalClicks: 0, + uniqueClicks: 0, + conversions: 0, + revenue: 0, + conversionRate: 0, + lastClickAt: null, + topSources: {}, + deviceBreakdown: {}, + locationBreakdown: {}, + }, + socialSharing: { + totalShares: 0, + platformBreakdown: {}, + lastSharedAt: null, + }, + }); + + const savedCode = await this.codeRepository.save(referralCode); + + // Update program analytics + await this.updateProgramAnalytics(dto.programId); + + this.logger.log(`Generated referral code: ${savedCode.code} for user: ${dto.userId}`); + return savedCode; + } + + async findCodeByCode(code: string): Promise { + const referralCode = await this.codeRepository.findOne({ + where: { code }, + relations: ['program'], + }); + + if (!referralCode) { + throw new NotFoundException(`Referral code ${code} not found`); + } + + return referralCode; + } + + async findCodesByUser(userId: string): Promise { + return this.codeRepository.find({ + where: { userId }, + relations: ['program'], + order: { createdAt: 'DESC' }, + }); + } + + async trackReferralClick( + code: string, + metadata: { + ipAddress?: string; + userAgent?: string; + referrer?: string; + utmSource?: string; + utmMedium?: string; + utmCampaign?: string; + }, + ): Promise { + const referralCode = await this.findCodeByCode(code); + + // Check if code is still valid + if (referralCode.status !== CodeStatus.ACTIVE) { + throw new BadRequestException('Referral code is not active'); + } + + if (referralCode.expiresAt && new Date() > referralCode.expiresAt) { + throw new BadRequestException('Referral code has expired'); + } + + if (referralCode.usageLimit && referralCode.usedCount >= referralCode.usageLimit) { + throw new BadRequestException('Referral code usage limit exceeded'); + } + + // Create tracking record + const tracking = this.trackingRepository.create({ + codeId: referralCode.id, + programId: referralCode.programId, + status: TrackingStatus.CLICKED, + deviceInfo: { + userAgent: metadata.userAgent, + ipAddress: metadata.ipAddress, + }, + sourceInfo: { + referrer: metadata.referrer, + utmSource: metadata.utmSource, + utmMedium: metadata.utmMedium, + utmCampaign: metadata.utmCampaign, + }, + fraudDetection: { + riskScore: await this.calculateRiskScore(metadata), + flags: [], + isBlocked: false, + }, + }); + + const savedTracking = await this.trackingRepository.save(tracking); + + // Update code analytics + await this.updateCodeAnalytics(referralCode.id, 'click', metadata); + + this.logger.log(`Tracked referral click for code: ${code}`); + return savedTracking; + } + + async processReferralConversion(dto: ReferralConversionDto): Promise { + const referralCode = await this.codeRepository.findOne({ + where: { id: dto.codeId }, + relations: ['program'], + }); + + if (!referralCode) { + throw new NotFoundException(`Referral code with ID ${dto.codeId} not found`); + } + + // Find the most recent click tracking for this code + const clickTracking = await this.trackingRepository.findOne({ + where: { + codeId: dto.codeId, + status: TrackingStatus.CLICKED, + }, + order: { createdAt: 'DESC' }, + }); + + if (!clickTracking) { + throw new BadRequestException('No click tracking found for this referral code'); + } + + // Update tracking with conversion + clickTracking.status = TrackingStatus.CONVERTED; + clickTracking.convertedUserId = dto.convertedUserId; + clickTracking.conversionType = dto.conversionType; + clickTracking.conversionValue = dto.conversionValue || 0; + clickTracking.conversionData = { + ...dto.metadata, + convertedAt: new Date(), + }; + + const savedTracking = await this.trackingRepository.save(clickTracking); + + // Update code analytics + await this.updateCodeAnalytics(dto.codeId, 'conversion', { + conversionValue: dto.conversionValue, + conversionType: dto.conversionType, + }); + + // Update program analytics + await this.updateProgramAnalytics(referralCode.programId); + + // Calculate and award rewards + await this.processRewards(referralCode, dto.conversionValue || 0); + + this.logger.log(`Processed referral conversion for code: ${referralCode.code}`); + return savedTracking; + } + + async getReferralAnalytics(codeId: string, dateRange?: { start: Date; end: Date }) { + const code = await this.codeRepository.findOne({ + where: { id: codeId }, + relations: ['program'], + }); + + if (!code) { + throw new NotFoundException(`Referral code with ID ${codeId} not found`); + } + + const whereClause: any = { codeId }; + if (dateRange) { + whereClause.createdAt = Between(dateRange.start, dateRange.end); + } + + const trackingRecords = await this.trackingRepository.find({ + where: whereClause, + order: { createdAt: 'DESC' }, + }); + + const analytics = { + totalClicks: trackingRecords.filter(t => t.status === TrackingStatus.CLICKED).length, + totalConversions: trackingRecords.filter(t => t.status === TrackingStatus.CONVERTED).length, + conversionRate: 0, + totalRevenue: 0, + averageOrderValue: 0, + topSources: {}, + deviceBreakdown: {}, + timeSeriesData: [], + }; + + if (analytics.totalClicks > 0) { + analytics.conversionRate = (analytics.totalConversions / analytics.totalClicks) * 100; + } + + const conversions = trackingRecords.filter(t => t.status === TrackingStatus.CONVERTED); + analytics.totalRevenue = conversions.reduce((sum, t) => sum + (t.conversionValue || 0), 0); + + if (analytics.totalConversions > 0) { + analytics.averageOrderValue = analytics.totalRevenue / analytics.totalConversions; + } + + return analytics; + } + + async getProgramAnalytics(programId: string, dateRange?: { start: Date; end: Date }) { + const program = await this.findProgramById(programId); + + const whereClause: any = { programId }; + if (dateRange) { + whereClause.createdAt = Between(dateRange.start, dateRange.end); + } + + const trackingRecords = await this.trackingRepository.find({ + where: whereClause, + }); + + const codes = await this.codeRepository.find({ + where: { programId }, + }); + + return { + program: { + id: program.id, + name: program.name, + status: program.status, + totalCodes: codes.length, + activeCodes: codes.filter(c => c.status === CodeStatus.ACTIVE).length, + }, + performance: { + totalClicks: trackingRecords.filter(t => t.status === TrackingStatus.CLICKED).length, + totalConversions: trackingRecords.filter(t => t.status === TrackingStatus.CONVERTED).length, + totalRevenue: trackingRecords + .filter(t => t.status === TrackingStatus.CONVERTED) + .reduce((sum, t) => sum + (t.conversionValue || 0), 0), + }, + topPerformingCodes: await this.getTopPerformingCodes(programId, 10), + }; + } + + private generateUniqueCode(): string { + const prefix = this.configService.get('REFERRAL_CODE_PREFIX', 'REF'); + const randomPart = randomBytes(4).toString('hex').toUpperCase(); + return `${prefix}${randomPart}`; + } + + private async calculateRiskScore(metadata: any): Promise { + let riskScore = 0; + + // Check for suspicious patterns + if (!metadata.userAgent) riskScore += 20; + if (!metadata.referrer) riskScore += 10; + + // Check for bot-like behavior + if (metadata.userAgent && metadata.userAgent.toLowerCase().includes('bot')) { + riskScore += 50; + } + + // Check for repeated IP addresses (simplified) + if (metadata.ipAddress) { + const recentClicks = await this.trackingRepository.count({ + where: { + createdAt: MoreThan(new Date(Date.now() - 24 * 60 * 60 * 1000)), // Last 24 hours + }, + }); + + if (recentClicks > 10) riskScore += 30; + } + + return Math.min(riskScore, 100); + } + + private async updateCodeAnalytics(codeId: string, eventType: 'click' | 'conversion', metadata: any) { + const code = await this.codeRepository.findOne({ where: { id: codeId } }); + if (!code) return; + + const analytics = code.analytics || {}; + + if (eventType === 'click') { + analytics.totalClicks = (analytics.totalClicks || 0) + 1; + analytics.lastClickAt = new Date(); + + // Update source breakdown + if (metadata.utmSource) { + analytics.topSources = analytics.topSources || {}; + analytics.topSources[metadata.utmSource] = (analytics.topSources[metadata.utmSource] || 0) + 1; + } + } else if (eventType === 'conversion') { + analytics.conversions = (analytics.conversions || 0) + 1; + analytics.revenue = (analytics.revenue || 0) + (metadata.conversionValue || 0); + + if (analytics.totalClicks > 0) { + analytics.conversionRate = (analytics.conversions / analytics.totalClicks) * 100; + } + } + + code.analytics = analytics; + await this.codeRepository.save(code); + } + + private async updateProgramAnalytics(programId: string) { + const program = await this.programRepository.findOne({ where: { id: programId } }); + if (!program) return; + + const codes = await this.codeRepository.find({ where: { programId } }); + const trackingRecords = await this.trackingRepository.find({ where: { programId } }); + + const analytics = { + totalCodes: codes.length, + activeCodes: codes.filter(c => c.status === CodeStatus.ACTIVE).length, + totalClicks: trackingRecords.filter(t => t.status === TrackingStatus.CLICKED).length, + totalConversions: trackingRecords.filter(t => t.status === TrackingStatus.CONVERTED).length, + totalRevenue: trackingRecords + .filter(t => t.status === TrackingStatus.CONVERTED) + .reduce((sum, t) => sum + (t.conversionValue || 0), 0), + conversionRate: 0, + averageOrderValue: 0, + }; + + if (analytics.totalClicks > 0) { + analytics.conversionRate = (analytics.totalConversions / analytics.totalClicks) * 100; + } + + if (analytics.totalConversions > 0) { + analytics.averageOrderValue = analytics.totalRevenue / analytics.totalConversions; + } + + program.analytics = analytics; + await this.programRepository.save(program); + } + + private async processRewards(code: ReferralCode, conversionValue: number) { + const program = code.program; + if (!program) return; + + // Calculate reward based on program settings + let rewardAmount = 0; + + if (program.rewardType === 'fixed') { + rewardAmount = program.rewardValue; + } else if (program.rewardType === 'percentage') { + rewardAmount = (conversionValue * program.rewardValue) / 100; + } + + // Update code with reward information + code.totalRewards = (code.totalRewards || 0) + rewardAmount; + code.totalRevenue = (code.totalRevenue || 0) + conversionValue; + + await this.codeRepository.save(code); + + this.logger.log(`Processed reward of ${rewardAmount} for referral code: ${code.code}`); + } + + private async getTopPerformingCodes(programId: string, limit: number = 10) { + return this.codeRepository.find({ + where: { programId }, + order: { totalRevenue: 'DESC' }, + take: limit, + }); + } +} diff --git a/src/social-media/services/social-media-analytics.service.ts b/src/social-media/services/social-media-analytics.service.ts new file mode 100644 index 00000000..5c7692a6 --- /dev/null +++ b/src/social-media/services/social-media-analytics.service.ts @@ -0,0 +1,736 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between, In } from 'typeorm'; +import { SocialPost, PostStatus } from '../entities/social-post.entity'; +import { SocialCampaign, CampaignStatus } from '../entities/social-campaign.entity'; +import { ReferralProgram } from '../entities/referral-program.entity'; +import { ReferralTracking, TrackingStatus } from '../entities/referral-tracking.entity'; +import { SocialProof, ProofStatus } from '../entities/social-proof.entity'; +import { InfluencerCollaboration, CollaborationStatus } from '../entities/influencer-collaboration.entity'; +import { UserGeneratedContent, ContentStatus } from '../entities/user-generated-content.entity'; + +export interface AnalyticsDateRange { + start: Date; + end: Date; +} + +export interface SocialMediaDashboard { + overview: { + totalPosts: number; + totalEngagement: number; + totalReach: number; + totalClicks: number; + engagementRate: number; + topPlatform: string; + }; + campaigns: { + activeCampaigns: number; + totalCampaigns: number; + averageROI: number; + topPerformingCampaign: any; + }; + referrals: { + activePrograms: number; + totalConversions: number; + totalRevenue: number; + conversionRate: number; + }; + influencers: { + activeCollaborations: number; + totalReach: number; + averageEngagement: number; + topInfluencer: any; + }; + socialProof: { + totalProofs: number; + approvedProofs: number; + averageCTR: number; + topProofType: string; + }; + ugc: { + totalContent: number; + approvedContent: number; + averageQualityScore: number; + topPlatform: string; + }; +} + +@Injectable() +export class SocialMediaAnalyticsService { + private readonly logger = new Logger(SocialMediaAnalyticsService.name); + + constructor( + @InjectRepository(SocialPost) + private readonly postRepository: Repository, + @InjectRepository(SocialCampaign) + private readonly campaignRepository: Repository, + @InjectRepository(ReferralProgram) + private readonly referralProgramRepository: Repository, + @InjectRepository(ReferralTracking) + private readonly referralTrackingRepository: Repository, + @InjectRepository(SocialProof) + private readonly socialProofRepository: Repository, + @InjectRepository(InfluencerCollaboration) + private readonly influencerRepository: Repository, + @InjectRepository(UserGeneratedContent) + private readonly ugcRepository: Repository, + ) {} + + async getDashboard( + organizerId: string, + dateRange?: AnalyticsDateRange, + ): Promise { + const whereClause: any = { organizerId }; + + if (dateRange) { + whereClause.createdAt = Between(dateRange.start, dateRange.end); + } + + const [ + posts, + campaigns, + referralPrograms, + referralTracking, + socialProofs, + influencerCollabs, + ugcContent, + ] = await Promise.all([ + this.postRepository.find({ where: whereClause }), + this.campaignRepository.find({ where: whereClause }), + this.referralProgramRepository.find({ where: whereClause }), + this.referralTrackingRepository.find({ where: whereClause }), + this.socialProofRepository.find({ where: whereClause }), + this.influencerRepository.find({ where: whereClause }), + this.ugcRepository.find({ where: whereClause }), + ]); + + return { + overview: this.calculateOverviewMetrics(posts), + campaigns: this.calculateCampaignMetrics(campaigns), + referrals: this.calculateReferralMetrics(referralPrograms, referralTracking), + influencers: this.calculateInfluencerMetrics(influencerCollabs), + socialProof: this.calculateSocialProofMetrics(socialProofs), + ugc: this.calculateUGCMetrics(ugcContent), + }; + } + + async getPostAnalytics( + postId: string, + includeTimeSeries: boolean = false, + ): Promise { + const post = await this.postRepository.findOne({ + where: { id: postId }, + relations: ['account', 'campaign'], + }); + + if (!post) { + return null; + } + + const analytics = { + post: { + id: post.id, + content: post.content.substring(0, 100), + platform: post.account?.platform, + status: post.status, + publishedAt: post.publishedAt, + }, + engagement: post.engagement, + performance: { + engagementRate: this.calculateEngagementRate(post.engagement), + clickThroughRate: this.calculateCTR(post.engagement), + viralityScore: this.calculateViralityScore(post.engagement), + }, + comparison: await this.getPostComparison(post), + }; + + if (includeTimeSeries) { + analytics['timeSeries'] = await this.getPostTimeSeriesData(postId); + } + + return analytics; + } + + async getCampaignAnalytics( + campaignId: string, + includePostBreakdown: boolean = false, + ): Promise { + const campaign = await this.campaignRepository.findOne({ + where: { id: campaignId }, + relations: ['posts', 'account'], + }); + + if (!campaign) { + return null; + } + + const posts = await this.postRepository.find({ + where: { campaignId }, + }); + + const analytics = { + campaign: { + id: campaign.id, + name: campaign.name, + status: campaign.status, + budget: campaign.budget, + spentAmount: campaign.spentAmount, + startDate: campaign.startDate, + endDate: campaign.endDate, + }, + performance: { + totalPosts: posts.length, + publishedPosts: posts.filter(p => p.status === PostStatus.PUBLISHED).length, + totalEngagement: posts.reduce((sum, p) => sum + (p.engagement?.engagement || 0), 0), + totalReach: posts.reduce((sum, p) => sum + (p.engagement?.reach || 0), 0), + totalClicks: posts.reduce((sum, p) => sum + (p.engagement?.clicks || 0), 0), + averageEngagementRate: this.calculateAverageEngagementRate(posts), + roi: this.calculateCampaignROI(campaign, posts), + costPerEngagement: this.calculateCostPerEngagement(campaign, posts), + }, + targeting: campaign.targeting, + contentStrategy: campaign.contentStrategy, + }; + + if (includePostBreakdown) { + analytics['postBreakdown'] = posts.map(post => ({ + id: post.id, + content: post.content.substring(0, 50), + status: post.status, + engagement: post.engagement, + publishedAt: post.publishedAt, + })); + } + + return analytics; + } + + async getReferralAnalytics( + programId: string, + dateRange?: AnalyticsDateRange, + ): Promise { + const program = await this.referralProgramRepository.findOne({ + where: { id: programId }, + }); + + if (!program) { + return null; + } + + const whereClause: any = { programId }; + if (dateRange) { + whereClause.createdAt = Between(dateRange.start, dateRange.end); + } + + const trackingRecords = await this.referralTrackingRepository.find({ + where: whereClause, + }); + + const clicks = trackingRecords.filter(t => t.status === TrackingStatus.CLICKED); + const conversions = trackingRecords.filter(t => t.status === TrackingStatus.CONVERTED); + + return { + program: { + id: program.id, + name: program.name, + status: program.status, + rewardType: program.rewardType, + rewardValue: program.rewardValue, + }, + performance: { + totalClicks: clicks.length, + totalConversions: conversions.length, + conversionRate: clicks.length > 0 ? (conversions.length / clicks.length) * 100 : 0, + totalRevenue: conversions.reduce((sum, c) => sum + (c.conversionValue || 0), 0), + averageOrderValue: conversions.length > 0 + ? conversions.reduce((sum, c) => sum + (c.conversionValue || 0), 0) / conversions.length + : 0, + }, + sources: this.analyzeReferralSources(trackingRecords), + timeSeriesData: this.generateReferralTimeSeries(trackingRecords, dateRange), + }; + } + + async getInfluencerAnalytics( + collaborationId: string, + ): Promise { + const collaboration = await this.influencerRepository.findOne({ + where: { id: collaborationId }, + }); + + if (!collaboration) { + return null; + } + + return { + collaboration: { + id: collaboration.id, + title: collaboration.title, + status: collaboration.status, + tier: collaboration.tier, + influencerName: collaboration.influencerProfile.name, + totalFollowers: collaboration.totalFollowers, + averageEngagementRate: collaboration.averageEngagementRate, + }, + performance: collaboration.performance, + deliverables: { + total: collaboration.deliverables.length, + completed: collaboration.completedDeliverables, + pending: collaboration.pendingDeliverables, + completionRate: collaboration.deliverables.length > 0 + ? (collaboration.completedDeliverables / collaboration.deliverables.length) * 100 + : 0, + }, + roi: collaboration.roi, + compensation: collaboration.compensation, + }; + } + + async getSocialProofAnalytics( + eventId: string, + dateRange?: AnalyticsDateRange, + ): Promise { + const whereClause: any = { eventId }; + if (dateRange) { + whereClause.createdAt = Between(dateRange.start, dateRange.end); + } + + const proofs = await this.socialProofRepository.find({ + where: whereClause, + }); + + const typeBreakdown = {}; + const platformBreakdown = {}; + let totalDisplays = 0; + let totalClicks = 0; + + for (const proof of proofs) { + typeBreakdown[proof.proofType] = (typeBreakdown[proof.proofType] || 0) + 1; + if (proof.platform) { + platformBreakdown[proof.platform] = (platformBreakdown[proof.platform] || 0) + 1; + } + totalDisplays += proof.displayCount; + totalClicks += proof.clickCount; + } + + return { + overview: { + totalProofs: proofs.length, + approvedProofs: proofs.filter(p => p.status === ProofStatus.APPROVED).length, + pendingProofs: proofs.filter(p => p.status === ProofStatus.PENDING).length, + totalDisplays, + totalClicks, + averageCTR: totalDisplays > 0 ? (totalClicks / totalDisplays) * 100 : 0, + }, + breakdown: { + byType: typeBreakdown, + byPlatform: platformBreakdown, + }, + topPerforming: proofs + .filter(p => p.displayCount > 0) + .sort((a, b) => b.clickThroughRate - a.clickThroughRate) + .slice(0, 10) + .map(p => ({ + id: p.id, + type: p.proofType, + content: p.content.substring(0, 100), + displayCount: p.displayCount, + clickCount: p.clickCount, + clickThroughRate: p.clickThroughRate, + })), + }; + } + + async getUGCAnalytics( + eventId: string, + dateRange?: AnalyticsDateRange, + ): Promise { + const whereClause: any = { eventId }; + if (dateRange) { + whereClause.createdAt = Between(dateRange.start, dateRange.end); + } + + const content = await this.ugcRepository.find({ + where: whereClause, + }); + + const platformBreakdown = {}; + const typeBreakdown = {}; + let totalEngagement = 0; + let totalQualityScore = 0; + let qualityScoreCount = 0; + + for (const item of content) { + if (item.platform) { + platformBreakdown[item.platform] = (platformBreakdown[item.platform] || 0) + 1; + } + typeBreakdown[item.contentType] = (typeBreakdown[item.contentType] || 0) + 1; + totalEngagement += item.totalEngagement; + + if (item.qualityScore) { + totalQualityScore += item.qualityScore; + qualityScoreCount++; + } + } + + return { + overview: { + totalContent: content.length, + approvedContent: content.filter(c => c.status === ContentStatus.APPROVED).length, + featuredContent: content.filter(c => c.status === ContentStatus.FEATURED).length, + pendingContent: content.filter(c => c.status === ContentStatus.PENDING).length, + totalEngagement, + averageQualityScore: qualityScoreCount > 0 ? totalQualityScore / qualityScoreCount : 0, + }, + breakdown: { + byPlatform: platformBreakdown, + byType: typeBreakdown, + }, + topPerforming: content + .filter(c => c.totalEngagement > 0) + .sort((a, b) => b.totalEngagement - a.totalEngagement) + .slice(0, 10) + .map(c => ({ + id: c.id, + title: c.title, + platform: c.platform, + contentType: c.contentType, + engagement: c.totalEngagement, + qualityScore: c.qualityScore, + authorName: c.authorName, + })), + }; + } + + async getCompetitorAnalysis( + organizerId: string, + competitors: string[], + ): Promise { + // This would integrate with social media monitoring tools + // For now, returning mock comparative data + return { + organizer: { + id: organizerId, + totalFollowers: 45000, + averageEngagement: 3.2, + postFrequency: 12, // posts per week + }, + competitors: competitors.map(comp => ({ + name: comp, + totalFollowers: Math.floor(Math.random() * 100000) + 20000, + averageEngagement: Math.random() * 5 + 1, + postFrequency: Math.floor(Math.random() * 20) + 5, + })), + insights: [ + 'Your engagement rate is above industry average', + 'Consider increasing post frequency to match top competitors', + 'Video content performs 40% better than image posts', + ], + }; + } + + private calculateOverviewMetrics(posts: SocialPost[]): any { + const publishedPosts = posts.filter(p => p.status === PostStatus.PUBLISHED); + + const totalEngagement = publishedPosts.reduce( + (sum, p) => sum + (p.engagement?.engagement || 0), 0 + ); + const totalReach = publishedPosts.reduce( + (sum, p) => sum + (p.engagement?.reach || 0), 0 + ); + const totalClicks = publishedPosts.reduce( + (sum, p) => sum + (p.engagement?.clicks || 0), 0 + ); + + const platformCounts = {}; + for (const post of publishedPosts) { + const platform = post.account?.platform || 'unknown'; + platformCounts[platform] = (platformCounts[platform] || 0) + 1; + } + + const topPlatform = Object.entries(platformCounts) + .sort(([,a], [,b]) => (b as number) - (a as number))[0]?.[0] || 'none'; + + return { + totalPosts: publishedPosts.length, + totalEngagement, + totalReach, + totalClicks, + engagementRate: totalReach > 0 ? (totalEngagement / totalReach) * 100 : 0, + topPlatform, + }; + } + + private calculateCampaignMetrics(campaigns: SocialCampaign[]): any { + const activeCampaigns = campaigns.filter(c => c.status === CampaignStatus.ACTIVE).length; + const completedCampaigns = campaigns.filter(c => c.status === CampaignStatus.COMPLETED); + + const averageROI = completedCampaigns.length > 0 + ? completedCampaigns.reduce((sum, c) => sum + (c.roi || 0), 0) / completedCampaigns.length + : 0; + + const topPerformingCampaign = campaigns + .filter(c => c.analytics?.returnOnAdSpend) + .sort((a, b) => (b.analytics?.returnOnAdSpend || 0) - (a.analytics?.returnOnAdSpend || 0))[0]; + + return { + activeCampaigns, + totalCampaigns: campaigns.length, + averageROI, + topPerformingCampaign: topPerformingCampaign ? { + id: topPerformingCampaign.id, + name: topPerformingCampaign.name, + roi: topPerformingCampaign.roi, + } : null, + }; + } + + private calculateReferralMetrics( + programs: ReferralProgram[], + tracking: ReferralTracking[], + ): any { + const activePrograms = programs.filter(p => p.status === 'active').length; + const conversions = tracking.filter(t => t.status === TrackingStatus.CONVERTED); + const clicks = tracking.filter(t => t.status === TrackingStatus.CLICKED); + + const totalRevenue = conversions.reduce((sum, c) => sum + (c.conversionValue || 0), 0); + const conversionRate = clicks.length > 0 ? (conversions.length / clicks.length) * 100 : 0; + + return { + activePrograms, + totalConversions: conversions.length, + totalRevenue, + conversionRate, + }; + } + + private calculateInfluencerMetrics(collaborations: InfluencerCollaboration[]): any { + const activeCollaborations = collaborations.filter( + c => c.status === CollaborationStatus.ACTIVE + ).length; + + const completedCollabs = collaborations.filter( + c => c.status === CollaborationStatus.COMPLETED + ); + + const totalReach = completedCollabs.reduce( + (sum, c) => sum + (c.performance?.reach || 0), 0 + ); + + const totalEngagement = completedCollabs.reduce( + (sum, c) => sum + (c.performance?.engagement || 0), 0 + ); + + const averageEngagement = completedCollabs.length > 0 + ? totalEngagement / completedCollabs.length + : 0; + + const topInfluencer = completedCollabs + .sort((a, b) => (b.performance?.engagement || 0) - (a.performance?.engagement || 0))[0]; + + return { + activeCollaborations, + totalReach, + averageEngagement, + topInfluencer: topInfluencer ? { + name: topInfluencer.influencerProfile.name, + engagement: topInfluencer.performance?.engagement || 0, + tier: topInfluencer.tier, + } : null, + }; + } + + private calculateSocialProofMetrics(proofs: SocialProof[]): any { + const approvedProofs = proofs.filter(p => p.status === ProofStatus.APPROVED).length; + const totalDisplays = proofs.reduce((sum, p) => sum + p.displayCount, 0); + const totalClicks = proofs.reduce((sum, p) => sum + p.clickCount, 0); + + const averageCTR = totalDisplays > 0 ? (totalClicks / totalDisplays) * 100 : 0; + + const typeCounts = {}; + for (const proof of proofs) { + typeCounts[proof.proofType] = (typeCounts[proof.proofType] || 0) + 1; + } + + const topProofType = Object.entries(typeCounts) + .sort(([,a], [,b]) => (b as number) - (a as number))[0]?.[0] || 'none'; + + return { + totalProofs: proofs.length, + approvedProofs, + averageCTR, + topProofType, + }; + } + + private calculateUGCMetrics(content: UserGeneratedContent[]): any { + const approvedContent = content.filter(c => c.status === ContentStatus.APPROVED).length; + + const qualityScores = content.filter(c => c.qualityScore).map(c => c.qualityScore); + const averageQualityScore = qualityScores.length > 0 + ? qualityScores.reduce((sum, score) => sum + score, 0) / qualityScores.length + : 0; + + const platformCounts = {}; + for (const item of content) { + if (item.platform) { + platformCounts[item.platform] = (platformCounts[item.platform] || 0) + 1; + } + } + + const topPlatform = Object.entries(platformCounts) + .sort(([,a], [,b]) => (b as number) - (a as number))[0]?.[0] || 'none'; + + return { + totalContent: content.length, + approvedContent, + averageQualityScore, + topPlatform, + }; + } + + private calculateEngagementRate(engagement: any): number { + if (!engagement || !engagement.reach) return 0; + const totalEngagement = (engagement.likes || 0) + + (engagement.comments || 0) + + (engagement.shares || 0); + return (totalEngagement / engagement.reach) * 100; + } + + private calculateCTR(engagement: any): number { + if (!engagement || !engagement.impressions || !engagement.clicks) return 0; + return (engagement.clicks / engagement.impressions) * 100; + } + + private calculateViralityScore(engagement: any): number { + if (!engagement) return 0; + const shares = engagement.shares || 0; + const reach = engagement.reach || 0; + return reach > 0 ? (shares / reach) * 100 : 0; + } + + private calculateAverageEngagementRate(posts: SocialPost[]): number { + const publishedPosts = posts.filter(p => p.status === PostStatus.PUBLISHED); + if (publishedPosts.length === 0) return 0; + + const totalEngagementRate = publishedPosts.reduce( + (sum, p) => sum + this.calculateEngagementRate(p.engagement), 0 + ); + + return totalEngagementRate / publishedPosts.length; + } + + private calculateCampaignROI(campaign: SocialCampaign, posts: SocialPost[]): number { + const spent = campaign.spentAmount || 0; + if (spent === 0) return 0; + + // This would integrate with actual conversion tracking + // For now, using mock calculation based on engagement + const totalEngagement = posts.reduce( + (sum, p) => sum + (p.engagement?.engagement || 0), 0 + ); + + const estimatedRevenue = totalEngagement * 0.05; // Mock conversion rate + return ((estimatedRevenue - spent) / spent) * 100; + } + + private calculateCostPerEngagement(campaign: SocialCampaign, posts: SocialPost[]): number { + const spent = campaign.spentAmount || 0; + const totalEngagement = posts.reduce( + (sum, p) => sum + (p.engagement?.engagement || 0), 0 + ); + + return totalEngagement > 0 ? spent / totalEngagement : 0; + } + + private async getPostComparison(post: SocialPost): Promise { + // Compare with other posts from the same account + const similarPosts = await this.postRepository.find({ + where: { + accountId: post.accountId, + status: PostStatus.PUBLISHED, + }, + order: { publishedAt: 'DESC' }, + take: 10, + }); + + const avgEngagement = similarPosts.reduce( + (sum, p) => sum + (p.engagement?.engagement || 0), 0 + ) / similarPosts.length; + + const currentEngagement = post.engagement?.engagement || 0; + const performanceVsAverage = avgEngagement > 0 + ? ((currentEngagement - avgEngagement) / avgEngagement) * 100 + : 0; + + return { + averageEngagement: avgEngagement, + performanceVsAverage, + ranking: similarPosts.findIndex(p => p.id === post.id) + 1, + }; + } + + private async getPostTimeSeriesData(postId: string): Promise { + // This would integrate with platform APIs to get historical engagement data + // For now, returning mock time series data + const mockData = []; + const now = new Date(); + + for (let i = 0; i < 24; i++) { + const timestamp = new Date(now.getTime() - (23 - i) * 60 * 60 * 1000); + mockData.push({ + timestamp, + likes: Math.floor(Math.random() * 100), + comments: Math.floor(Math.random() * 20), + shares: Math.floor(Math.random() * 10), + reach: Math.floor(Math.random() * 1000) + 500, + }); + } + + return mockData; + } + + private analyzeReferralSources(tracking: ReferralTracking[]): any { + const sources = {}; + const mediums = {}; + const campaigns = {}; + + for (const record of tracking) { + const source = record.sourceInfo?.utmSource || 'direct'; + const medium = record.sourceInfo?.utmMedium || 'none'; + const campaign = record.sourceInfo?.utmCampaign || 'none'; + + sources[source] = (sources[source] || 0) + 1; + mediums[medium] = (mediums[medium] || 0) + 1; + campaigns[campaign] = (campaigns[campaign] || 0) + 1; + } + + return { sources, mediums, campaigns }; + } + + private generateReferralTimeSeries( + tracking: ReferralTracking[], + dateRange?: AnalyticsDateRange, + ): any[] { + // Group tracking records by day + const dailyData = {}; + + for (const record of tracking) { + const date = record.createdAt.toISOString().split('T')[0]; + if (!dailyData[date]) { + dailyData[date] = { clicks: 0, conversions: 0 }; + } + + if (record.status === TrackingStatus.CLICKED) { + dailyData[date].clicks++; + } else if (record.status === TrackingStatus.CONVERTED) { + dailyData[date].conversions++; + } + } + + return Object.entries(dailyData).map(([date, data]) => ({ + date, + ...data, + })); + } +} diff --git a/src/social-media/services/social-media-api.service.ts b/src/social-media/services/social-media-api.service.ts new file mode 100644 index 00000000..4d1442d1 --- /dev/null +++ b/src/social-media/services/social-media-api.service.ts @@ -0,0 +1,554 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { HttpService } from '@nestjs/axios'; +import { firstValueFrom } from 'rxjs'; +import { SocialPlatform } from '../entities/social-account.entity'; +import { PostType } from '../entities/social-post.entity'; + +export interface PlatformCredentials { + accessToken: string; + refreshToken?: string; + appId?: string; + appSecret?: string; + pageId?: string; + businessAccountId?: string; +} + +export interface PostData { + content: string; + media?: Array<{ + type: 'image' | 'video'; + url: string; + altText?: string; + }>; + link?: string; + scheduledTime?: Date; + targeting?: Record; +} + +export interface PostResult { + success: boolean; + platformPostId?: string; + platformUrl?: string; + error?: string; + scheduledFor?: Date; +} + +@Injectable() +export class SocialMediaApiService { + private readonly logger = new Logger(SocialMediaApiService.name); + + constructor( + private readonly configService: ConfigService, + private readonly httpService: HttpService, + ) {} + + async publishPost( + platform: SocialPlatform, + credentials: PlatformCredentials, + postData: PostData, + ): Promise { + try { + switch (platform) { + case SocialPlatform.FACEBOOK: + return await this.publishToFacebook(credentials, postData); + case SocialPlatform.INSTAGRAM: + return await this.publishToInstagram(credentials, postData); + case SocialPlatform.TWITTER: + return await this.publishToTwitter(credentials, postData); + case SocialPlatform.LINKEDIN: + return await this.publishToLinkedIn(credentials, postData); + default: + throw new Error(`Unsupported platform: ${platform}`); + } + } catch (error) { + this.logger.error(`Failed to publish to ${platform}:`, error); + return { + success: false, + error: error.message, + }; + } + } + + private async publishToFacebook( + credentials: PlatformCredentials, + postData: PostData, + ): Promise { + const pageId = credentials.pageId; + const accessToken = credentials.accessToken; + + if (!pageId) { + throw new Error('Facebook page ID is required'); + } + + const url = `https://graph.facebook.com/v18.0/${pageId}/feed`; + + const payload: any = { + message: postData.content, + access_token: accessToken, + }; + + // Handle media + if (postData.media && postData.media.length > 0) { + if (postData.media.length === 1) { + const media = postData.media[0]; + if (media.type === 'image') { + payload.link = media.url; + } else if (media.type === 'video') { + // For video, use different endpoint + const videoUrl = `https://graph.facebook.com/v18.0/${pageId}/videos`; + const videoPayload = { + description: postData.content, + file_url: media.url, + access_token: accessToken, + }; + + if (postData.scheduledTime) { + videoPayload['scheduled_publish_time'] = Math.floor(postData.scheduledTime.getTime() / 1000); + videoPayload['published'] = false; + } + + const response = await firstValueFrom( + this.httpService.post(videoUrl, videoPayload) + ); + + return { + success: true, + platformPostId: response.data.id, + platformUrl: `https://facebook.com/${response.data.id}`, + scheduledFor: postData.scheduledTime, + }; + } + } else { + // Multiple media - create album + payload.attached_media = postData.media.map((media, index) => ({ + media_fbid: `temp_${index}`, // This would need proper media upload first + })); + } + } + + // Handle link + if (postData.link) { + payload.link = postData.link; + } + + // Handle scheduling + if (postData.scheduledTime) { + payload.scheduled_publish_time = Math.floor(postData.scheduledTime.getTime() / 1000); + payload.published = false; + } + + const response = await firstValueFrom( + this.httpService.post(url, payload) + ); + + return { + success: true, + platformPostId: response.data.id, + platformUrl: `https://facebook.com/${response.data.id}`, + scheduledFor: postData.scheduledTime, + }; + } + + private async publishToInstagram( + credentials: PlatformCredentials, + postData: PostData, + ): Promise { + const businessAccountId = credentials.businessAccountId; + const accessToken = credentials.accessToken; + + if (!businessAccountId) { + throw new Error('Instagram business account ID is required'); + } + + // Instagram requires media for posts + if (!postData.media || postData.media.length === 0) { + throw new Error('Instagram posts require media'); + } + + const media = postData.media[0]; + const mediaType = media.type === 'video' ? 'VIDEO' : 'IMAGE'; + + // Step 1: Create media container + const containerUrl = `https://graph.facebook.com/v18.0/${businessAccountId}/media`; + const containerPayload: any = { + image_url: media.url, + caption: postData.content, + media_type: mediaType, + access_token: accessToken, + }; + + if (media.type === 'video') { + containerPayload.video_url = media.url; + delete containerPayload.image_url; + } + + const containerResponse = await firstValueFrom( + this.httpService.post(containerUrl, containerPayload) + ); + + const containerId = containerResponse.data.id; + + // Step 2: Publish the media container + const publishUrl = `https://graph.facebook.com/v18.0/${businessAccountId}/media_publish`; + const publishPayload = { + creation_id: containerId, + access_token: accessToken, + }; + + const publishResponse = await firstValueFrom( + this.httpService.post(publishUrl, publishPayload) + ); + + return { + success: true, + platformPostId: publishResponse.data.id, + platformUrl: `https://instagram.com/p/${publishResponse.data.id}`, + }; + } + + private async publishToTwitter( + credentials: PlatformCredentials, + postData: PostData, + ): Promise { + // Twitter API v2 implementation + const url = 'https://api.twitter.com/2/tweets'; + + const payload: any = { + text: postData.content, + }; + + // Handle media + if (postData.media && postData.media.length > 0) { + // Media would need to be uploaded first using media upload endpoint + const mediaIds = await this.uploadTwitterMedia(credentials, postData.media); + payload.media = { media_ids: mediaIds }; + } + + const headers = { + 'Authorization': `Bearer ${credentials.accessToken}`, + 'Content-Type': 'application/json', + }; + + const response = await firstValueFrom( + this.httpService.post(url, payload, { headers }) + ); + + return { + success: true, + platformPostId: response.data.data.id, + platformUrl: `https://twitter.com/i/web/status/${response.data.data.id}`, + }; + } + + private async publishToLinkedIn( + credentials: PlatformCredentials, + postData: PostData, + ): Promise { + const url = 'https://api.linkedin.com/v2/ugcPosts'; + + const payload: any = { + author: `urn:li:person:${credentials.appId}`, // This would be the LinkedIn person/organization ID + lifecycleState: 'PUBLISHED', + specificContent: { + 'com.linkedin.ugc.ShareContent': { + shareCommentary: { + text: postData.content, + }, + shareMediaCategory: 'NONE', + }, + }, + visibility: { + 'com.linkedin.ugc.MemberNetworkVisibility': 'PUBLIC', + }, + }; + + // Handle media + if (postData.media && postData.media.length > 0) { + payload.specificContent['com.linkedin.ugc.ShareContent'].shareMediaCategory = 'IMAGE'; + payload.specificContent['com.linkedin.ugc.ShareContent'].media = postData.media.map(media => ({ + status: 'READY', + description: { + text: media.altText || '', + }, + media: media.url, // This would need proper LinkedIn media upload + })); + } + + const headers = { + 'Authorization': `Bearer ${credentials.accessToken}`, + 'Content-Type': 'application/json', + 'X-Restli-Protocol-Version': '2.0.0', + }; + + const response = await firstValueFrom( + this.httpService.post(url, payload, { headers }) + ); + + return { + success: true, + platformPostId: response.headers['x-restli-id'], + platformUrl: `https://linkedin.com/feed/update/${response.headers['x-restli-id']}`, + }; + } + + private async uploadTwitterMedia( + credentials: PlatformCredentials, + media: Array<{ type: 'image' | 'video'; url: string; altText?: string }>, + ): Promise { + const mediaIds: string[] = []; + + for (const item of media) { + const uploadUrl = 'https://upload.twitter.com/1.1/media/upload.json'; + + // This is simplified - actual implementation would need to handle chunked upload for large files + const payload = { + media_data: item.url, // This would be base64 encoded media data + media_category: item.type === 'video' ? 'tweet_video' : 'tweet_image', + }; + + if (item.altText) { + payload['alt_text'] = { text: item.altText }; + } + + const headers = { + 'Authorization': `Bearer ${credentials.accessToken}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }; + + const response = await firstValueFrom( + this.httpService.post(uploadUrl, payload, { headers }) + ); + + mediaIds.push(response.data.media_id_string); + } + + return mediaIds; + } + + async getPostEngagement( + platform: SocialPlatform, + credentials: PlatformCredentials, + platformPostId: string, + ): Promise { + try { + switch (platform) { + case SocialPlatform.FACEBOOK: + return await this.getFacebookEngagement(credentials, platformPostId); + case SocialPlatform.INSTAGRAM: + return await this.getInstagramEngagement(credentials, platformPostId); + case SocialPlatform.TWITTER: + return await this.getTwitterEngagement(credentials, platformPostId); + case SocialPlatform.LINKEDIN: + return await this.getLinkedInEngagement(credentials, platformPostId); + default: + throw new Error(`Unsupported platform: ${platform}`); + } + } catch (error) { + this.logger.error(`Failed to get engagement for ${platform}:`, error); + return null; + } + } + + private async getFacebookEngagement( + credentials: PlatformCredentials, + postId: string, + ): Promise { + const url = `https://graph.facebook.com/v18.0/${postId}`; + const params = { + fields: 'likes.summary(true),comments.summary(true),shares,reactions.summary(true)', + access_token: credentials.accessToken, + }; + + const response = await firstValueFrom( + this.httpService.get(url, { params }) + ); + + return { + likes: response.data.likes?.summary?.total_count || 0, + comments: response.data.comments?.summary?.total_count || 0, + shares: response.data.shares?.count || 0, + reactions: response.data.reactions?.summary?.total_count || 0, + }; + } + + private async getInstagramEngagement( + credentials: PlatformCredentials, + postId: string, + ): Promise { + const url = `https://graph.facebook.com/v18.0/${postId}`; + const params = { + fields: 'like_count,comments_count,media_type,media_url,permalink', + access_token: credentials.accessToken, + }; + + const response = await firstValueFrom( + this.httpService.get(url, { params }) + ); + + return { + likes: response.data.like_count || 0, + comments: response.data.comments_count || 0, + shares: 0, // Instagram doesn't provide share count via API + saves: 0, // Would need Instagram Insights API for saves + }; + } + + private async getTwitterEngagement( + credentials: PlatformCredentials, + tweetId: string, + ): Promise { + const url = `https://api.twitter.com/2/tweets/${tweetId}`; + const params = { + 'tweet.fields': 'public_metrics', + }; + + const headers = { + 'Authorization': `Bearer ${credentials.accessToken}`, + }; + + const response = await firstValueFrom( + this.httpService.get(url, { params, headers }) + ); + + const metrics = response.data.data.public_metrics; + return { + likes: metrics.like_count || 0, + comments: metrics.reply_count || 0, + shares: metrics.retweet_count || 0, + impressions: metrics.impression_count || 0, + }; + } + + private async getLinkedInEngagement( + credentials: PlatformCredentials, + postId: string, + ): Promise { + const url = `https://api.linkedin.com/v2/socialActions/${postId}`; + + const headers = { + 'Authorization': `Bearer ${credentials.accessToken}`, + 'X-Restli-Protocol-Version': '2.0.0', + }; + + const response = await firstValueFrom( + this.httpService.get(url, { headers }) + ); + + return { + likes: response.data.likesSummary?.totalLikes || 0, + comments: response.data.commentsSummary?.totalComments || 0, + shares: response.data.sharesSummary?.totalShares || 0, + }; + } + + async refreshAccessToken( + platform: SocialPlatform, + refreshToken: string, + appId: string, + appSecret: string, + ): Promise<{ accessToken: string; refreshToken?: string; expiresAt?: Date }> { + try { + switch (platform) { + case SocialPlatform.FACEBOOK: + case SocialPlatform.INSTAGRAM: + return await this.refreshFacebookToken(refreshToken, appId, appSecret); + case SocialPlatform.TWITTER: + return await this.refreshTwitterToken(refreshToken, appId, appSecret); + case SocialPlatform.LINKEDIN: + return await this.refreshLinkedInToken(refreshToken, appId, appSecret); + default: + throw new Error(`Token refresh not supported for platform: ${platform}`); + } + } catch (error) { + this.logger.error(`Failed to refresh token for ${platform}:`, error); + throw error; + } + } + + private async refreshFacebookToken( + refreshToken: string, + appId: string, + appSecret: string, + ): Promise<{ accessToken: string; expiresAt?: Date }> { + const url = 'https://graph.facebook.com/v18.0/oauth/access_token'; + const params = { + grant_type: 'fb_exchange_token', + client_id: appId, + client_secret: appSecret, + fb_exchange_token: refreshToken, + }; + + const response = await firstValueFrom( + this.httpService.get(url, { params }) + ); + + const expiresAt = response.data.expires_in + ? new Date(Date.now() + response.data.expires_in * 1000) + : undefined; + + return { + accessToken: response.data.access_token, + expiresAt, + }; + } + + private async refreshTwitterToken( + refreshToken: string, + clientId: string, + clientSecret: string, + ): Promise<{ accessToken: string; refreshToken: string; expiresAt: Date }> { + const url = 'https://api.twitter.com/2/oauth2/token'; + + const payload = new URLSearchParams({ + refresh_token: refreshToken, + grant_type: 'refresh_token', + client_id: clientId, + }); + + const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); + const headers = { + 'Authorization': `Basic ${credentials}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }; + + const response = await firstValueFrom( + this.httpService.post(url, payload, { headers }) + ); + + return { + accessToken: response.data.access_token, + refreshToken: response.data.refresh_token, + expiresAt: new Date(Date.now() + response.data.expires_in * 1000), + }; + } + + private async refreshLinkedInToken( + refreshToken: string, + clientId: string, + clientSecret: string, + ): Promise<{ accessToken: string; refreshToken: string; expiresAt: Date }> { + const url = 'https://www.linkedin.com/oauth/v2/accessToken'; + + const payload = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: clientId, + client_secret: clientSecret, + }); + + const headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + }; + + const response = await firstValueFrom( + this.httpService.post(url, payload, { headers }) + ); + + return { + accessToken: response.data.access_token, + refreshToken: response.data.refresh_token || refreshToken, + expiresAt: new Date(Date.now() + response.data.expires_in * 1000), + }; + } +} diff --git a/src/social-media/services/social-post.service.ts b/src/social-media/services/social-post.service.ts new file mode 100644 index 00000000..c3c829c8 --- /dev/null +++ b/src/social-media/services/social-post.service.ts @@ -0,0 +1,491 @@ +import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import { SocialPost, PostStatus, PostType } from '../entities/social-post.entity'; +import { SocialAccount } from '../entities/social-account.entity'; +import { SocialMediaApiService, PostData, PostResult } from './social-media-api.service'; +import { ConfigService } from '@nestjs/config'; +import { Cron, CronExpression } from '@nestjs/schedule'; + +export interface CreateSocialPostDto { + accountId: string; + campaignId?: string; + eventId?: string; + postType: PostType; + content: string; + mediaUrls?: Array<{ + type: 'image' | 'video'; + url: string; + thumbnailUrl?: string; + altText?: string; + }>; + scheduledFor?: Date; + targeting?: any; + hashtags?: string[]; + mentions?: string[]; + crossPost?: { + enabled: boolean; + platforms: string[]; + }; +} + +export interface UpdateSocialPostDto { + content?: string; + mediaUrls?: Array<{ + type: 'image' | 'video'; + url: string; + thumbnailUrl?: string; + altText?: string; + }>; + scheduledFor?: Date; + hashtags?: string[]; + mentions?: string[]; +} + +@Injectable() +export class SocialPostService { + private readonly logger = new Logger(SocialPostService.name); + + constructor( + @InjectRepository(SocialPost) + private readonly postRepository: Repository, + @InjectRepository(SocialAccount) + private readonly accountRepository: Repository, + private readonly socialMediaApiService: SocialMediaApiService, + private readonly configService: ConfigService, + ) {} + + async createPost(dto: CreateSocialPostDto): Promise { + const account = await this.accountRepository.findOne({ + where: { id: dto.accountId }, + }); + + if (!account) { + throw new NotFoundException(`Social account with ID ${dto.accountId} not found`); + } + + const post = this.postRepository.create({ + ...dto, + status: dto.scheduledFor ? PostStatus.SCHEDULED : PostStatus.DRAFT, + engagement: { + likes: 0, + comments: 0, + shares: 0, + clicks: 0, + impressions: 0, + reach: 0, + saves: 0, + lastUpdated: new Date(), + }, + aiGenerated: { + isGenerated: false, + confidence: 0, + suggestions: [], + }, + }); + + const savedPost = await this.postRepository.save(post); + this.logger.log(`Created social post: ${savedPost.id}`); + return savedPost; + } + + async findPostById(id: string): Promise { + const post = await this.postRepository.findOne({ + where: { id }, + relations: ['account', 'campaign'], + }); + + if (!post) { + throw new NotFoundException(`Social post with ID ${id} not found`); + } + + return post; + } + + async findPostsByAccount(accountId: string): Promise { + return this.postRepository.find({ + where: { accountId }, + order: { createdAt: 'DESC' }, + relations: ['campaign'], + }); + } + + async findPostsByCampaign(campaignId: string): Promise { + return this.postRepository.find({ + where: { campaignId }, + order: { scheduledFor: 'ASC' }, + relations: ['account'], + }); + } + + async findPostsByEvent(eventId: string): Promise { + return this.postRepository.find({ + where: { eventId }, + order: { createdAt: 'DESC' }, + relations: ['account', 'campaign'], + }); + } + + async updatePost(id: string, dto: UpdateSocialPostDto): Promise { + const post = await this.findPostById(id); + + if (post.status === PostStatus.PUBLISHED) { + throw new BadRequestException('Cannot update published posts'); + } + + Object.assign(post, dto); + + if (dto.scheduledFor && post.status === PostStatus.DRAFT) { + post.status = PostStatus.SCHEDULED; + } + + return this.postRepository.save(post); + } + + async publishPost(id: string): Promise { + const post = await this.findPostById(id); + + if (post.status === PostStatus.PUBLISHED) { + throw new BadRequestException('Post is already published'); + } + + const account = await this.accountRepository.findOne({ + where: { id: post.accountId }, + }); + + if (!account) { + throw new NotFoundException('Associated social account not found'); + } + + try { + const postData: PostData = { + content: post.content, + media: post.mediaUrls, + link: post.linkUrl, + targeting: post.targeting, + }; + + const credentials = { + accessToken: account.accessToken, + refreshToken: account.refreshToken, + appId: account.appId, + appSecret: account.appSecret, + pageId: account.pageId, + businessAccountId: account.businessAccountId, + }; + + const result: PostResult = await this.socialMediaApiService.publishPost( + account.platform, + credentials, + postData, + ); + + if (result.success) { + post.status = PostStatus.PUBLISHED; + post.platformPostId = result.platformPostId; + post.platformUrl = result.platformUrl; + post.publishedAt = new Date(); + + // Handle cross-posting + if (post.crossPost?.enabled && post.crossPost.platforms?.length > 0) { + await this.handleCrossPosting(post, postData); + } + } else { + post.status = PostStatus.FAILED; + post.errorMessage = result.error; + } + + const savedPost = await this.postRepository.save(post); + this.logger.log(`Published social post: ${savedPost.id} to ${account.platform}`); + return savedPost; + } catch (error) { + post.status = PostStatus.FAILED; + post.errorMessage = error.message; + await this.postRepository.save(post); + + this.logger.error(`Failed to publish post ${id}:`, error); + throw error; + } + } + + async schedulePost(id: string, scheduledFor: Date): Promise { + const post = await this.findPostById(id); + + if (post.status === PostStatus.PUBLISHED) { + throw new BadRequestException('Cannot reschedule published posts'); + } + + if (scheduledFor <= new Date()) { + throw new BadRequestException('Scheduled time must be in the future'); + } + + post.scheduledFor = scheduledFor; + post.status = PostStatus.SCHEDULED; + + return this.postRepository.save(post); + } + + async deletePost(id: string): Promise { + const post = await this.findPostById(id); + + if (post.status === PostStatus.PUBLISHED && post.platformPostId) { + // Note: Most platforms don't allow deletion via API + this.logger.warn(`Cannot delete published post ${id} from platform`); + } + + await this.postRepository.remove(post); + this.logger.log(`Deleted social post: ${id}`); + } + + async duplicatePost(id: string): Promise { + const originalPost = await this.findPostById(id); + + const duplicatedPost = this.postRepository.create({ + accountId: originalPost.accountId, + campaignId: originalPost.campaignId, + eventId: originalPost.eventId, + postType: originalPost.postType, + content: `${originalPost.content} (Copy)`, + mediaUrls: originalPost.mediaUrls, + hashtags: originalPost.hashtags, + mentions: originalPost.mentions, + targeting: originalPost.targeting, + crossPost: originalPost.crossPost, + status: PostStatus.DRAFT, + engagement: { + likes: 0, + comments: 0, + shares: 0, + clicks: 0, + impressions: 0, + reach: 0, + saves: 0, + lastUpdated: new Date(), + }, + }); + + const savedPost = await this.postRepository.save(duplicatedPost); + this.logger.log(`Duplicated social post: ${originalPost.id} -> ${savedPost.id}`); + return savedPost; + } + + async updateEngagementMetrics(id: string): Promise { + const post = await this.findPostById(id); + + if (post.status !== PostStatus.PUBLISHED || !post.platformPostId) { + return post; + } + + const account = await this.accountRepository.findOne({ + where: { id: post.accountId }, + }); + + if (!account) { + return post; + } + + try { + const credentials = { + accessToken: account.accessToken, + refreshToken: account.refreshToken, + appId: account.appId, + appSecret: account.appSecret, + }; + + const engagement = await this.socialMediaApiService.getPostEngagement( + account.platform, + credentials, + post.platformPostId, + ); + + if (engagement) { + post.engagement = { + ...post.engagement, + ...engagement, + lastUpdated: new Date(), + }; + + await this.postRepository.save(post); + this.logger.log(`Updated engagement metrics for post: ${id}`); + } + } catch (error) { + this.logger.error(`Failed to update engagement for post ${id}:`, error); + } + + return post; + } + + async generateAIContent( + prompt: string, + postType: PostType, + platform: string, + eventContext?: any, + ): Promise<{ + content: string; + hashtags: string[]; + confidence: number; + suggestions: string[]; + }> { + // This would integrate with an AI service like OpenAI + // For now, returning mock data + const mockContent = { + content: `🎉 Don't miss out on this amazing event! ${prompt}`, + hashtags: ['#event', '#dontmiss', '#amazing'], + confidence: 0.85, + suggestions: [ + 'Add more emojis for better engagement', + 'Consider mentioning the date and location', + 'Include a call-to-action', + ], + }; + + this.logger.log(`Generated AI content for ${platform} ${postType}`); + return mockContent; + } + + async optimizePostTiming(accountId: string, postType: PostType): Promise { + // This would analyze historical engagement data to suggest optimal posting times + const account = await this.accountRepository.findOne({ + where: { id: accountId }, + }); + + if (!account) { + throw new NotFoundException('Social account not found'); + } + + // Mock optimal times based on platform + const optimalTimes: Date[] = []; + const now = new Date(); + + // Suggest times for the next 7 days + for (let i = 1; i <= 7; i++) { + const date = new Date(now); + date.setDate(date.getDate() + i); + + // Different optimal times based on platform + switch (account.platform) { + case 'facebook': + date.setHours(9, 0, 0, 0); // 9 AM + break; + case 'instagram': + date.setHours(11, 0, 0, 0); // 11 AM + break; + case 'twitter': + date.setHours(12, 0, 0, 0); // 12 PM + break; + case 'linkedin': + date.setHours(8, 0, 0, 0); // 8 AM + break; + default: + date.setHours(10, 0, 0, 0); // 10 AM + } + + optimalTimes.push(date); + } + + return optimalTimes; + } + + @Cron(CronExpression.EVERY_MINUTE) + async processScheduledPosts(): Promise { + const now = new Date(); + const scheduledPosts = await this.postRepository.find({ + where: { + status: PostStatus.SCHEDULED, + }, + relations: ['account'], + }); + + const postsToPublish = scheduledPosts.filter( + post => post.scheduledFor && post.scheduledFor <= now + ); + + for (const post of postsToPublish) { + try { + await this.publishPost(post.id); + this.logger.log(`Auto-published scheduled post: ${post.id}`); + } catch (error) { + this.logger.error(`Failed to auto-publish post ${post.id}:`, error); + } + } + } + + @Cron(CronExpression.EVERY_HOUR) + async updateAllEngagementMetrics(): Promise { + const publishedPosts = await this.postRepository.find({ + where: { + status: PostStatus.PUBLISHED, + }, + take: 100, // Process in batches + order: { publishedAt: 'DESC' }, + }); + + for (const post of publishedPosts) { + try { + await this.updateEngagementMetrics(post.id); + } catch (error) { + this.logger.error(`Failed to update engagement for post ${post.id}:`, error); + } + } + + this.logger.log(`Updated engagement metrics for ${publishedPosts.length} posts`); + } + + private async handleCrossPosting(post: SocialPost, postData: PostData): Promise { + if (!post.crossPost?.enabled || !post.crossPost.platforms?.length) { + return; + } + + // Get accounts for cross-posting platforms + const crossPostAccounts = await this.accountRepository.find({ + where: { + platform: In(post.crossPost.platforms), + organizerId: post.account?.organizerId, // Assuming we have this relation + isActive: true, + }, + }); + + for (const account of crossPostAccounts) { + try { + const credentials = { + accessToken: account.accessToken, + refreshToken: account.refreshToken, + appId: account.appId, + appSecret: account.appSecret, + pageId: account.pageId, + businessAccountId: account.businessAccountId, + }; + + const result = await this.socialMediaApiService.publishPost( + account.platform, + credentials, + postData, + ); + + if (result.success) { + // Create a new post record for the cross-posted content + const crossPost = this.postRepository.create({ + accountId: account.id, + campaignId: post.campaignId, + eventId: post.eventId, + postType: post.postType, + content: post.content, + mediaUrls: post.mediaUrls, + hashtags: post.hashtags, + mentions: post.mentions, + status: PostStatus.PUBLISHED, + platformPostId: result.platformPostId, + platformUrl: result.platformUrl, + publishedAt: new Date(), + parentPostId: post.id, + }); + + await this.postRepository.save(crossPost); + this.logger.log(`Cross-posted to ${account.platform}: ${crossPost.id}`); + } + } catch (error) { + this.logger.error(`Failed to cross-post to ${account.platform}:`, error); + } + } + } +} diff --git a/src/social-media/services/social-proof.service.ts b/src/social-media/services/social-proof.service.ts new file mode 100644 index 00000000..19be64fc --- /dev/null +++ b/src/social-media/services/social-proof.service.ts @@ -0,0 +1,474 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In, Between } from 'typeorm'; +import { SocialProof, ProofType, ProofStatus } from '../entities/social-proof.entity'; +import { UserGeneratedContent, ContentStatus } from '../entities/user-generated-content.entity'; + +export interface CreateSocialProofDto { + eventId?: string; + userId?: string; + organizerId?: string; + proofType: ProofType; + content: string; + authorName?: string; + authorUsername?: string; + authorAvatarUrl?: string; + platform?: string; + sourceUrl?: string; + mediaUrls?: string[]; + rating?: number; + friendsData?: Array<{ + userId: string; + name: string; + avatarUrl?: string; + mutualFriends?: number; + attendanceStatus?: string; + }>; + metadata?: any; +} + +export interface SocialProofWidget { + type: 'friend_attendance' | 'recent_activity' | 'testimonials' | 'user_count'; + data: any; + displaySettings: { + maxItems: number; + showAvatars: boolean; + showTimestamp: boolean; + autoRefresh: boolean; + }; +} + +@Injectable() +export class SocialProofService { + private readonly logger = new Logger(SocialProofService.name); + + constructor( + @InjectRepository(SocialProof) + private readonly proofRepository: Repository, + @InjectRepository(UserGeneratedContent) + private readonly ugcRepository: Repository, + ) {} + + async createSocialProof(dto: CreateSocialProofDto): Promise { + const credibilityScore = await this.calculateCredibilityScore(dto); + + const proof = this.proofRepository.create({ + ...dto, + status: ProofStatus.PENDING, + credibilityScore, + displayCount: 0, + clickCount: 0, + metrics: { + reach: 0, + impressions: 0, + engagement: 0, + clickThroughRate: 0, + conversionRate: 0, + }, + }); + + const savedProof = await this.proofRepository.save(proof); + this.logger.log(`Created social proof: ${savedProof.id}`); + return savedProof; + } + + async findProofById(id: string): Promise { + const proof = await this.proofRepository.findOne({ + where: { id }, + }); + + if (!proof) { + throw new NotFoundException(`Social proof with ID ${id} not found`); + } + + return proof; + } + + async findProofsByEvent( + eventId: string, + proofTypes?: ProofType[], + limit: number = 50, + ): Promise { + const whereClause: any = { + eventId, + status: ProofStatus.APPROVED, + isActive: true, + }; + + if (proofTypes && proofTypes.length > 0) { + whereClause.proofType = In(proofTypes); + } + + return this.proofRepository.find({ + where: whereClause, + order: { + credibilityScore: 'DESC', + createdAt: 'DESC', + }, + take: limit, + }); + } + + async getFriendAttendanceProof( + eventId: string, + userId: string, + limit: number = 10, + ): Promise { + // This would integrate with user's social connections + // For now, returning mock data structure + const friendsAttending = await this.proofRepository.find({ + where: { + eventId, + proofType: ProofType.FRIEND_ATTENDANCE, + status: ProofStatus.APPROVED, + }, + order: { createdAt: 'DESC' }, + take: limit, + }); + + return { + type: 'friend_attendance', + data: { + totalFriendsAttending: friendsAttending.length, + friends: friendsAttending.map(proof => ({ + name: proof.authorName, + avatarUrl: proof.authorAvatarUrl, + mutualFriends: proof.friendsData?.[0]?.mutualFriends || 0, + attendanceStatus: proof.friendsData?.[0]?.attendanceStatus || 'attending', + })), + }, + displaySettings: { + maxItems: limit, + showAvatars: true, + showTimestamp: false, + autoRefresh: true, + }, + }; + } + + async getRecentActivityProof( + eventId: string, + limit: number = 5, + ): Promise { + const recentActivity = await this.proofRepository.find({ + where: { + eventId, + proofType: In([ + ProofType.SOCIAL_MENTION, + ProofType.REVIEW, + ProofType.USER_COUNT, + ]), + status: ProofStatus.APPROVED, + }, + order: { createdAt: 'DESC' }, + take: limit, + }); + + return { + type: 'recent_activity', + data: { + activities: recentActivity.map(proof => ({ + id: proof.id, + type: proof.proofType, + content: proof.content, + authorName: proof.authorName, + authorAvatarUrl: proof.authorAvatarUrl, + platform: proof.platform, + timestamp: proof.createdAt, + engagement: proof.totalEngagement, + })), + }, + displaySettings: { + maxItems: limit, + showAvatars: true, + showTimestamp: true, + autoRefresh: true, + }, + }; + } + + async getTestimonialsWidget( + eventId: string, + limit: number = 3, + ): Promise { + const testimonials = await this.proofRepository.find({ + where: { + eventId, + proofType: In([ProofType.TESTIMONIAL, ProofType.REVIEW]), + status: ProofStatus.APPROVED, + rating: 4, // Only high-rated testimonials + }, + order: { + rating: 'DESC', + credibilityScore: 'DESC', + }, + take: limit, + }); + + return { + type: 'testimonials', + data: { + testimonials: testimonials.map(proof => ({ + id: proof.id, + content: proof.content, + authorName: proof.authorName, + authorAvatarUrl: proof.authorAvatarUrl, + rating: proof.rating, + platform: proof.platform, + verified: proof.metadata?.verified || false, + })), + averageRating: testimonials.reduce((sum, t) => sum + (t.rating || 0), 0) / testimonials.length, + }, + displaySettings: { + maxItems: limit, + showAvatars: true, + showTimestamp: false, + autoRefresh: false, + }, + }; + } + + async getUserCountWidget(eventId: string): Promise { + // This would integrate with actual ticket sales and registration data + const userCountProof = await this.proofRepository.findOne({ + where: { + eventId, + proofType: ProofType.USER_COUNT, + status: ProofStatus.APPROVED, + }, + order: { createdAt: 'DESC' }, + }); + + // Mock data - in real implementation, this would come from event registration system + const mockData = { + totalRegistered: 1247, + recentRegistrations: 23, + timeframe: '24 hours', + trending: true, + }; + + return { + type: 'user_count', + data: userCountProof?.metadata || mockData, + displaySettings: { + maxItems: 1, + showAvatars: false, + showTimestamp: true, + autoRefresh: true, + }, + }; + } + + async approveProof(id: string): Promise { + const proof = await this.findProofById(id); + proof.status = ProofStatus.APPROVED; + + return this.proofRepository.save(proof); + } + + async rejectProof(id: string, reason?: string): Promise { + const proof = await this.findProofById(id); + proof.status = ProofStatus.REJECTED; + + if (reason) { + proof.metadata = { ...proof.metadata, rejectionReason: reason }; + } + + return this.proofRepository.save(proof); + } + + async trackProofDisplay(id: string): Promise { + await this.proofRepository.increment( + { id }, + 'displayCount', + 1, + ); + + await this.proofRepository.update( + { id }, + { lastDisplayedAt: new Date() }, + ); + } + + async trackProofClick(id: string): Promise { + await this.proofRepository.increment( + { id }, + 'clickCount', + 1, + ); + + // Update click-through rate + const proof = await this.findProofById(id); + const ctr = proof.displayCount > 0 ? (proof.clickCount / proof.displayCount) * 100 : 0; + + await this.proofRepository.update( + { id }, + { + metrics: { + ...proof.metrics, + clickThroughRate: ctr, + }, + }, + ); + } + + async getProofAnalytics( + eventId: string, + dateRange?: { start: Date; end: Date }, + ): Promise { + const whereClause: any = { eventId }; + + if (dateRange) { + whereClause.createdAt = Between(dateRange.start, dateRange.end); + } + + const proofs = await this.proofRepository.find({ + where: whereClause, + }); + + const analytics = { + totalProofs: proofs.length, + approvedProofs: proofs.filter(p => p.status === ProofStatus.APPROVED).length, + pendingProofs: proofs.filter(p => p.status === ProofStatus.PENDING).length, + totalDisplays: proofs.reduce((sum, p) => sum + p.displayCount, 0), + totalClicks: proofs.reduce((sum, p) => sum + p.clickCount, 0), + averageCtr: 0, + proofTypeBreakdown: {}, + platformBreakdown: {}, + topPerformingProofs: [], + }; + + // Calculate average CTR + const proofsWithDisplays = proofs.filter(p => p.displayCount > 0); + if (proofsWithDisplays.length > 0) { + analytics.averageCtr = proofsWithDisplays.reduce( + (sum, p) => sum + p.clickThroughRate, 0 + ) / proofsWithDisplays.length; + } + + // Proof type breakdown + for (const proof of proofs) { + analytics.proofTypeBreakdown[proof.proofType] = + (analytics.proofTypeBreakdown[proof.proofType] || 0) + 1; + } + + // Platform breakdown + for (const proof of proofs) { + if (proof.platform) { + analytics.platformBreakdown[proof.platform] = + (analytics.platformBreakdown[proof.platform] || 0) + 1; + } + } + + // Top performing proofs + analytics.topPerformingProofs = proofs + .filter(p => p.displayCount > 0) + .sort((a, b) => b.clickThroughRate - a.clickThroughRate) + .slice(0, 10) + .map(p => ({ + id: p.id, + content: p.content.substring(0, 100), + proofType: p.proofType, + displayCount: p.displayCount, + clickCount: p.clickCount, + clickThroughRate: p.clickThroughRate, + })); + + return analytics; + } + + async syncUserGeneratedContent(eventId: string): Promise { + // Find UGC that can be converted to social proof + const ugcContent = await this.ugcRepository.find({ + where: { + eventId, + status: ContentStatus.APPROVED, + }, + take: 50, + }); + + for (const content of ugcContent) { + // Check if we already have social proof for this UGC + const existingProof = await this.proofRepository.findOne({ + where: { + sourceUrl: content.originalUrl, + }, + }); + + if (!existingProof && content.originalUrl) { + await this.createSocialProof({ + eventId: content.eventId, + userId: content.userId, + organizerId: content.organizerId, + proofType: ProofType.USER_COUNT, + content: content.description, + authorName: content.authorName, + authorUsername: content.authorUsername, + authorAvatarUrl: content.authorAvatarUrl, + platform: content.platform, + sourceUrl: content.originalUrl, + mediaUrls: content.mediaUrls.map(m => m.url), + metadata: { + fromUGC: true, + ugcId: content.id, + engagement: content.totalEngagement, + qualityScore: content.qualityScore, + }, + }); + } + } + + this.logger.log(`Synced UGC to social proof for event: ${eventId}`); + } + + private async calculateCredibilityScore(dto: CreateSocialProofDto): Promise { + let score = 50; // Base score + + // Platform credibility + if (dto.platform) { + const platformScores = { + 'instagram': 15, + 'facebook': 12, + 'twitter': 10, + 'linkedin': 18, + 'tiktok': 8, + 'youtube': 14, + }; + score += platformScores[dto.platform.toLowerCase()] || 5; + } + + // Author verification + if (dto.metadata?.verified) { + score += 20; + } + + // Engagement metrics + if (dto.metadata?.followerCount) { + const followers = dto.metadata.followerCount; + if (followers > 100000) score += 15; + else if (followers > 10000) score += 10; + else if (followers > 1000) score += 5; + } + + // Content quality + if (dto.mediaUrls && dto.mediaUrls.length > 0) { + score += 10; + } + + if (dto.content.length > 50) { + score += 5; + } + + // Rating for reviews/testimonials + if (dto.rating && dto.rating >= 4) { + score += 10; + } + + // Friend connections for friend attendance + if (dto.proofType === ProofType.FRIEND_ATTENDANCE && dto.friendsData) { + score += Math.min(dto.friendsData.length * 2, 20); + } + + return Math.min(score, 100); + } +} diff --git a/src/social-media/social-media.module.ts b/src/social-media/social-media.module.ts new file mode 100644 index 00000000..79c6fb84 --- /dev/null +++ b/src/social-media/social-media.module.ts @@ -0,0 +1,81 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { HttpModule } from '@nestjs/axios'; +import { ConfigModule } from '@nestjs/config'; +import { ScheduleModule } from '@nestjs/schedule'; + +// Entities +import { SocialAccount } from './entities/social-account.entity'; +import { SocialPost } from './entities/social-post.entity'; +import { SocialCampaign } from './entities/social-campaign.entity'; +import { ReferralProgram } from './entities/referral-program.entity'; +import { ReferralCode } from './entities/referral-code.entity'; +import { ReferralTracking } from './entities/referral-tracking.entity'; +import { SocialProof } from './entities/social-proof.entity'; +import { UserGeneratedContent } from './entities/user-generated-content.entity'; +import { InfluencerCollaboration } from './entities/influencer-collaboration.entity'; + +// Services +import { SocialMediaApiService } from './services/social-media-api.service'; +import { SocialPostService } from './services/social-post.service'; +import { ReferralProgramService } from './services/referral-program.service'; +import { SocialProofService } from './services/social-proof.service'; +import { InfluencerCollaborationService } from './services/influencer-collaboration.service'; +import { SocialMediaAnalyticsService } from './services/social-media-analytics.service'; + +// Controllers +import { SocialAccountController } from './controllers/social-account.controller'; +import { SocialPostController } from './controllers/social-post.controller'; +import { ReferralProgramController } from './controllers/referral-program.controller'; +import { SocialProofController } from './controllers/social-proof.controller'; +import { InfluencerCollaborationController } from './controllers/influencer-collaboration.controller'; +import { SocialMediaAnalyticsController } from './controllers/social-media-analytics.controller'; +import { UserGeneratedContentController } from './controllers/user-generated-content.controller'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + SocialAccount, + SocialPost, + SocialCampaign, + ReferralProgram, + ReferralCode, + ReferralTracking, + SocialProof, + UserGeneratedContent, + InfluencerCollaboration, + ]), + HttpModule.register({ + timeout: 10000, + maxRedirects: 5, + }), + ConfigModule, + ScheduleModule.forRoot(), + ], + providers: [ + SocialMediaApiService, + SocialPostService, + ReferralProgramService, + SocialProofService, + InfluencerCollaborationService, + SocialMediaAnalyticsService, + ], + controllers: [ + SocialAccountController, + SocialPostController, + ReferralProgramController, + SocialProofController, + InfluencerCollaborationController, + SocialMediaAnalyticsController, + UserGeneratedContentController, + ], + exports: [ + SocialMediaApiService, + SocialPostService, + ReferralProgramService, + SocialProofService, + InfluencerCollaborationService, + SocialMediaAnalyticsService, + ], +}) +export class SocialMediaModule {}