diff --git a/layers/prisma/nodejs/package-lock.json b/layers/prisma/nodejs/package-lock.json index 389329f..861854b 100644 --- a/layers/prisma/nodejs/package-lock.json +++ b/layers/prisma/nodejs/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "@prisma/adapter-pg": "^7.0.1", "@prisma/client": "^7.0.1", - "pg": "^8.13.0" + "pg": "^8.13.0", + "zod": "^4.1.12" } }, "node_modules/@prisma/adapter-pg": { @@ -223,6 +224,15 @@ "engines": { "node": ">=0.4" } + }, + "node_modules/zod": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", + "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/layers/prisma/nodejs/package.json b/layers/prisma/nodejs/package.json index 47d0046..5cd95ff 100644 --- a/layers/prisma/nodejs/package.json +++ b/layers/prisma/nodejs/package.json @@ -1,10 +1,11 @@ { "name": "prisma-layer", "version": "1.0.0", - "description": "Lambda layer for Prisma 7 with pg driver adapter", + "description": "Lambda layer for Prisma 7 with pg driver adapter and zod", "dependencies": { "@prisma/client": "^7.0.1", "@prisma/adapter-pg": "^7.0.1", - "pg": "^8.13.0" + "pg": "^8.13.0", + "zod": "^4.1.12" } } diff --git a/package-lock.json b/package-lock.json index 5aa6fd1..9af9724 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,8 @@ "prisma": "^7.0.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", - "serverless": "4.17.0", + "serverless": "4.24.0", + "swagger-ui-express": "^5.0.0", "tslib": "^2.8.1", "uuid": "^13.0.0", "yup": "^1.7.1", @@ -14491,12 +14492,12 @@ } }, "node_modules/serverless": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/serverless/-/serverless-4.17.0.tgz", - "integrity": "sha512-hoZmipwyN/h7y9HwkWGlJ0YT06RFq7WNOD7fFEiPfnSnnUMVTzeNHq2BRrUlpHhf5s9srCHDc2wx5I06acfq1Q==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/serverless/-/serverless-4.24.0.tgz", + "integrity": "sha512-bgxFQ6QyOGJC9IZjZIXo4m6bdWMl9I7HNZ4jrmwSpdePdsRd46igGRpSnhdYFOc71GNplhSOeoCibL94yCHfrg==", "hasInstallScript": true, "dependencies": { - "axios": "^1.8.3", + "axios": "^1.12.1", "axios-proxy-builder": "^0.1.2", "rimraf": "^5.0.5", "xml2js": "0.6.2" diff --git a/package.json b/package.json index 81e9c17..ec4caaf 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,8 @@ "prisma": "^7.0.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", - "serverless": "4.17.0", + "serverless": "4.24.0", + "swagger-ui-express": "^5.0.0", "tslib": "^2.8.1", "uuid": "^13.0.0", "yup": "^1.7.1", diff --git a/prisma/prisma.ts b/prisma/prisma.ts index 61913bb..966c5cd 100644 --- a/prisma/prisma.ts +++ b/prisma/prisma.ts @@ -1,8 +1,8 @@ // prisma.ts -import { PrismaClient } from '@prisma/client'; +// Re-export from the main singleton for consistency +import { prisma } from '../src/common/database/prisma.client'; -// The DATABASE_URL environment variable will be automatically used -export const prisma = new PrismaClient(); +export { prisma }; process.on('SIGINT', async () => { await prisma.$disconnect(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4db51d4..56b18b8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -67,6 +67,11 @@ model User { userAddressDetails UserAddressDetails[] userDocuments UserDocuments[] activityTracks ActivityTrack[] + // 🔹 Activities created by this user + createdActivities Activities[] @relation("UserActivities") + + // 🔹 Activities where this user is Account Manager + managedActivities Activities[] @relation("ActivityAccountManager") @@map("users") @@schema("usr") @@ -394,12 +399,13 @@ model DocumentType { } model FoodCuisines { - id Int @id @default(autoincrement()) - cuisineName String @unique @map("cuisine_name") @db.VarChar(30) - isActive Boolean @default(true) @map("is_active") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - deletedAt DateTime? @map("deleted_at") + id Int @id @default(autoincrement()) + cuisineName String @unique @map("cuisine_name") @db.VarChar(30) + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + activityCuisines ActivityCuisine[] @@map("food_cuisines") @@schema("mst") @@ -434,13 +440,14 @@ model Amenities { } model FoodTypes { - id Int @id @default(autoincrement()) - foodTypeName String @unique @map("food_type_name") @db.VarChar(30) - isActive Boolean @default(true) @map("is_active") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - deletedAt DateTime? @map("deleted_at") - ActivityFoodDetails ActivityFoodDetails[] + id Int @id @default(autoincrement()) + foodTypeName String @unique @map("food_type_name") @db.VarChar(30) + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + ActivityFoodCost ActivityFoodCost[] + activityFoodTypes ActivityFoodTypes[] @@map("food_types") @@schema("mst") @@ -614,6 +621,7 @@ model EnergyLevels { id Int @id @default(autoincrement()) energyLevelName String @map("energy_level_name") @db.VarChar(30) energyIcon String @map("energy_icon") @db.VarChar(400) + energyColor String @map("energy_color") @db.VarChar(20) isActive Boolean @default(true) @map("is_active") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -844,38 +852,45 @@ model HostTrack { // ACTIVITY MODELS model Activities { - id Int @id @default(autoincrement()) - hostXid Int @map("host_xid") - host HostHeader @relation(fields: [hostXid], references: [id], onDelete: Cascade) - activityTypeXid Int @map("activity_type_xid") - activityType ActivityTypes @relation(fields: [activityTypeXid], references: [id], onDelete: Restrict) - frequenciesXid Int? @map("frequencies_xid") - frequency Frequencies? @relation(fields: [frequenciesXid], references: [id], onDelete: Restrict) - activityRefNumber String? @map("activity_ref_number") @db.VarChar(30) - activityTitle String? @map("activity_title") @db.VarChar(30) - activityDescription String? @map("activity_description") @db.VarChar(80) - checkInLat Float? @map("check_in_lat") - checkInLong Float? @map("check_in_long") - checkInAddress String? @map("check_in_address") @db.VarChar(150) - isCheckOutSame Boolean? @default(true) @map("is_check_out_same") - checkOutLat Float? @map("check_out_lat") - checkOutLong Float? @map("check_out_long") - checkOutAddress String? @map("check_out_address") @db.VarChar(150) - energyLevelXid Int? @map("energy_level_xid") - energyLevel EnergyLevels? @relation(fields: [energyLevelXid], references: [id], onDelete: Restrict) - activityDurationMins Int? @map("activity_duration_mins") - foodAvailable Boolean? @default(false) @map("food_available") - foodIsChargeable Boolean? @default(false) @map("food_is_chargeable") - alcoholAvailable Boolean? @default(false) @map("alcohol_available") - trainerAvailable Boolean? @default(false) @map("trainer_available") - trainerIsChargeable Boolean? @default(false) @map("trainer_is_chargeable") - pickUpDropAvailable Boolean? @default(false) @map("pick_up_drop_available") - pickUpDropIsChargeable Boolean? @default(false) @map("pick_up_drop_is_chargeable") - inActivityAvailable Boolean? @default(false) @map("in_activity_available") - inActivityIsChargeable Boolean? @default(false) @map("in_activity_is_chargeable") - equipmentAvailable Boolean? @default(false) @map("equipment_available") - equipmentIsChargeable Boolean? @default(false) @map("equipment_is_chargeable") - cancellationAvailable Boolean? @default(false) @map("cancellation_available") + id Int @id @default(autoincrement()) + hostXid Int @map("host_xid") + host HostHeader @relation(fields: [hostXid], references: [id], onDelete: Cascade) + activityTypeXid Int @map("activity_type_xid") + activityType ActivityTypes @relation(fields: [activityTypeXid], references: [id], onDelete: Restrict) + frequenciesXid Int? @map("frequencies_xid") + frequency Frequencies? @relation(fields: [frequenciesXid], references: [id], onDelete: Restrict) + activityRefNumber String? @map("activity_ref_number") @db.VarChar(30) + activityTitle String? @map("activity_title") @db.VarChar(30) + activityDescription String? @map("activity_description") @db.VarChar(255) + checkInLat Float? @map("check_in_lat") + checkInLong Float? @map("check_in_long") + checkInAddress String? @map("check_in_address") @db.VarChar(150) + isCheckOutSame Boolean? @default(true) @map("is_check_out_same") + checkOutLat Float? @map("check_out_lat") + checkOutLong Float? @map("check_out_long") + checkOutAddress String? @map("check_out_address") @db.VarChar(150) + energyLevelXid Int? @map("energy_level_xid") + energyLevel EnergyLevels? @relation(fields: [energyLevelXid], references: [id], onDelete: Restrict) + activityDurationMins Int? @map("activity_duration_mins") + foodAvailable Boolean? @default(false) @map("food_available") + foodIsChargeable Boolean? @default(false) @map("food_is_chargeable") + alcoholAvailable Boolean? @default(false) @map("alcohol_available") + trainerAvailable Boolean? @default(false) @map("trainer_available") + trainerIsChargeable Boolean? @default(false) @map("trainer_is_chargeable") + pickUpDropAvailable Boolean? @default(false) @map("pick_up_drop_available") + pickUpDropIsChargeable Boolean? @default(false) @map("pick_up_drop_is_chargeable") + inActivityAvailable Boolean? @default(false) @map("in_activity_available") + inActivityIsChargeable Boolean? @default(false) @map("in_activity_is_chargeable") + equipmentAvailable Boolean? @default(false) @map("equipment_available") + equipmentIsChargeable Boolean? @default(false) @map("equipment_is_chargeable") + cancellationAvailable Boolean? @default(false) @map("cancellation_available") + // 🔹 Creator / owner + userId Int? + user User? @relation("UserActivities", fields: [userId], references: [id]) + + // 🔹 Account Manager + accountManagerXid Int? + accountManager User? @relation("ActivityAccountManager", fields: [accountManagerXid], references: [id], onDelete: Restrict) cancellationAllowedBeforeMins Int? @map("cancellation_allowed_before_mins") currencyXid Int? @map("currency_xid") currencies Currencies? @relation(fields: [currencyXid], references: [id], onDelete: Restrict) @@ -898,19 +913,19 @@ model Activities { ActivityEligibility ActivityEligibility[] ActivitySuggestions ActivitySuggestions[] ActivityAmDetails ActivityAmDetails[] - ActivityPrices ActivityPrices[] - ActivityVenueArtifacts ActivityVenueArtifacts[] ActivityPQQheader ActivityPQQheader[] ActivityAllowedEntry ActivityAllowedEntry[] - ActivityFoodDetails ActivityFoodDetails[] + ActivityFoodCost ActivityFoodCost[] ActivityEquipments ActivityEquipments[] ActivityNavigationModes ActivityNavigationModes[] ActivityPickUpDetails ActivityPickUpDetails[] ActivityAmenities ActivityAmenities[] - ActivityEquipmentTaxes ActivityEquipmentTaxes[] ScheduleHeader ScheduleHeader[] ItineraryActivities ItineraryActivities[] activityTracks ActivityTrack[] + activityFoodTypes ActivityFoodTypes[] + activityCuisines ActivityCuisine[] + activityPickUpTransports ActivityPickUpTransport[] @@map("activities") @@schema("act") @@ -920,8 +935,7 @@ model ActivityOtherDetails { id Int @id @default(autoincrement()) activityXid Int @map("activity_xid") activity Activities @relation(fields: [activityXid], references: [id], onDelete: Cascade) - foodCuisines String? @map("food_cuisines") @db.VarChar(30) - exclusiveNotes String? @map("exclusive_notes") @db.VarChar(50) + exclusiveNotes String? @map("exclusive_notes") @db.VarChar(500) dosNotes String? @map("dos_notes") @db.VarChar(200) dontsNotes String? @map("donts_notes") @db.VarChar(200) tipsNotes String? @map("tips_notes") @db.VarChar(100) @@ -987,6 +1001,8 @@ model ActivityVenues { deletedAt DateTime? @map("deleted_at") ScheduleHeader ScheduleHeader[] ItineraryActivities ItineraryActivities[] + ActivityPrices ActivityPrices[] // <-- Added opposite relation + ActivityVenueArtifacts ActivityVenueArtifacts[] // <-- Added opposite relation @@map("activity_venues") @@schema("act") @@ -1036,10 +1052,14 @@ model ActivityEligibility { weightRestrictionName String? @map("weight_restriction_name") @db.VarChar(30) weightEntered Int? @map("weight_entered") weightIn String? @map("weight_in") @db.VarChar(30) + minWeight Int? @map("min_weight") + maxWeight Int? @map("max_weight") isHeightRestriction Boolean @default(false) @map("is_height_restriction") heightRestrictionName String? @map("height_restriction_name") @db.VarChar(30) heightEntered Int? @map("height_entered") heightIn String? @map("height_in") @db.VarChar(30) + minHeight Int? @map("min_height") + maxHeight Int? @map("max_height") isActive Boolean @default(true) @map("is_active") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -1086,7 +1106,7 @@ model ActivityAmDetails { model ActivityPrices { id Int @id @default(autoincrement()) activityVenueXid Int @map("activity_venue_xid") - activityVenue Activities @relation(fields: [activityVenueXid], references: [id], onDelete: Cascade) + activityVenue ActivityVenues @relation(fields: [activityVenueXid], references: [id], onDelete: Cascade) noOfSession Int @map("no_of_session") isPackage Boolean @default(false) @map("is_package") sessionValidity Int @map("session_validity") @@ -1123,7 +1143,7 @@ model ActivityPriceTaxes { model ActivityVenueArtifacts { id Int @id @default(autoincrement()) activityVenueXid Int @map("activity_venue_xid") - activityVenue Activities @relation(fields: [activityVenueXid], references: [id], onDelete: Cascade) + activityVenue ActivityVenues @relation(fields: [activityVenueXid], references: [id], onDelete: Cascade) mediaType String @map("media_type") @db.VarChar(30) mediaFileName String @map("media_file_name") @db.VarChar(400) isActive Boolean @default(true) @map("is_active") @@ -1204,12 +1224,10 @@ model ActivityAllowedEntry { @@schema("act") } -model ActivityFoodDetails { +model ActivityFoodCost { id Int @id @default(autoincrement()) activityXid Int @map("activity_xid") activity Activities @relation(fields: [activityXid], references: [id], onDelete: Cascade) - foodTypeXid Int @map("food_type_xid") - foodType FoodTypes @relation(fields: [foodTypeXid], references: [id], onDelete: Restrict) baseAmount Int @map("base_amount") totalAmount Int @map("total_amount") isActive Boolean @default(true) @map("is_active") @@ -1217,23 +1235,55 @@ model ActivityFoodDetails { updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") ActivityFoodTaxes ActivityFoodTaxes[] + foodTypes FoodTypes? @relation(fields: [foodTypesId], references: [id]) + foodTypesId Int? - @@map("activity_food_details") + @@map("activity_food_cost") + @@schema("act") +} + +model ActivityFoodTypes { + id Int @id @default(autoincrement()) + activityXid Int @map("activity_xid") + activity Activities @relation(fields: [activityXid], references: [id], onDelete: Cascade) + foodTypeXid Int @map("food_type_xid") + foodType FoodTypes @relation(fields: [foodTypeXid], references: [id], onDelete: Restrict) + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + @@map("activity_food_types") + @@schema("act") +} + +model ActivityCuisine { + id Int @id @default(autoincrement()) + activityXid Int @map("activity_xid") + activity Activities @relation(fields: [activityXid], references: [id], onDelete: Cascade) + foodCuisineXid Int @map("food_cuisine_xid") + foodCuisine FoodCuisines @relation(fields: [foodCuisineXid], references: [id], onDelete: Restrict) + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + @@map("activity_cuisine") @@schema("act") } model ActivityFoodTaxes { - id Int @id @default(autoincrement()) - activityFoodDetailsXid Int @map("activity_food_details_xid") - activityFoodDetails ActivityFoodDetails @relation(fields: [activityFoodDetailsXid], references: [id], onDelete: Cascade) - taxXid Int @map("tax_xid") - taxes Taxes @relation(fields: [taxXid], references: [id], onDelete: Restrict) - taxPer Float @map("tax_per") - taxAmount Int @map("tax_amount") - isActive Boolean @default(true) @map("is_active") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - deletedAt DateTime? @map("deleted_at") + id Int @id @default(autoincrement()) + activityFoodCostXid Int @map("activity_food_cost_xid") + activityFoodCost ActivityFoodCost @relation(fields: [activityFoodCostXid], references: [id], onDelete: Cascade) + taxXid Int @map("tax_xid") + taxes Taxes @relation(fields: [taxXid], references: [id], onDelete: Restrict) + taxPer Float @map("tax_per") + taxAmount Int @map("tax_amount") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") @@map("activity_food_taxes") @@schema("act") @@ -1251,6 +1301,7 @@ model ActivityEquipments { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") + ActivityEquipmentTaxes ActivityEquipmentTaxes[] @@map("activity_equipments") @@schema("act") @@ -1259,7 +1310,7 @@ model ActivityEquipments { model ActivityEquipmentTaxes { id Int @id @default(autoincrement()) activityEquipmentXid Int @map("activity_equipment_xid") - activityEquipment Activities @relation(fields: [activityEquipmentXid], references: [id], onDelete: Cascade) + activityEquipment ActivityEquipments @relation(fields: [activityEquipmentXid], references: [id], onDelete: Cascade) taxXid Int @map("tax_xid") taxes Taxes @relation(fields: [taxXid], references: [id], onDelete: Restrict) taxPer Float @map("tax_per") @@ -1310,54 +1361,57 @@ model ActivityNavigationModesTaxes { } model ActivityPickUpDetails { - id Int @id @default(autoincrement()) - activityXid Int @map("activity_xid") - activity Activities @relation(fields: [activityXid], references: [id], onDelete: Cascade) - isPickUp Boolean @default(false) @map("is_pick_up") - locationLat Float? @map("location_lat") - locationLong Float? @map("location_long") - locationAddress String? @map("location_address") @db.VarChar(150) - isActive Boolean @default(true) @map("is_active") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - deletedAt DateTime? @map("deleted_at") - ActivityPickUpTransport ActivityPickUpTransport[] - - @@map("activity_pick_up_details") - @@schema("act") -} - -model ActivityPickUpTransport { id Int @id @default(autoincrement()) - activityPickUpDetailsXid Int @map("activity_pick_up_details_xid") - activityPickUpDetails ActivityPickUpDetails @relation(fields: [activityPickUpDetailsXid], references: [id], onDelete: Cascade) - transportModeXid Int @map("transport_mode_xid") - transportMode TransportModes @relation(fields: [transportModeXid], references: [id], onDelete: Restrict) - isTransportModeChargeable Boolean @default(false) @map("is_transport_mode_chargeable") + activityPickUpTransportXid Int @map("activity_pick_up_transport_xid") + activityPickUpTransport ActivityPickUpTransport @relation(fields: [activityPickUpTransportXid], references: [id], onDelete: Cascade) + isPickUp Boolean @default(false) @map("is_pick_up") + locationLat Float? @map("location_lat") + locationLong Float? @map("location_long") + locationAddress String? @map("location_address") @db.VarChar(150) transportBasePrice Int @map("transport_base_price") transportTotalPrice Int @map("transport_total_price") isActive Boolean @default(true) @map("is_active") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") - ActivityPickUpTransportTaxes ActivityPickUpTransportTaxes[] + activities Activities? @relation(fields: [activitiesId], references: [id]) + activitiesId Int? + activityPickUpTransportTaxes ActivityPickUpTransportTaxes[] + + @@map("activity_pick_up_details") + @@schema("act") +} + +model ActivityPickUpTransport { + id Int @id @default(autoincrement()) + activityXid Int @map("activity_xid") + activity Activities @relation(fields: [activityXid], references: [id], onDelete: Cascade) + transportModeXid Int @map("transport_mode_xid") + transportMode TransportModes @relation(fields: [transportModeXid], references: [id], onDelete: Restrict) + isTransportModeChargeable Boolean @default(false) @map("is_transport_mode_chargeable") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + pickupDetails ActivityPickUpDetails[] @@map("activity_pick_up_transport") @@schema("act") } model ActivityPickUpTransportTaxes { - id Int @id @default(autoincrement()) - activityPickUpTransportXid Int @map("activity_pick_up_transport_xid") - activityPickUpTransport ActivityPickUpTransport @relation(fields: [activityPickUpTransportXid], references: [id], onDelete: Cascade) - taxXid Int @map("tax_xid") - taxes Taxes @relation(fields: [taxXid], references: [id], onDelete: Restrict) - taxPer Float @map("tax_per") - taxAmount Int @map("tax_amount") - isActive Boolean @default(true) @map("is_active") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - deletedAt DateTime? @map("deleted_at") + id Int @id @default(autoincrement()) + activityPickUpDetailsXid Int @map("activity_pick_up_details_xid") + activityPickUpDetails ActivityPickUpDetails @relation(fields: [activityPickUpDetailsXid], references: [id], onDelete: Cascade) + taxXid Int @map("tax_xid") + taxes Taxes @relation(fields: [taxXid], references: [id], onDelete: Restrict) + taxPer Float @map("tax_per") + taxAmount Int @map("tax_amount") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") @@map("activity_pick_up_transport_taxes") @@schema("act") diff --git a/prisma/seed.ts b/prisma/seed.ts index 291cd0c..a97f7e5 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -223,6 +223,15 @@ async function main() { ], skipDuplicates: true, // prevents error if already seeded }); + // ✅ Energy Levels + await prisma.energyLevels.createMany({ + data: [ + { energyLevelName: 'Low', energyIcon: '📶', energyColor: 'Red' }, + { energyLevelName: 'Medium', energyIcon: '📶', energyColor: 'Yellow' }, + { energyLevelName: 'High', energyIcon: '📶', energyColor: 'Green' }, + ], + skipDuplicates: true, // prevents error if already seeded + }); // ✅ Company types data await prisma.companyTypes.upsert({ @@ -250,9 +259,9 @@ async function main() { }); await prisma.companyTypes.upsert({ - where: { companyTypeName: 'Private Limited, Public Limited' }, + where: { companyTypeName: 'Private Limited' }, update: {}, - create: { companyTypeName: 'Private Limited, Public Limited', displayOrder: 5 }, + create: { companyTypeName: 'Private Limited', displayOrder: 5 }, }); await prisma.companyTypes.upsert({ @@ -261,6 +270,12 @@ async function main() { create: { companyTypeName: 'Non-Profit Organisation', displayOrder: 6 }, }); + await prisma.companyTypes.upsert({ + where: { companyTypeName: 'Public Limited' }, + update: {}, + create: { companyTypeName: 'Public Limited', displayOrder: 7 }, + }); + // ✅ Food Types await prisma.foodTypes.createMany({ data: [ diff --git a/serverless.yml b/serverless.yml index 060b40e..1a0918a 100644 --- a/serverless.yml +++ b/serverless.yml @@ -1,4 +1,4 @@ -service: minglarDev +service: minglar useDotenv: true @@ -20,7 +20,9 @@ provider: # Apply Prisma layer to all functions # Reference the layer defined in this stack using CloudFormation Ref layers: - - !Ref PrismaLambdaLayer + # Use the exported stack output so deploy function works (expects a string ARN) + # For offline/local, fall back to an empty string so the CF lookup is optional. + - ${cf:${self:service}-${sls:stage}.PrismaLambdaLayerQualifiedArn, ''} apiGateway: binaryMediaTypes: - '*/*' @@ -84,11 +86,12 @@ build: platform: node # Mark as external so they're not bundled into the JS external: - # - '@prisma/client' - # - '.prisma/client' - # - '.prisma' + - '@prisma/client' + - '.prisma/client' + - '.prisma' - '@prisma/adapter-pg' - 'pg' + - 'zod' - '@aws-sdk/*' - '@smithy/*' - '@aws-crypto/*' @@ -99,10 +102,11 @@ build: - '@smithy/*' - '@aws-crypto/*' - '@prisma/adapter-pg' - # - '@prisma/client' - # - '.prisma' - # - '.prisma/client' + - '@prisma/client' + - '.prisma' + - '.prisma/client' - 'pg' + - 'zod' - 'pg-*' - 'postgres-*' - 'pgpass' diff --git a/serverless/functions/host.yml b/serverless/functions/host.yml index 117b18c..2b1d8f9 100644 --- a/serverless/functions/host.yml +++ b/serverless/functions/host.yml @@ -177,6 +177,23 @@ prePopulateNewActivity: path: /host/Activity_Hub/OnBoarding/prepopulate-new-activity method: get +createNewActivity: + handler: src/modules/host/handlers/Activity_Hub/OnBoarding/CreateNewActivity.handler + memorySize: 1024 + timeout: 30 + package: + patterns: + - 'src/modules/host/handlers/Activity_Hub/OnBoarding/CreateNewActivity.*' + - 'src/modules/host/services/**' + - ${file(./serverless/patterns/base.yml):pattern1} + - ${file(./serverless/patterns/base.yml):pattern2} + - ${file(./serverless/patterns/base.yml):pattern3} + - ${file(./serverless/patterns/base.yml):pattern4} + events: + - httpApi: + path: /host/Activity_Hub/OnBoarding/create-new-activity + method: patch + showSuggestion: handler: src/modules/host/handlers/Host_Admin/onboarding/showSuggestion.handler memorySize: 384 diff --git a/serverless/functions/minglaradmin.yml b/serverless/functions/minglaradmin.yml index c660469..c718cd8 100644 --- a/serverless/functions/minglaradmin.yml +++ b/serverless/functions/minglaradmin.yml @@ -406,3 +406,20 @@ getAllPQPDetailsForAM: - httpApi: path: /minglaradmin/hosthub/pqp/pqp-details-for-am/{activityXid} method: get + + +getSuggestionsForAM: + handler: src/modules/minglaradmin/handlers/hosthub/onboarding/showSuggestionToAM.handler + memorySize: 384 + package: + patterns: + - 'src/modules/minglaradmin/handlers/hosthub/onboarding/showSuggestionToAM**' + - 'src/modules/minglaradmin/services/**' + - ${file(./serverless/patterns/base.yml):pattern1} + - ${file(./serverless/patterns/base.yml):pattern2} + - ${file(./serverless/patterns/base.yml):pattern3} + - ${file(./serverless/patterns/base.yml):pattern4} + events: + - httpApi: + path: /minglaradmin/hosthub/onboarding/show-suggestion-to-am/{hostXid} + method: get diff --git a/serverless/functions/prepopulate.yml b/serverless/functions/prepopulate.yml index 7521ddb..d9282b5 100644 --- a/serverless/functions/prepopulate.yml +++ b/serverless/functions/prepopulate.yml @@ -91,4 +91,19 @@ getFrequenciesOfActivity: events: - httpApi: path: /prepopulate/get-all-Frequencies - method: get \ No newline at end of file + method: get + +getAddActivityPrePopulate: + handler: src/modules/prepopulate/handlers/getAddActivityPrePopulate.handler + memorySize: 384 + package: + patterns: + - 'src/modules/prepopulate/**' + - ${file(./serverless/patterns/base.yml):pattern1} + - ${file(./serverless/patterns/base.yml):pattern2} + - ${file(./serverless/patterns/base.yml):pattern3} + - ${file(./serverless/patterns/base.yml):pattern4} + events: + - httpApi: + path: /prepopulate/get-add-activity-prepopulate + method: get diff --git a/src/common/database/prisma.client.ts b/src/common/database/prisma.client.ts index 1da7d2e..1831f66 100644 --- a/src/common/database/prisma.client.ts +++ b/src/common/database/prisma.client.ts @@ -1,11 +1,29 @@ import { PrismaClient } from '@prisma/client'; import { PrismaPg } from '@prisma/adapter-pg'; -const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); +// Singleton pattern for Prisma client - prevents "Too many database connections" error +const globalForPrisma = globalThis as unknown as { + prisma: PrismaClient | undefined; +}; -export const prisma = new PrismaClient({ - adapter, - log: process.env.NODE_ENV === 'dev' ? ['query', 'info', 'warn', 'error'] : ['error'], -}); +function createPrismaClient() { + const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); + + return new PrismaClient({ + adapter, + log: process.env.NODE_ENV === 'dev' ? ['query', 'info', 'warn', 'error'] : ['error'], + }); +} + +export const prisma = globalForPrisma.prisma ?? createPrismaClient(); + +if (process.env.NODE_ENV !== 'production') { + globalForPrisma.prisma = prisma; +} + +// For serverless environments, always cache the client +if (process.env.IS_OFFLINE || process.env.AWS_LAMBDA_FUNCTION_NAME) { + globalForPrisma.prisma = prisma; +} diff --git a/src/common/database/prisma.lambda.service.ts b/src/common/database/prisma.lambda.service.ts index 0aa5b78..e5409b6 100644 --- a/src/common/database/prisma.lambda.service.ts +++ b/src/common/database/prisma.lambda.service.ts @@ -1,22 +1,5 @@ -import { PrismaClient } from '@prisma/client'; -import { PrismaPg } from '@prisma/adapter-pg'; - -const adapter = new PrismaPg({ - connectionString: process.env.DATABASE_URL!, -}); - -let prisma: PrismaClient; - -if (!(global as any).prisma) { - (global as any).prisma = new PrismaClient({ - adapter, - log: - process.env.NODE_ENV === 'dev' - ? ['query', 'info', 'warn', 'error'] - : ['error'], - }); -} - -prisma = (global as any).prisma; +// Re-export the singleton prisma client for Lambda handlers +// This ensures all Lambda functions use the same cached connection +import { prisma } from './prisma.client'; export const prismaClient = prisma; \ No newline at end of file diff --git a/src/common/database/prisma.service.ts b/src/common/database/prisma.service.ts index bb6565f..b75245f 100644 --- a/src/common/database/prisma.service.ts +++ b/src/common/database/prisma.service.ts @@ -1,8 +1,15 @@ import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; +import { prisma } from './prisma.client'; @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + constructor() { + super(); + // Use the singleton instance + Object.assign(this, prisma); + } + async onModuleInit() { await this.$connect(); } diff --git a/src/common/utils/constants/host.constant.ts b/src/common/utils/constants/host.constant.ts index 4cccf6e..3dbe85f 100644 --- a/src/common/utils/constants/host.constant.ts +++ b/src/common/utils/constants/host.constant.ts @@ -1,73 +1,100 @@ export const HOST_STATUS_INTERNAL = { - HOST_SUBMITTED: "Host Submitted", - HOST_TO_UPDATE: "Host To Update", - REJECTED: "Rejected", - APPROVED: "Approved", - DRAFT: "Draft", -} + HOST_SUBMITTED: 'Host Submitted', + HOST_TO_UPDATE: 'Host To Update', + REJECTED: 'Rejected', + APPROVED: 'Approved', + DRAFT: 'Draft', +}; export const HOST_STATUS_DISPLAY = { - DRAFT: "Draft", - UNDER_REVIEW: "Under Review", - ENHANCING: "Enhancing", - REJECTED: "Rejected", - APPROVED: "Approved", -} + DRAFT: 'Draft', + UNDER_REVIEW: 'Under Review', + ENHANCING: 'Enhancing', + REJECTED: 'Rejected', + APPROVED: 'Approved', +}; export const STEPPER = { - NOT_SUBMITTED: 1, - UNDER_REVIEW: 2, - COMPANY_DETAILS_APPROVED: 3, - BANK_DETAILS_UPDATED: 4, - AGREEMENT_ACCEPTED: 5, - REJECTED: 6 -} + NOT_SUBMITTED: 1, + UNDER_REVIEW: 2, + COMPANY_DETAILS_APPROVED: 3, + BANK_DETAILS_UPDATED: 4, + AGREEMENT_ACCEPTED: 5, + REJECTED: 6, +}; export const ACTIVITY_INTERNAL_STATUS = { - DRAFT_PQ: 'Draft - PQ', - APPROVED: 'Approved', - REJECTED: 'Rejected', - DRAFT: 'Draft', - UNDER_REVIEW: 'Under-Review', - PQ_FAILED: 'PQ Failed', - PQ_TO_UPDATE: 'PQ To Update', - PQ_SUBMITTED: 'PQ Submitted', - PQ_APPROVED: 'PQ Approved' -} + DRAFT_PQ: 'Draft - PQ', + APPROVED: 'Approved', + REJECTED: 'Rejected', + DRAFT: 'Draft', + UNDER_REVIEW: 'Under-Review', + PQ_FAILED: 'PQ Failed', + PQ_TO_UPDATE: 'PQ To Update', + PQ_SUBMITTED: 'PQ Submitted', + PQ_APPROVED: 'PQ Approved', + + ACTIVITY_DRAFT: 'Draft - Activity', + ACTIVITY_SUBMITTED: 'Activity Submitted', + ACTIVITY_TO_REVIEW: 'Activity To Review', + ACTIVITY_REJECTED: 'Activity Rejected', + ACTIVITY_APPROVED: 'Activity Approved', + ACTIVITY_LISTED: 'Activity Listed', + ACTIVITY_UNLISTED: 'Activity Un Listed By Host', +}; export const ACTIVITY_DISPLAY_STATUS = { - DRAFT_PQ: 'Draft - PQ', - APPROVED: 'Approved', - REJECTED: 'Rejected', - DRAFT: 'Draft', - UNDER_REVIEW: 'Under-Review', - PQ_FAILED: 'PQ Failed', - ENHANCING: 'Enchancing', - PQ_IN_REVIEW: 'PQ In Review', - PQ_APPROVED: 'PQ Approved' -} + DRAFT_PQ: 'Draft - PQ', + APPROVED: 'Approved', + REJECTED: 'Rejected', + DRAFT: 'Draft', + UNDER_REVIEW: 'Under-Review', + PQ_FAILED: 'PQ Failed', + ENHANCING: 'Enchancing', + PQ_IN_REVIEW: 'PQ In Review', + PQ_APPROVED: 'PQ Approved', + + ACTIVITY_DRAFT: 'Draft - Activity', + ACTIVITY_IN_REVIEW: 'In Review', + ACTIVITY_TO_REVIEW: 'To Review', + ACTIVITY_NOT_LISTED: 'Not Listed', + ACTIVITY_LISTED: 'Listed', + ACTIVITY_UNLISTED: 'Un Listed', +}; export const ACTIVITY_AM_INTERNAL_STATUS = { - DRAFT_PQ: 'Draft - PQ', - APPROVED: 'Approved', - REJECTED: 'Rejected', - DRAFT: 'Draft', - UNDER_REVIEW: 'Under-Review', - PQ_FAILED: 'PQ Failed', - PQ_REJECTED: 'PQ Rejected', - PQ_TO_REVIEW: 'PQ To Review', - PQ_APPROVED: 'PQ Approved' -} + DRAFT_PQ: 'Draft - PQ', + APPROVED: 'Approved', + REJECTED: 'Rejected', + DRAFT: 'Draft', + UNDER_REVIEW: 'Under-Review', + PQ_FAILED: 'PQ Failed', + PQ_REJECTED: 'PQ Rejected', + PQ_TO_REVIEW: 'PQ To Review', + PQ_APPROVED: 'PQ Approved', + + ACTIVITY_DRAFT: 'Draft - Activity', + ACTIVITY_TO_REVIEW: 'Activity To Review', + ACTIVITY_REJECTED: 'Activity Rejected', + ACTIVITY_APPROVED: 'Activity Approved', + ACTIVITY_LISTED: 'Activity Listed', +}; export const ACTIVITY_AM_DISPLAY_STATUS = { - DRAFT_PQ: 'Draft - PQ', - APPROVED: 'Approved', - REJECTED: 'Rejected', - DRAFT: 'Draft', - UNDER_REVIEW: 'Under-Review', - PQ_FAILED: 'PQ Failed', - ENHANCING: 'Enchancing', - NEW: 'New', - PQ_APPROVED: 'PQ Approved', - REVISED: 'Revised' -} \ No newline at end of file + DRAFT_PQ: 'Draft - PQ', + APPROVED: 'Approved', + REJECTED: 'Rejected', + DRAFT: 'Draft', + UNDER_REVIEW: 'Under-Review', + PQ_FAILED: 'PQ Failed', + ENHANCING: 'Enchancing', + NEW: 'New', + PQ_APPROVED: 'PQ Approved', + REVISED: 'Revised', + + ACTIVITY_DRAFT: 'Draft - Activity', + ACTIVITY_NEW: 'To Review', + ACTIVITY_ENHANCING: 'Enhancing', + ACTIVITY_NOT_LISTED: 'Not Listed', + ACTIVITY_LISTED: 'Listed', +}; diff --git a/src/common/utils/constants/minglar.constant.ts b/src/common/utils/constants/minglar.constant.ts index 0287b62..8c8c639 100644 --- a/src/common/utils/constants/minglar.constant.ts +++ b/src/common/utils/constants/minglar.constant.ts @@ -34,7 +34,10 @@ export const ACTIVITY_TRACK_STATUS = { REJECTED_BY_AM: 'Rejected By AM', ACCEPTED_BY_AM: 'Accepted By AM', ENHANCING: 'Enhancing', - PQ_SUBMITTED: 'PQ Submitted' + PQ_SUBMITTED: 'PQ Submitted', + UNDER_REVIEW:'Under Review', + SUBMITTED:'Activity Submitted', + DRAFT:'Activity Draft' } // export const HOST_SUGGESTION_TITLES = { diff --git a/src/common/utils/validation/host/hostCompanyDetails.validation.ts b/src/common/utils/validation/host/hostCompanyDetails.validation.ts index 0012e4e..a6ca41a 100644 --- a/src/common/utils/validation/host/hostCompanyDetails.validation.ts +++ b/src/common/utils/validation/host/hostCompanyDetails.validation.ts @@ -2,8 +2,8 @@ import { z } from "zod"; export const parentCompanySchema = z.object({ companyName: z.string() - .min(1, "Parent company name is required") - .max(100, "Parent company name cannot exceed 100 characters"), + .max(100, "Parent company name cannot exceed 100 characters") + .optional(), address1: z.string() .max(150, "Address1 cannot exceed 150 characters") @@ -44,7 +44,7 @@ export const parentCompanySchema = z.object({ }), companyTypeXid: z.number() - .min(1, "Company type XID is required"), + .optional(), websiteUrl: z.string().nullable().optional(), instagramUrl: z.string().nullable().optional(), diff --git a/src/modules/host/dto/createActivity.schema.ts b/src/modules/host/dto/createActivity.schema.ts new file mode 100644 index 0000000..98d2874 --- /dev/null +++ b/src/modules/host/dto/createActivity.schema.ts @@ -0,0 +1,162 @@ +import { z } from 'zod'; + +/* ================= MEDIA ================= */ +export const MediaDto = z.object({ + mediaType: z.string().optional(), // "image/jpeg", "video/mp4", etc. + mediaFileName: z.string(), // S3 file URL +}); + +/* ================= PRICE ================= + * ❌ No tax info here; root-level only + */ +export const PriceDto = z.object({ + noOfSession: z.number().int().optional().default(1), + isPackage: z.boolean().optional().default(false), + sessionValidity: z.number().int().optional().default(0), + sessionValidityFrequency: z.string().optional().default('Days'), + basePrice: z.number().int().optional().default(0), + sellPrice: z.number().int(), // required +}); + +/* ================= VENUE ================= */ +export const VenueDto = z.object({ + venueName: z.string(), + venueCapacity: z.number().int().optional().default(0), + availableSeats: z.number().int().optional().default(0), + isMinPeopleReqMandatory: z.boolean().optional().default(false), + minPeopleRequired: z.number().int().nullable().optional(), + minReqfullfilledBeforeMins: z.number().int().nullable().optional(), + venueDescription: z.string().optional(), + + // ✅ new: media per venue (for ActivityVenueArtifacts) + media: z.array(MediaDto).optional().default([]), + + // price list per venue + prices: z.array(PriceDto).optional().default([]), +}); + +/* ================= PICKUP / DROP ================= */ +export const PickupDetailDto = z.object({ + isPickUp: z.boolean().optional().default(false), + locationLat: z.number().nullable().optional(), + locationLong: z.number().nullable().optional(), + locationAddress: z.string().nullable().optional(), + transportBasePrice: z.number().int().optional().default(0), + transportTotalPrice: z.number().int().optional().default(0), +}); + +export const PickupTransportDto = z.object({ + transportModeXid: z.number().int(), + isTransportModeChargeable: z.boolean().optional().default(false), + pickupDetails: z.array(PickupDetailDto).optional().default([]), +}); + +/* ================= EQUIPMENT ================= */ +export const EquipmentDto = z.object({ + equipmentName: z.string(), + isEquipmentChargeable: z.boolean().optional().default(false), + equipmentBasePrice: z.number().int().optional().default(0), + equipmentTotalPrice: z.number().int().optional().default(0), +}); + +/* ================= ELIGIBILITY ================= */ +export const EligibilityDto = z.object({ + isAgeRestriction: z.boolean().optional().default(false), + ageRestrictionXid: z.number().int().nullable().optional(), + + isWeightRestriction: z.boolean().optional().default(false), + weightRestrictionName: z.string().nullable().optional(), + weightEntered: z.number().int().nullable().optional(), + weightIn: z.string().nullable().optional(), + minWeight: z.number().int().nullable().optional(), + maxWeight: z.number().int().nullable().optional(), + + isHeightRestriction: z.boolean().optional().default(false), + heightRestrictionName: z.string().nullable().optional(), + heightEntered: z.number().int().nullable().optional(), + heightIn: z.string().nullable().optional(), + minHeight: z.number().int().nullable().optional(), + maxHeight: z.number().int().nullable().optional(), +}); + +/* ================= OTHER DETAILS ================= */ +export const OtherDetailsDto = z.object({ + exclusiveNotes: z.string().optional(), + dosNotes: z.string().optional(), + dontsNotes: z.string().optional(), + tipsNotes: z.string().optional(), + termsAndCondition: z.string().optional(), +}); + +/* ================= CREATE ACTIVITY ================= */ +export const CreateActivityDto = z.object({ + /* 🔑 REQUIRED */ + activityXid: z.number().int(), + + /* OPTIONAL CORE */ + activityTypeXid: z.number().int().optional(), + frequenciesXid: z.number().int().nullable().optional(), + activityTitle: z.string().optional(), + activityDescription: z.string().optional(), + + /* LOCATION */ + checkInLat: z.number().nullable().optional(), + checkInLong: z.number().nullable().optional(), + checkInAddress: z.string().nullable().optional(), + isCheckOutSame: z.boolean().optional().default(true), + checkOutLat: z.number().nullable().optional(), + checkOutLong: z.number().nullable().optional(), + checkOutAddress: z.string().nullable().optional(), + + /* DURATION / ENERGY */ + energyLevelXid: z.number().int().nullable().optional(), + activityDurationMins: z.number().int().nullable().optional(), + durationHours: z.number().int().optional(), + durationMins: z.number().int().optional(), + + /* FLAGS */ + foodAvailable: z.boolean().optional().default(false), + foodIsChargeable: z.boolean().optional().default(false), + alcoholAvailable: z.boolean().optional().default(false), + + trainerAvailable: z.boolean().optional().default(false), + trainerIsChargeable: z.boolean().optional().default(false), + + pickUpDropAvailable: z.boolean().optional().default(false), + pickUpDropIsChargeable: z.boolean().optional().default(false), + + inActivityAvailable: z.boolean().optional().default(false), + inActivityIsChargeable: z.boolean().optional().default(false), + + equipmentAvailable: z.boolean().optional().default(false), + equipmentIsChargeable: z.boolean().optional().default(false), + + cancellationAvailable: z.boolean().optional().default(false), + + /* MONEY / CURRENCY */ + currencyXid: z.number().int().nullable().optional(), + sustainabilityScore: z.number().int().nullable().optional(), + safetyScore: z.number().int().nullable().optional(), + isInstantBooking: z.boolean().optional().default(false), + + /* 🔥 ROOT-LEVEL TAX (SINGLE SOURCE OF TRUTH) */ + taxXids: z.array(z.number().int()).optional().default([]), + + /* 🔥 MEDIA ARRAYS */ + media: z.array(MediaDto).optional().default([]), // Activity-level media + venues: z.array(VenueDto).optional().default([]), // Each venue’s media + prices + + /* RELATION ARRAYS */ + foodTypeIds: z.array(z.number().int()).optional().default([]), + cuisineIds: z.array(z.number().int()).optional().default([]), + pickupTransports: z.array(PickupTransportDto).optional().default([]), + navigationModes: z.array(z.number().int()).optional().default([]), + equipments: z.array(EquipmentDto).optional().default([]), + amenitiesIds: z.array(z.number().int()).optional().default([]), + + /* EXTRA OBJECTS */ + eligibility: EligibilityDto.optional(), + otherDetails: OtherDetailsDto.optional(), +}); + +export type CreateActivityInput = z.infer; diff --git a/src/modules/host/handlers/Activity_Hub/OnBoarding/CreateNewActivity.ts b/src/modules/host/handlers/Activity_Hub/OnBoarding/CreateNewActivity.ts new file mode 100644 index 0000000..cd77d0e --- /dev/null +++ b/src/modules/host/handlers/Activity_Hub/OnBoarding/CreateNewActivity.ts @@ -0,0 +1,311 @@ +import config from '../../../../../config/config'; +import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import AWS from 'aws-sdk'; +import Busboy from 'busboy'; +import { prismaClient } from '../../../../../common/database/prisma.lambda.service'; +import { verifyHostToken } from '../../../../../common/middlewares/jwt/authForHost'; +import { safeHandler } from '../../../../../common/utils/handlers/safeHandler'; +import ApiError from '../../../../../common/utils/helper/ApiError'; +import { + CreateActivityDto, + CreateActivityInput, +} from '../../../dto/createActivity.schema'; +import { HostService } from '../../../services/host.service'; + +const hostService = new HostService(prismaClient); +const s3 = new AWS.S3({ region: config.aws.region }); + +/* ------------------------------- Utilities ------------------------------- */ + +function getExtensionFromMime(mimeType: string) { + const map: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + 'video/mp4': 'mp4', + 'video/quicktime': 'mov', + 'video/x-msvideo': 'avi', + 'video/x-matroska': 'mkv', + }; + return map[mimeType] || 'bin'; +} + +function normalizeJsonField(fields: any, key: string) { + if (!fields[key]) return undefined; + if (typeof fields[key] === 'object') return fields[key]; + + try { + return JSON.parse(fields[key]); + } catch { + throw new ApiError(400, `Invalid JSON in field: ${key}`); + } +} + +/* -------------------------------- Handler -------------------------------- */ + +export const handler = safeHandler( + async (event: APIGatewayProxyEvent): Promise => { + /* 1️⃣ AUTH */ + const token = + event.headers['x-auth-token'] || event.headers['X-Auth-Token']; + if (!token) { + throw new ApiError( + 401, + 'This is a protected route. Please provide a valid token.', + ); + } + + const userInfo = await verifyHostToken(token); + + /* 2️⃣ CONTENT TYPE */ + const contentType = + event.headers['content-type'] || event.headers['Content-Type']; + if (!contentType?.includes('multipart/form-data')) { + throw new ApiError(400, 'Content-Type must be multipart/form-data'); + } + + /* 3️⃣ BODY BUFFER */ + const bodyBuffer = event.isBase64Encoded + ? Buffer.from(event.body as string, 'base64') + : Buffer.from(event.body as string); + + const fields: Record = {}; + const files: Array<{ + buffer: Buffer; + mimeType: string; + fileName: string; + fieldName: string; + }> = []; + + await new Promise((resolve, reject) => { + const bb = Busboy({ + headers: { + ...event.headers, + 'content-type': contentType, + }, + }); + + bb.on('field', (name, value) => { + fields[name] = value; + }); + + bb.on('file', (fieldName, file, info) => { + const { filename, mimeType } = info; + const chunks: Buffer[] = []; + let size = 0; + const MAX_SIZE = 5 * 1024 * 1024; + + file.on('data', (chunk) => { + size += chunk.length; + if (size > MAX_SIZE) { + file.destroy(new Error('File exceeds 5MB limit')); + return; + } + chunks.push(chunk); + }); + + file.on('end', () => { + if (chunks.length > 0) { + files.push({ + buffer: Buffer.concat(chunks), + mimeType: mimeType || 'application/octet-stream', + fileName: filename || 'unknown', + fieldName, + }); + } + }); + }); + + bb.on('finish', () => resolve()); + bb.on('error', (err) => reject(new ApiError(400, err.message))); + + bb.end(bodyBuffer); + }); + + /* 4️⃣ FLAGS */ + const isDraft = fields.isDraft === 'true' || fields.isDraft === true; + + /* 5️⃣ ACTIVITY PAYLOAD */ + const activityPayload: any = normalizeJsonField(fields, 'activity'); + if (!activityPayload) { + throw new ApiError(400, 'activity payload is required'); + } + + /* 6️⃣ NORMALIZE IDS */ + if (activityPayload.activityXid) { + activityPayload.activityXid = Number(activityPayload.activityXid); + } + + const numberKeys = [ + 'currencyXid', + 'energyLevelXid', + 'activityDurationMins', + 'activityTypeXid', + 'frequenciesXid', + 'trainerTotalAmount', + 'pickupDropTotalPrice', + 'navigationModeTotalPrice', + 'sustainabilityScore', + 'safetyScore', + 'checkInLat', + 'checkInLong', + 'checkOutLat', + 'checkOutLong', + ]; + + for (const key of numberKeys) { + if (activityPayload[key] !== undefined && activityPayload[key] !== null && activityPayload[key] !== '') { + activityPayload[key] = Number(activityPayload[key]); + } + } + + /* 7️⃣ NORMALIZE BOOLEANS */ + const booleanKeys = [ + 'isInstantBooking', + 'foodAvailable', + 'foodIsChargeable', + 'alcoholAvailable', + 'trainerAvailable', + 'trainerIsChargeable', + 'pickUpDropAvailable', + 'pickUpDropIsChargeable', + 'inActivityAvailable', + 'inActivityIsChargeable', + 'equipmentAvailable', + 'equipmentIsChargeable', + 'cancellationAvailable', + 'isCheckOutSame', + ]; + + for (const key of booleanKeys) { + if (activityPayload[key] === 'true') activityPayload[key] = true; + if (activityPayload[key] === 'false') activityPayload[key] = false; + } + + /* 8️⃣ UPLOAD ACTIVITY-LEVEL MEDIA (images/videos) */ + const uploadedActivityMedia: Array<{ mediaType?: string; mediaFileName: string }> = []; + + for (const file of files.filter( + (f) => f.fieldName === 'activityImages' || f.fieldName === 'activityVideos', + )) { + const s3Key = `ActivityOnboarding/Activity_${activityPayload.activityXid}/Media/${Date.now()}_${file.fileName}`; + + if (s3Key.length > 900) { + throw new ApiError(400, 'Generated S3 key too long'); + } + + await s3 + .upload({ + Bucket: config.aws.bucketName, + Key: s3Key, + Body: file.buffer, + ContentType: file.mimeType, + ACL: 'private', + }) + .promise(); + + uploadedActivityMedia.push({ + mediaType: file.mimeType, + mediaFileName: `https://${config.aws.bucketName}.s3.${config.aws.region}.amazonaws.com/${s3Key}`, + }); + } + + /* 🔥 MERGE ACTIVITY MEDIA */ + const existingMedia = Array.isArray(activityPayload.media) + ? activityPayload.media + : []; + activityPayload.media = [...existingMedia, ...uploadedActivityMedia]; + + /* 9️⃣ PROCESS VENUE MEDIA UPLOADS */ + // Group venue files by index: venueImages[0], venueImages[1], etc. + const venueFilesMap: Map> = new Map(); + + for (const file of files) { + // Match patterns like: venueImages[0], venueVideos[1], etc. + const match = file.fieldName.match(/^venue(Images|Videos)\[(\d+)\]$/); + if (match) { + const venueIndex = parseInt(match[2], 10); + if (!venueFilesMap.has(venueIndex)) { + venueFilesMap.set(venueIndex, []); + } + venueFilesMap.get(venueIndex)!.push(file); + } + } + + // Upload venue files and attach to corresponding venues + if (Array.isArray(activityPayload.venues)) { + for (let i = 0; i < activityPayload.venues.length; i++) { + const venue = activityPayload.venues[i]; + const venueFiles = venueFilesMap.get(i) || []; + + const uploadedVenueMedia: Array<{ mediaType?: string; mediaFileName: string }> = []; + + for (const file of venueFiles) { + const s3Key = `ActivityOnboarding/Activity_${activityPayload.activityXid}/Venue_${i}/Media/${Date.now()}_${file.fileName}`; + + if (s3Key.length > 900) { + throw new ApiError(400, 'Generated S3 key too long for venue media'); + } + + await s3 + .upload({ + Bucket: config.aws.bucketName, + Key: s3Key, + Body: file.buffer, + ContentType: file.mimeType, + ACL: 'private', + }) + .promise(); + + uploadedVenueMedia.push({ + mediaType: file.mimeType, + mediaFileName: `https://${config.aws.bucketName}.s3.${config.aws.region}.amazonaws.com/${s3Key}`, + }); + } + + // Merge with existing venue media + const existingVenueMedia = Array.isArray(venue.media) ? venue.media : []; + venue.media = [...existingVenueMedia, ...uploadedVenueMedia]; + } + } + + /* 🔟 VALIDATION */ + let parsedDto: CreateActivityInput; + + if (!isDraft) { + const parsed = CreateActivityDto.safeParse(activityPayload); + if (!parsed.success) { + throw new ApiError( + 400, + parsed.error.issues.map((i) => i.message).join(', '), + ); + } + parsedDto = parsed.data; + } else { + parsedDto = activityPayload as CreateActivityInput; + } + + /* 1️⃣1️⃣ SAVE ACTIVITY */ + const createdActivity = await hostService.createOrUpdateActivity( + userInfo.id, + parsedDto, + isDraft, + ); + + /* 1️⃣2️⃣ RESPONSE */ + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + success: true, + message: isDraft + ? 'Activity saved as draft successfully' + : 'Activity created successfully', + data: createdActivity, + }), + }; + }, +); \ No newline at end of file diff --git a/src/modules/host/handlers/Host_Admin/onboarding/submitCompanyDetails.ts b/src/modules/host/handlers/Host_Admin/onboarding/submitCompanyDetails.ts index de59512..c3b8493 100644 --- a/src/modules/host/handlers/Host_Admin/onboarding/submitCompanyDetails.ts +++ b/src/modules/host/handlers/Host_Admin/onboarding/submitCompanyDetails.ts @@ -17,6 +17,16 @@ import { sendEmailToAM, sendEmailToMinglarAdmin } from '../../../services/sendHo const hostService = new HostService(prismaClient); +function getExtensionFromMime(mimeType: string) { + const map: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'application/pdf': 'pdf', + 'image/webp': 'webp', + }; + return map[mimeType] || 'bin'; +} + const s3 = new AWS.S3({ region: config.aws.region, }); @@ -149,6 +159,15 @@ export const handler = safeHandler(async (event: APIGatewayProxyEvent): Promise< } } + if ( + companyDetailsRaw.parentCompany && + Object.values(companyDetailsRaw.parentCompany).every( + (v) => v === undefined || v === null + ) + ) { + companyDetailsRaw.parentCompany = null; + } + /** 6) Profile update if provided */ if (fields.userProfile) { const userProfileRaw = normalizeJsonField(fields, 'userProfile'); @@ -284,11 +303,10 @@ export const handler = safeHandler(async (event: APIGatewayProxyEvent): Promise< } } - - /** 11) UPLOAD DOCUMENTS */ async function uploadToS3(buffer, mimeType, originalName, folderType, documentTypeXid?, fieldName?) { - const ext = originalName.split('.').pop() || 'jpg'; + // const ext = originalName.split('.').pop() || 'jpg'; + const ext = getExtensionFromMime(mimeType); let s3Key = ''; @@ -362,31 +380,67 @@ export const handler = safeHandler(async (event: APIGatewayProxyEvent): Promise< } /** UPLOAD LOGO (if provided) */ - const logoFile = files.find((f) => f.fieldName === 'companyLogo' || f.fieldName === 'companyLogoFile'); - if (logoFile) { - const logoUrl = await uploadToS3(logoFile.buffer, logoFile.mimeType, logoFile.fileName, 'logo'); + const logoFile = files.find( + (f) => f.fieldName === 'companyLogo' || f.fieldName === 'companyLogoFile' + ); + + if (logoFile && logoFile.buffer && logoFile.fileName) { + const logoUrl = await uploadToS3( + logoFile.buffer, + logoFile.mimeType, + logoFile.fileName, + 'logo' + ); parsedCompany.logoPath = logoUrl; } /** UPLOAD PARENT COMPANY LOGO (if provided) */ - const parentLogoFile = files.find((f) => f.fieldName === 'parentCompanyLogo'); - if (parentLogoFile) { + const parentLogoFile = files.find( + (f) => f.fieldName === 'parentCompanyLogo' + ); + + if (parentLogoFile && parentLogoFile.buffer && parentLogoFile.mimeType) { + // 🔒 Only upload when an actual file is present const parentLogoUrl = await uploadToS3( parentLogoFile.buffer, parentLogoFile.mimeType, - parentLogoFile.fileName, + parentLogoFile.fileName, // safe here because it's a real file 'parent_company_logo', ); if (parsedParentCompany) { parsedParentCompany.logoPath = parentLogoUrl; } else { - // if no parent object exists yet (drafts or other flows), attach it safely - parsedParentCompany = parsedParentCompany || {}; - parsedParentCompany.logoPath = parentLogoUrl; + parsedParentCompany = { + logoPath: parentLogoUrl, + }; } } + if (parsedCompany.cityXid) { + const city = await prismaClient.cities.findUnique({ + where: { id: Number(parsedCompany.cityXid) } + }); + if (!city) { + throw new ApiError(400, `City with ID ${parsedCompany.cityXid} not found`); + } + } + + if (!parsedCompany.isSubsidairy) { + const parentDocuments = await hostService.getParentDocumentsByHostId(userInfo.id); + if (parentDocuments.length > 0) { + for (const doc of parentDocuments) { + try { + const s3Key = getS3KeyFromUrl(doc.filePath); + await deleteFromS3(s3Key); + } catch (e) { + console.error("S3 delete failed:", doc.filePath, e); + } + } + } + await hostService.deleteExistingParentRecords(userInfo.id) + } + /** 12) SAVE / UPDATE HOST ENTRY */ const createdOrUpdated = await hostService.addOrUpdateCompanyDetails( userInfo.id, diff --git a/src/modules/host/handlers/Host_Admin/onboarding/updateBankDetails.ts b/src/modules/host/handlers/Host_Admin/onboarding/updateBankDetails.ts index edfabfe..0853bd7 100644 --- a/src/modules/host/handlers/Host_Admin/onboarding/updateBankDetails.ts +++ b/src/modules/host/handlers/Host_Admin/onboarding/updateBankDetails.ts @@ -43,7 +43,7 @@ export const handler = safeHandler(async ( // ✅ Validate payload using Zod const validationResult = hostBankDetailsSchema.safeParse({ ...(body as object), - hostXid: host.id, // inject hostId from token (not from user input) + hostXid: host.host.id, // inject hostId from token (not from user input) }); if (!validationResult.success) { diff --git a/src/modules/host/handlers/getStepper.ts b/src/modules/host/handlers/getStepper.ts index 38e3b63..8f9bc19 100644 --- a/src/modules/host/handlers/getStepper.ts +++ b/src/modules/host/handlers/getStepper.ts @@ -27,10 +27,6 @@ export const handler = safeHandler(async ( // Fetch user with their HostHeader stepper info const host = await hostService.getHostIdByUserXid(userId); - if (!host) { - throw new ApiError(404, 'Host record not found'); - } - return { statusCode: 200, headers: { @@ -41,7 +37,8 @@ export const handler = safeHandler(async ( success: true, message: 'Stepper information retrieved successfully', data: { - stepper: host.stepper, + stepper: host?.host?.stepper || null, + emailAddress: host.user?.emailAddress || null, }, }), }; diff --git a/src/modules/host/services/host.service.ts b/src/modules/host/services/host.service.ts index d7f269e..20a9266 100644 --- a/src/modules/host/services/host.service.ts +++ b/src/modules/host/services/host.service.ts @@ -14,7 +14,9 @@ import { hostCompanyDetailsSchema } from '../../../common/utils/validation/host/ import { ACTIVITY_AM_DISPLAY_STATUS, ACTIVITY_AM_INTERNAL_STATUS, - ACTIVITY_DISPLAY_STATUS, ACTIVITY_INTERNAL_STATUS, HOST_STATUS_DISPLAY, + ACTIVITY_DISPLAY_STATUS, + ACTIVITY_INTERNAL_STATUS, + HOST_STATUS_DISPLAY, HOST_STATUS_INTERNAL, STEPPER, } from '../../../common/utils/constants/host.constant'; @@ -32,6 +34,14 @@ import { import { getPresignedUrl } from '../../../common/middlewares/aws/getPreSignedUrl'; import config from '../../../config/config'; +function sanitizeDocumentName(name?: string) { + if (!name) return null; + + return name + .replace(/[^a-zA-Z0-9 _-]/g, '') // remove / . + .substring(0, 100); +} + type HostCompanyDetailsInput = z.infer; // Document input after S3 upload (with S3 URL as filePath) @@ -52,18 +62,50 @@ export async function generateActivityRefNumber(tx: any) { const nextId = lastrecord ? lastrecord.id + 1 : 1; - return `ACT-${String(nextId).padStart(6, '0')}`;; + return `ACT-${String(nextId).padStart(6, '0')}`; } function round2(value: number) { return Math.round(value); } +function computeBasePriceAndTaxes( + sellPrice: number, + taxes: Array<{ id: number; taxPer: number }>, +) { + if (!taxes?.length) { + return { + basePrice: round2(sellPrice), + taxDetails: [] as Array<{ + taxXid: number; + taxPer: number; + taxAmount: number; + }>, + }; + } + + const totalTaxPer = taxes.reduce( + (sum, t) => sum + (Number(t.taxPer) || 0), + 0, + ); + const denominator = 1 + totalTaxPer / 100; + const basePrice = + denominator > 0 ? round2(sellPrice / denominator) : round2(sellPrice); + + const taxDetails = taxes.map((t) => ({ + taxXid: t.id, + taxPer: t.taxPer, + taxAmount: round2(basePrice * (t.taxPer / 100)), + })); + + return { basePrice, taxDetails }; +} + const bucket = config.aws.bucketName; @Injectable() export class HostService { - constructor(private prisma: PrismaClient) { } + constructor(private prisma: PrismaClient) {} async createHost(data: CreateHostDto) { return this.prisma.user.create({ data }); @@ -76,9 +118,14 @@ export class HostService { async getHostIdByUserXid(user_xid: number) { const host = await this.prisma.hostHeader.findFirst({ where: { userXid: user_xid }, - select: { id: true, companyName: true, countryXid: true, stepper: true }, + select: { id: true, stepper: true }, }); - return host; + + const user = await this.prisma.user.findUnique({ + where: { id: user_xid }, + select: { id: true, emailAddress: true }, + }); + return { host, user }; } async getHostById(id: number) { @@ -93,10 +140,10 @@ export class HostService { filePath: true, documentName: true, documentTypeXid: true, - documentType: true - } - } - } + documentType: true, + }, + }, + }, }, HostBankDetails: true, HostDocuments: { @@ -113,7 +160,7 @@ export class HostService { mobileNumber: true, profileImage: true, userRefNumber: true, - } + }, }, user: { select: { @@ -125,7 +172,7 @@ export class HostService { profileImage: true, userStatus: true, userRefNumber: true, - } + }, }, companyTypes: { select: { @@ -144,7 +191,7 @@ export class HostService { title: true, comments: true, isparent: true, - } + }, }, countries: true, currencies: true, @@ -160,7 +207,6 @@ export class HostService { } if (host.HostDocuments?.length) { - for (const doc of host.HostDocuments) { if (doc.filePath) { const filePath = doc.filePath; @@ -175,8 +221,8 @@ export class HostService { } } if (host.user?.profileImage) { - const key = host.user.profileImage.startsWith("http") - ? host.user.profileImage.split(".com/")[1] + const key = host.user.profileImage.startsWith('http') + ? host.user.profileImage.split('.com/')[1] : host.user.profileImage; host.user.profileImage = await getPresignedUrl(bucket, key); @@ -203,8 +249,8 @@ export class HostService { // Parent company logo if (parent.logoPath) { - const key = parent.logoPath.startsWith("http") - ? parent.logoPath.split(".com/")[1] + const key = parent.logoPath.startsWith('http') + ? parent.logoPath.split('.com/')[1] : parent.logoPath; parent.logoPath = await getPresignedUrl(bucket, key); @@ -214,8 +260,8 @@ export class HostService { if (parent.HostParenetDocuments?.length) { for (const doc of parent.HostParenetDocuments) { if (doc.filePath) { - const key = doc.filePath.startsWith("http") - ? doc.filePath.split(".com/")[1] + const key = doc.filePath.startsWith('http') + ? doc.filePath.split('.com/')[1] : doc.filePath; (doc as any).presignedUrl = await getPresignedUrl(bucket, key); @@ -290,21 +336,27 @@ export class HostService { async loginForHost(emailAddress: string, userPassword: string) { const existingUser = await this.prisma.user.findUnique({ - where: { emailAddress: emailAddress }, + where: { emailAddress: emailAddress, isActive: true }, select: { id: true, roleXid: true, + firstName: true, + lastName: true, + emailAddress: true, + mobileNumber: true, userPassword: true, - userStatus: true - } + userStatus: true, + }, }); - console.log(existingUser, "ajsbfkjd") if (!existingUser) { throw new ApiError(404, 'User not found'); } if (existingUser.userStatus == USER_STATUS.REJECTED) { - throw new ApiError(403, "You are not allowed to login. Please contact minglar admin.") + throw new ApiError( + 403, + 'You are not allowed to login. Please contact minglar admin.', + ); } if (existingUser.roleXid !== 4) { @@ -388,7 +440,7 @@ export class HostService { if (existingAccount) { throw new ApiError( 400, - 'Host account with this account number already exists.' + 'Host account with this account number already exists.', ); } const addedPaymentDetails = await tx.hostBankDetails.create({ @@ -403,16 +455,20 @@ export class HostService { where: { id: data.hostXid }, data: { stepper: STEPPER.BANK_DETAILS_UPDATED, - currencyXid: data.currencyXid + currencyXid: data.currencyXid, }, }); }); } - async getAllHostActivity(search?: string, user_xid?: number, paginationOptions?: { page: number; limit: number; skip: number }) { + async getAllHostActivity( + search?: string, + user_xid?: number, + paginationOptions?: { page: number; limit: number; skip: number }, + ) { const hostDetails = await this.prisma.hostHeader.findFirst({ - where: { userXid: user_xid, isActive: true } - }) + where: { userXid: user_xid, isActive: true }, + }); const whereClause: any = { isActive: true, @@ -423,7 +479,7 @@ export class HostService { data: [], total: 0, page: paginationOptions?.page || 1, - limit: paginationOptions?.limit || 10 + limit: paginationOptions?.limit || 10, }; } @@ -436,8 +492,8 @@ export class HostService { { activityTitle: { contains: term, mode: 'insensitive' } }, { activityType: { - activityTypeName: { contains: term, mode: 'insensitive' } - } + activityTypeName: { contains: term, mode: 'insensitive' }, + }, }, ]; } @@ -459,8 +515,8 @@ export class HostService { frequency: { select: { id: true, - frequencyName: true - } + frequencyName: true, + }, }, ActivityAmDetails: { select: { @@ -483,10 +539,10 @@ export class HostService { interests: { select: { id: true, - interestName: true - } - } - } + interestName: true, + }, + }, + }, }, }, skip: paginationOptions?.skip || 0, @@ -501,8 +557,8 @@ export class HostService { const am = activity.ActivityAmDetails?.[0]?.accountManager; if (am?.profileImage) { - const key = am.profileImage.startsWith("http") - ? am.profileImage.split(".com/")[1] + const key = am.profileImage.startsWith('http') + ? am.profileImage.split('.com/')[1] : am.profileImage; const presignedUrl = await getPresignedUrl(bucket, key); @@ -514,11 +570,13 @@ export class HostService { } } - const { paginationService } = require('@/common/utils/pagination/pagination.service'); + const { + paginationService, + } = require('@/common/utils/pagination/pagination.service'); return paginationService.createPaginatedResponse( hostAllActivities, totalCount, - paginationOptions || { page: 1, limit: 10, skip: 0 } + paginationOptions || { page: 1, limit: 10, skip: 0 }, ); } @@ -555,8 +613,8 @@ export class HostService { id: true, activityPqqHeaderXid: true, mediaFileName: true, - mediaType: true - } + mediaType: true, + }, }, ActivityPQQSuggestions: { where: { isActive: true, isReviewed: false }, @@ -564,13 +622,12 @@ export class HostService { id: true, title: true, comments: true, - } + }, }, }, }); if (detailsOfQuestion.ActivityPQQSupportings?.length) { - for (const doc of detailsOfQuestion.ActivityPQQSupportings) { if (doc.mediaFileName) { const filePath = doc.mediaFileName; @@ -594,8 +651,8 @@ export class HostService { activityXid: activity_xid, isActive: true, pqqAnswerXid: { - not: null - } + not: null, + }, }, select: { pqqQuestionXid: true, @@ -615,6 +672,50 @@ export class HostService { }); } + async getParentDocumentsByHostId(userId: number) { + const host = await this.prisma.hostHeader.findFirst({ + where: { userXid: userId }, + select: { id: true }, + }); + + if (!host) return []; + + const parents = await this.prisma.hostParent.findMany({ + where: { hostXid: host.id }, + include: { HostParenetDocuments: true }, + }); + + return parents.flatMap((p) => p.HostParenetDocuments); + } + + async deleteExistingParentRecords(userId: number) { + const host = await this.prisma.hostHeader.findFirst({ + where: { userXid: userId }, + select: { id: true }, + }); + + if (!host) return; + + const parents = await this.prisma.hostParent.findMany({ + where: { hostXid: host.id }, + select: { id: true }, + }); + + if (!parents.length) return; + + const parentIds = parents.map((p) => p.id); + + // 1️⃣ Delete documents first + await this.prisma.hostParenetDocuments.deleteMany({ + where: { hostParentXid: { in: parentIds } }, + }); + + // 2️⃣ Then delete parent records + await this.prisma.hostParent.deleteMany({ + where: { id: { in: parentIds } }, + }); + } + async addOrUpdateCompanyDetails( user_xid: number, companyData: HostCompanyDetailsInput, @@ -629,6 +730,7 @@ export class HostService { where: { userXid: user_xid }, include: { hostParent: true }, }); + console.log(existingHostCompany, '-: Existing hai'); let existingParentCompany; @@ -637,9 +739,9 @@ export class HostService { where: { hostXid: existingHostCompany.id }, select: { id: true, - logoPath: true - } - }) + logoPath: true, + }, + }); } let hostStatusInternal; @@ -657,7 +759,8 @@ export class HostService { // CASE 1: Host was asked to update AND is submitting final if ( existingHostCompany && - existingHostCompany.hostStatusInternal === HOST_STATUS_INTERNAL.HOST_TO_UPDATE && + existingHostCompany.hostStatusInternal === + HOST_STATUS_INTERNAL.HOST_TO_UPDATE && !isDraft ) { hostStatusInternal = HOST_STATUS_INTERNAL.HOST_SUBMITTED; @@ -669,7 +772,8 @@ export class HostService { // CASE 2: Host was asked to update BUT saving draft else if ( existingHostCompany && - existingHostCompany.hostStatusInternal === HOST_STATUS_INTERNAL.HOST_TO_UPDATE && + existingHostCompany.hostStatusInternal === + HOST_STATUS_INTERNAL.HOST_TO_UPDATE && isDraft ) { // keep original @@ -704,12 +808,17 @@ export class HostService { // ------------------------------------------------------- if (!existingHostCompany) { if (!isDraft) { + console.log('First time direct final submit.'); const existingByPan = await tx.hostHeader.findFirst({ where: { panNumber: companyData.panNumber }, }); if (existingByPan) - throw new ApiError(400, 'Company already exists with this pan/bin number'); + throw new ApiError( + 400, + 'Company already exists with this pan/bin number', + ); } + console.log('First Time Aaya hai'); const createdHost = await tx.hostHeader.create({ data: { @@ -717,11 +826,17 @@ export class HostService { companyName: companyData.companyName, address1: companyData.address1, address2: companyData.address2, - cities: companyData.cityXid ? { connect: { id: companyData.cityXid } } : undefined, - states: companyData.stateXid ? { connect: { id: companyData.stateXid } } : undefined, - countries: companyData.countryXid ? { connect: { id: companyData.countryXid } } : undefined, + cities: companyData.cityXid + ? { connect: { id: companyData.cityXid } } + : undefined, + states: companyData.stateXid + ? { connect: { id: companyData.stateXid } } + : undefined, + countries: companyData.countryXid + ? { connect: { id: companyData.countryXid } } + : undefined, pinCode: companyData.pinCode, - logoPath: companyData.logoPath || existingHostCompany.logoPath, + logoPath: companyData.logoPath || null, isSubsidairy: companyData.isSubsidairy, registrationNumber: companyData.registrationNumber, panNumber: companyData.panNumber, @@ -751,7 +866,7 @@ export class HostService { const docsData = documents.map((doc) => ({ hostXid: createdHost.id, documentTypeXid: doc.documentTypeXid, - documentName: doc.documentName, + documentName: sanitizeDocumentName(doc.documentName), filePath: doc.filePath, })); await tx.hostDocuments.createMany({ data: docsData }); @@ -759,23 +874,33 @@ export class HostService { // parent create if (companyData.isSubsidairy && parentCompanyData) { + console.log('Parent ke saath aaya hai first time.'); const createdParent = await tx.hostParent.create({ data: { host: { connect: { id: createdHost.id } }, - companyName: parentCompanyData.companyName, + companyName: parentCompanyData.companyName || null, address1: parentCompanyData.address1 || null, address2: parentCompanyData.address2 || null, - cities: parentCompanyData.cityXid - ? { connect: { id: parentCompanyData.cityXid } } - : undefined, - states: parentCompanyData.stateXid - ? { connect: { id: parentCompanyData.stateXid } } - : undefined, - countries: parentCompanyData.countryXid - ? { connect: { id: parentCompanyData.countryXid } } - : undefined, + // Safely handle city connection - only connect if valid ID exists + cities: + parentCompanyData?.cityXid && + !isNaN(Number(parentCompanyData.cityXid)) + ? { connect: { id: Number(parentCompanyData.cityXid) } } + : undefined, + + states: + parentCompanyData?.stateXid && + !isNaN(Number(parentCompanyData.stateXid)) + ? { connect: { id: Number(parentCompanyData.stateXid) } } + : undefined, + + countries: + parentCompanyData?.countryXid && + !isNaN(Number(parentCompanyData.countryXid)) + ? { connect: { id: Number(parentCompanyData.countryXid) } } + : undefined, pinCode: parentCompanyData.pinCode || null, - logoPath: parentCompanyData.logoPath || existingParentCompany.logoPath, + logoPath: parentCompanyData.logoPath || null, registrationNumber: parentCompanyData.registrationNumber || null, panNumber: parentCompanyData.panNumber || null, gstNumber: parentCompanyData.gstNumber || null, @@ -798,7 +923,7 @@ export class HostService { const parentDocsData = parentDocuments.map((doc) => ({ hostParentXid: createdParent.id, documentTypeXid: doc.documentTypeXid, - documentName: doc.documentName, + documentName: sanitizeDocumentName(doc.documentName), filePath: doc.filePath, })); await tx.hostParenetDocuments.createMany({ data: parentDocsData }); @@ -827,9 +952,23 @@ export class HostService { companyName: companyData.companyName, address1: companyData.address1, address2: companyData.address2, - cities: companyData.cityXid ? { connect: { id: companyData.cityXid } } : undefined, - states: companyData.stateXid ? { connect: { id: companyData.stateXid } } : undefined, - countries: companyData.countryXid ? { connect: { id: companyData.countryXid } } : undefined, + // Safely handle city connection - only connect if valid ID exists + cities: + companyData.cityXid && !isNaN(Number(companyData.cityXid)) + ? { connect: { id: Number(companyData.cityXid) } } + : undefined, // Don't change if not provided + + // Same for state + states: + companyData.stateXid && !isNaN(Number(companyData.stateXid)) + ? { connect: { id: Number(companyData.stateXid) } } + : undefined, + + // Same for country + countries: + companyData.countryXid && !isNaN(Number(companyData.countryXid)) + ? { connect: { id: Number(companyData.countryXid) } } + : undefined, pinCode: companyData.pinCode, logoPath: companyData.logoPath || existingHostCompany.logoPath, isSubsidairy: companyData.isSubsidairy, @@ -859,6 +998,7 @@ export class HostService { // documents UPSERT if (documents?.length) { for (const doc of documents) { + if (!doc.filePath) continue; const existingDoc = await tx.hostDocuments.findFirst({ where: { hostXid: updatedHost.id, @@ -871,7 +1011,9 @@ export class HostService { where: { id: existingDoc.id }, data: { filePath: doc.filePath, - documentName: doc.documentName || existingDoc.documentName, + documentName: + sanitizeDocumentName(doc.documentName) || + existingDoc.documentName, }, }); } else { @@ -879,7 +1021,7 @@ export class HostService { data: { hostXid: updatedHost.id, documentTypeXid: doc.documentTypeXid, - documentName: doc.documentName, + documentName: sanitizeDocumentName(doc.documentName), filePath: doc.filePath, }, }); @@ -890,26 +1032,40 @@ export class HostService { // parent logic untouched if (companyData.isSubsidairy) { const parentRecords = existingHostCompany.hostParent; - const parentRecord = Array.isArray(parentRecords) ? parentRecords[0] : parentRecords; - + const parentRecord = Array.isArray(parentRecords) + ? parentRecords[0] + : parentRecords; + console.log('Yaha aaya update in the apretn me'); if (!parentRecord) { + console.log('Parent record nahi mila to create kar raha hai.'); const createdParent = await tx.hostParent.create({ data: { host: { connect: { id: updatedHost.id } }, - companyName: parentCompanyData.companyName, + companyName: parentCompanyData.companyName || null, address1: parentCompanyData.address1 || null, address2: parentCompanyData.address2 || null, - cities: parentCompanyData.cityXid - ? { connect: { id: parentCompanyData.cityXid } } - : undefined, - states: parentCompanyData.stateXid - ? { connect: { id: parentCompanyData.stateXid } } - : undefined, - countries: parentCompanyData.countryXid - ? { connect: { id: parentCompanyData.countryXid } } - : undefined, + cities: + parentCompanyData?.cityXid && + !isNaN(Number(parentCompanyData.cityXid)) + ? { connect: { id: Number(parentCompanyData.cityXid) } } + : undefined, + + states: + parentCompanyData?.stateXid && + !isNaN(Number(parentCompanyData.stateXid)) + ? { connect: { id: Number(parentCompanyData.stateXid) } } + : undefined, + + countries: + parentCompanyData?.countryXid && + !isNaN(Number(parentCompanyData.countryXid)) + ? { connect: { id: Number(parentCompanyData.countryXid) } } + : undefined, pinCode: parentCompanyData.pinCode || null, - logoPath: parentCompanyData.logoPath || existingParentCompany.logoPath, + logoPath: + parentCompanyData?.logoPath || + existingParentCompany?.logoPath || + null, registrationNumber: parentCompanyData.registrationNumber || null, panNumber: parentCompanyData.panNumber || null, gstNumber: parentCompanyData.gstNumber || null, @@ -933,7 +1089,7 @@ export class HostService { data: { hostParentXid: createdParent.id, documentTypeXid: doc.documentTypeXid, - documentName: doc.documentName, + documentName: sanitizeDocumentName(doc.documentName), filePath: doc.filePath, }, }); @@ -943,20 +1099,31 @@ export class HostService { await tx.hostParent.update({ where: { id: parentRecord.id }, data: { - companyName: parentCompanyData.companyName, + companyName: parentCompanyData.companyName || null, address1: parentCompanyData.address1 || null, address2: parentCompanyData.address2 || null, - cities: parentCompanyData.cityXid - ? { connect: { id: parentCompanyData.cityXid } } - : undefined, - states: parentCompanyData.stateXid - ? { connect: { id: parentCompanyData.stateXid } } - : undefined, - countries: parentCompanyData.countryXid - ? { connect: { id: parentCompanyData.countryXid } } - : undefined, + cities: + parentCompanyData?.cityXid && + !isNaN(Number(parentCompanyData.cityXid)) + ? { connect: { id: Number(parentCompanyData.cityXid) } } + : undefined, + + states: + parentCompanyData?.stateXid && + !isNaN(Number(parentCompanyData.stateXid)) + ? { connect: { id: Number(parentCompanyData.stateXid) } } + : undefined, + + countries: + parentCompanyData?.countryXid && + !isNaN(Number(parentCompanyData.countryXid)) + ? { connect: { id: Number(parentCompanyData.countryXid) } } + : undefined, pinCode: parentCompanyData.pinCode || null, - logoPath: parentCompanyData.logoPath || existingParentCompany.logoPath, + logoPath: + parentCompanyData?.logoPath || + existingParentCompany?.logoPath || + null, registrationNumber: parentCompanyData.registrationNumber || null, panNumber: parentCompanyData.panNumber || null, gstNumber: parentCompanyData.gstNumber || null, @@ -976,19 +1143,23 @@ export class HostService { if (parentDocuments?.length) { for (const doc of parentDocuments) { - const existingParentDoc = await tx.hostParenetDocuments.findFirst({ - where: { - hostParentXid: parentRecord.id, - documentTypeXid: doc.documentTypeXid, + const existingParentDoc = await tx.hostParenetDocuments.findFirst( + { + where: { + hostParentXid: parentRecord.id, + documentTypeXid: doc.documentTypeXid, + }, }, - }); + ); if (existingParentDoc) { await tx.hostParenetDocuments.update({ where: { id: existingParentDoc.id }, data: { filePath: doc.filePath, - documentName: doc.documentName || existingParentDoc.documentName, + documentName: + sanitizeDocumentName(doc.documentName) || + existingParentDoc.documentName, }, }); } else { @@ -996,7 +1167,7 @@ export class HostService { data: { hostParentXid: parentRecord.id, documentTypeXid: doc.documentTypeXid, - documentName: doc.documentName, + documentName: sanitizeDocumentName(doc.documentName), filePath: doc.filePath, }, }); @@ -1005,12 +1176,17 @@ export class HostService { } } } else { + console.log('Last ke else block me aaya hai'); const previousParent = existingHostCompany.hostParent; let prevParentId = null; if (Array.isArray(previousParent) && previousParent.length) { prevParentId = previousParent[0].id; - } else if (previousParent && typeof previousParent === 'object' && 'id' in previousParent) { + } else if ( + previousParent && + typeof previousParent === 'object' && + 'id' in previousParent + ) { prevParentId = previousParent.id; } @@ -1048,8 +1224,6 @@ export class HostService { }); } - - async getSuggestionDetails(user_xid: number) { const hostDetails = await this.prisma.hostHeader.findFirst({ where: { userXid: user_xid, isActive: true }, @@ -1059,7 +1233,7 @@ export class HostService { id: true, emailAddress: true, firstName: true, - userRefNumber: true + userRefNumber: true, }, }, accountManager: { @@ -1219,7 +1393,9 @@ export class HostService { // Overall percent const overallPercentage = - totalMaxPoints > 0 ? round2((totalUserPoints / totalMaxPoints) * 100) : 0; + totalMaxPoints > 0 + ? round2((totalUserPoints / totalMaxPoints) * 100) + : 0; // ---------- 🔥 ONLY FIRST 2 CATEGORIES ---------- const categoryArray = Object.values(categories); @@ -1239,14 +1415,14 @@ export class HostService { await this.prisma.activities.update({ where: { - id: activityXid + id: activityXid, }, data: { totalScore: round2(overallPercentage), sustainabilityScore: round2(categoryWise.Sustainability), safetyScore: round2(categoryWise.Safety), - } - }) + }, + }); // Return final score object return { @@ -1272,10 +1448,7 @@ export class HostService { }); } - async findHeaderByCompositeKey( - activityXid: number, - pqqQuestionXid: number, - ) { + async findHeaderByCompositeKey(activityXid: number, pqqQuestionXid: number) { return await this.prisma.activityPQQheader.findFirst({ where: { activityXid, @@ -1284,7 +1457,11 @@ export class HostService { }); } - async updateHeader(headerId: number, pqqAnswerXid: number, comments?: string | null) { + async updateHeader( + headerId: number, + pqqAnswerXid: number, + comments?: string | null, + ) { return await this.prisma.activityPQQheader.update({ where: { id: headerId, @@ -1329,15 +1506,17 @@ export class HostService { activityDisplayStatus: true, activityInternalStatus: true, amInternalStatus: true, - amDisplayStatus: true - } - }) + amDisplayStatus: true, + }, + }); if (!activity) { - throw new ApiError(404, "Activity not found") + throw new ApiError(404, 'Activity not found'); } - if (activity.activityInternalStatus == ACTIVITY_INTERNAL_STATUS.PQ_TO_UPDATE) { + if ( + activity.activityInternalStatus == ACTIVITY_INTERNAL_STATUS.PQ_TO_UPDATE + ) { return await this.prisma.$transaction(async (tx) => { await this.prisma.activities.update({ where: { id: activity_xid }, @@ -1345,9 +1524,9 @@ export class HostService { activityInternalStatus: ACTIVITY_INTERNAL_STATUS.PQ_SUBMITTED, activityDisplayStatus: ACTIVITY_DISPLAY_STATUS.PQ_IN_REVIEW, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.PQ_TO_REVIEW, - amDisplayStatus: ACTIVITY_AM_DISPLAY_STATUS.REVISED - } - }) + amDisplayStatus: ACTIVITY_AM_DISPLAY_STATUS.REVISED, + }, + }); await tx.activityTrack.create({ data: { @@ -1356,10 +1535,10 @@ export class HostService { trackStatus: ACTIVITY_TRACK_STATUS.PQ_SUBMITTED, updatedByXid: user_xid, updatedByRole: ROLE_NAME.HOST, - updatedOn: new Date() - } - }) - }) + updatedOn: new Date(), + }, + }); + }); } else { return await this.prisma.$transaction(async (tx) => { await this.prisma.activities.update({ @@ -1368,9 +1547,9 @@ export class HostService { activityInternalStatus: ACTIVITY_INTERNAL_STATUS.PQ_SUBMITTED, activityDisplayStatus: ACTIVITY_DISPLAY_STATUS.PQ_IN_REVIEW, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.PQ_TO_REVIEW, - amDisplayStatus: ACTIVITY_AM_DISPLAY_STATUS.NEW - } - }) + amDisplayStatus: ACTIVITY_AM_DISPLAY_STATUS.NEW, + }, + }); await tx.activityTrack.create({ data: { @@ -1379,13 +1558,12 @@ export class HostService { trackStatus: ACTIVITY_TRACK_STATUS.PQ_SUBMITTED, updatedByXid: user_xid, updatedByRole: ROLE_NAME.HOST, - updatedOn: new Date() - } - }) - }) + updatedOn: new Date(), + }, + }); + }); } - }) - + }); } async updateSupportingFile( @@ -1413,20 +1591,24 @@ export class HostService { }); } - async markPQQSuggestionReviewed(user_xid: number, activityPqqHeaderXid: number, activityPQQSuggestionId: number) { + async markPQQSuggestionReviewed( + user_xid: number, + activityPqqHeaderXid: number, + activityPQQSuggestionId: number, + ) { return await this.prisma.activityPQQSuggestions.update({ where: { id: activityPQQSuggestionId, activityPqqHeaderXid: activityPqqHeaderXid, isActive: true, - isReviewed: false + isReviewed: false, }, data: { isReviewed: true, reviewedByXid: user_xid, - reviewedOn: new Date() - } - }) + reviewedOn: new Date(), + }, + }); } async getAllPQQQuesAndSubmittedAns(activity_xid: number) { @@ -1453,11 +1635,11 @@ export class HostService { id: true, categoryName: true, displayOrder: true, - } - } - } - } - } + }, + }, + }, + }, + }, }, ActivityPQQSuggestions: { select: { @@ -1467,22 +1649,22 @@ export class HostService { isReviewed: true, reviewedBy: true, reviewedOn: true, - } + }, }, pqqAnswers: { select: { id: true, displayOrder: true, answerName: true, - answerPoints: true - } + answerPoints: true, + }, }, ActivityPQQSupportings: { select: { id: true, mediaFileName: true, mediaType: true, - } + }, }, }, }); @@ -1525,9 +1707,7 @@ export class HostService { activityTypeXid: number, frequenciesXid?: number, ) { - return await this.prisma.$transaction(async (tx) => { - // Fetch host const host = await tx.hostHeader.findFirst({ where: { userXid: userId, isActive: true }, @@ -1572,10 +1752,9 @@ export class HostService { async createActivityAndAllQuestionsEntry( userId: number, activityTypeXid: number, - frequenciesXid: number + frequenciesXid: number, ) { return await this.prisma.$transaction(async (tx) => { - const host = await tx.hostHeader.findFirst({ where: { userXid: userId, isActive: true }, }); @@ -1678,10 +1857,10 @@ export class HostService { select: { id: true, categoryName: true, - displayOrder: true - } - } - } + displayOrder: true, + }, + }, + }, }, // 🔥 ALL ANSWER OPTIONS FOR THIS QUESTION @@ -1691,11 +1870,11 @@ export class HostService { id: true, answerName: true, answerPoints: true, - displayOrder: true + displayOrder: true, }, - orderBy: { displayOrder: "asc" } - } - } + orderBy: { displayOrder: 'asc' }, + }, + }, }, ActivityPQQSuggestions: { where: { isActive: true }, @@ -1703,19 +1882,19 @@ export class HostService { id: true, title: true, comments: true, - activityPqqHeaderXid: true - } + activityPqqHeaderXid: true, + }, }, ActivityPQQSupportings: { where: { isActive: true }, select: { id: true, mediaType: true, - mediaFileName: true - } + mediaFileName: true, + }, }, }, - orderBy: { id: "asc" } + orderBy: { id: 'asc' }, }); // ---------------- GROUPING ------------------ @@ -1732,19 +1911,21 @@ export class HostService { id: cat.id, categoryName: cat.categoryName, displayOrder: cat.displayOrder, - pqqsubCategories: [] + pqqsubCategories: [], }; } const category = grouped[cat.id]; - let subCat = category.pqqsubCategories.find((s: any) => s.id === sub.id); + let subCat = category.pqqsubCategories.find( + (s: any) => s.id === sub.id, + ); if (!subCat) { subCat = { id: sub.id, subCategoryName: sub.subCategoryName, displayOrder: sub.displayOrder, - questions: [] + questions: [], }; category.pqqsubCategories.push(subCat); } @@ -1761,25 +1942,729 @@ export class HostService { }); } - const sortedCategories: any = Object.values(grouped) - .sort((a: any, b: any) => a.displayOrder - b.displayOrder); + const sortedCategories: any = Object.values(grouped).sort( + (a: any, b: any) => a.displayOrder - b.displayOrder, + ); for (const cat of sortedCategories) { - cat.pqqsubCategories.sort((a: any, b: any) => a.displayOrder - b.displayOrder); + cat.pqqsubCategories.sort( + (a: any, b: any) => a.displayOrder - b.displayOrder, + ); for (const sub of cat.pqqsubCategories) { - sub.questions.sort((a: any, b: any) => a.displayOrder - b.displayOrder); + sub.questions.sort( + (a: any, b: any) => a.displayOrder - b.displayOrder, + ); } } return { activity_xid: created.id, - sortedCategories + sortedCategories, }; }); } + /** + * Create a full activity with related records based on payload from the onboarding form. + * This method will create Activities + ActivityOtherDetails + ActivitiesMedia + + * ActivityVenues + ActivityPrices + ActivityFoodTypes + ActivityCuisine + + * ActivityPickUpTransport/Details + ActivityNavigationModes + ActivityEquipments + + * ActivityAmenities + ActivityEligibility + */ +async createOrUpdateActivity( + userId: number, + payload: any, + isDraft: boolean, +) { + /* ===================================================== + * HELPERS + * ===================================================== */ + const toBool = (v: any) => + v === true || v === 'true' || v === 1 || v === '1'; + const toNumber = (v: any) => + v === undefined || v === null || v === '' ? undefined : Number(v); + + const round2 = (v: number) => Math.round(v); + + const computeBasePriceAndTaxes = ( + sellPrice: number, + taxes: Array<{ id: number; taxPer: number }>, + ) => { + if (!taxes.length) { + return { basePrice: round2(sellPrice), taxDetails: [] }; + } + + const totalTaxPer = taxes.reduce((s, t) => s + Number(t.taxPer || 0), 0); + const basePrice = round2(sellPrice / (1 + totalTaxPer / 100)); + + return { + basePrice, + taxDetails: taxes.map((t) => ({ + taxXid: t.id, + taxPer: t.taxPer, + taxAmount: round2(basePrice * (t.taxPer / 100)), + })), + }; + }; + + /* ===================================================== + * BASIC GUARDS + * ===================================================== */ + if (!payload.activityXid) { + throw new ApiError(400, 'activityXid is required'); + } + + /* ===================================================== + * HARD NORMALIZATION (SERVICE-LEVEL) + * ===================================================== */ + payload.foodAvailable = toBool(payload.foodAvailable); + payload.alcoholAvailable = toBool(payload.alcoholAvailable); + payload.trainerAvailable = toBool(payload.trainerAvailable); + payload.pickUpDropAvailable = toBool(payload.pickUpDropAvailable); + payload.inActivityAvailable = toBool(payload.inActivityAvailable); + payload.equipmentAvailable = toBool(payload.equipmentAvailable); + payload.cancellationAvailable = toBool(payload.cancellationAvailable); + payload.isInstantBooking = toBool(payload.isInstantBooking); + payload.isCheckOutSame = toBool(payload.isCheckOutSame); + + payload.trainerTotalAmount = toNumber(payload.trainerTotalAmount); + + if (payload.trainerAvailable) { + if ( + typeof payload.trainerTotalAmount !== 'number' || + Number.isNaN(payload.trainerTotalAmount) || + payload.trainerTotalAmount <= 0 + ) { + throw new ApiError(400, 'trainerTotalAmount must be > 0'); + } + } else { + delete payload.trainerTotalAmount; + } + + if (payload.venues && !Array.isArray(payload.venues)) { + throw new ApiError(400, 'venues must be an array'); + } + + payload.venues?.forEach((v, idx) => { + v.isMinPeopleReqMandatory = toBool(v.isMinPeopleReqMandatory); + + if (!v.venueName) { + throw new ApiError(400, `venues[${idx}] venueName required`); + } + + if (v.isMinPeopleReqMandatory && !v.minPeopleRequired) { + throw new ApiError(400, `venues[${idx}] min people requirement missing`); + } + + if (!Array.isArray(v.prices) || !v.prices.length) { + throw new ApiError(400, `venues[${idx}] must have at least one price`); + } + }); + + /* ===================================================== + * ROOT TAX + * ===================================================== */ + const taxIds = Array.isArray(payload.taxXids) + ? payload.taxXids.map(Number) + : []; + + const rootTaxes = + taxIds.length > 0 + ? await this.prisma.taxes.findMany({ + where: { id: { in: taxIds }, isActive: true }, + select: { id: true, taxPer: true }, + }) + : []; + + if (taxIds.length !== rootTaxes.length) { + throw new ApiError(400, 'Invalid or inactive tax provided'); + } + + /* ===================================================== + * TRANSACTION + * ===================================================== */ + return await this.prisma.$transaction(async (tx) => { + /* -------------------------------- + * 1️⃣ HOST + * -------------------------------- */ + const host = await tx.hostHeader.findFirst({ + where: { userXid: userId, isActive: true }, + }); + if (!host) throw new ApiError(404, 'Host not found'); + + /* -------------------------------- + * 2️⃣ ACTIVITY + * -------------------------------- */ + const existingActivity = await tx.activities.findFirst({ + where: { + id: Number(payload.activityXid), + hostXid: host.id, + isActive: true, + }, + }); + if (!existingActivity) { + throw new ApiError(404, 'Activity not found'); + } + + /* -------------------------------- + * 3️⃣ STATUS DECISION + * -------------------------------- */ + let activityInternalStatus; + let activityDisplayStatus; + let amInternalStatus; + let amDisplayStatus; + + const wasRejected = + existingActivity.activityInternalStatus === + ACTIVITY_INTERNAL_STATUS.ACTIVITY_REJECTED; + + if (wasRejected) { + if (isDraft) { + activityInternalStatus = existingActivity.activityInternalStatus; + activityDisplayStatus = existingActivity.activityDisplayStatus; + amInternalStatus = existingActivity.amInternalStatus; + amDisplayStatus = existingActivity.amDisplayStatus; + } else { + activityInternalStatus = ACTIVITY_INTERNAL_STATUS.ACTIVITY_SUBMITTED; + activityDisplayStatus = ACTIVITY_DISPLAY_STATUS.ACTIVITY_IN_REVIEW; + amInternalStatus = ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_TO_REVIEW; + amDisplayStatus = ACTIVITY_AM_DISPLAY_STATUS.ACTIVITY_NEW; + } + } else { + if (isDraft) { + activityInternalStatus = ACTIVITY_INTERNAL_STATUS.ACTIVITY_DRAFT; + activityDisplayStatus = ACTIVITY_DISPLAY_STATUS.ACTIVITY_DRAFT; + amInternalStatus = ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_DRAFT; + amDisplayStatus = ACTIVITY_AM_DISPLAY_STATUS.ACTIVITY_DRAFT; + } else { + activityInternalStatus = ACTIVITY_INTERNAL_STATUS.ACTIVITY_SUBMITTED; + activityDisplayStatus = ACTIVITY_DISPLAY_STATUS.ACTIVITY_IN_REVIEW; + amInternalStatus = ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_TO_REVIEW; + amDisplayStatus = ACTIVITY_AM_DISPLAY_STATUS.ACTIVITY_NEW; + } + } + + /* -------------------------------- + * 4️⃣ UPDATE ACTIVITY CORE + FLAGS + * -------------------------------- */ + const activity = await tx.activities.update({ + where: { id: existingActivity.id }, + data: { + activityTypeXid: payload.activityTypeXid ?? undefined, + frequenciesXid: payload.frequenciesXid ?? undefined, + activityTitle: payload.activityTitle ?? undefined, + activityDescription: payload.activityDescription ?? undefined, + + checkInLat: payload.checkInLat ?? undefined, + checkInLong: payload.checkInLong ?? undefined, + checkInAddress: payload.checkInAddress ?? undefined, + isCheckOutSame: toBool(payload.isCheckOutSame), + checkOutLat: payload.checkOutLat ?? undefined, + checkOutLong: payload.checkOutLong ?? undefined, + checkOutAddress: payload.checkOutAddress ?? undefined, + + energyLevelXid: payload.energyLevelXid ?? undefined, + activityDurationMins: payload.activityDurationMins ?? undefined, + + currencyXid: payload.currencyXid ?? undefined, + sustainabilityScore: payload.sustainabilityScore ?? undefined, + safetyScore: payload.safetyScore ?? undefined, + isInstantBooking: payload.isInstantBooking ?? undefined, + + foodAvailable: payload.foodAvailable, + foodIsChargeable: toBool(payload.foodIsChargeable), + alcoholAvailable: payload.alcoholAvailable, + trainerAvailable: payload.trainerAvailable, + trainerIsChargeable: toBool(payload.trainerIsChargeable), + pickUpDropAvailable: payload.pickUpDropAvailable, + pickUpDropIsChargeable: toBool(payload.pickUpDropIsChargeable), + inActivityAvailable: payload.inActivityAvailable, + inActivityIsChargeable: toBool(payload.inActivityIsChargeable), + equipmentAvailable: payload.equipmentAvailable, + equipmentIsChargeable: toBool(payload.equipmentIsChargeable), + cancellationAvailable: payload.cancellationAvailable, + + activityInternalStatus, + activityDisplayStatus, + amInternalStatus, + amDisplayStatus, + }, + }); + + const activityXid = activity.id; + + /* -------------------------------- + * 5️⃣ CLEAN OLD ACTIVITY MEDIA + * -------------------------------- */ + await tx.activitiesMedia.deleteMany({ where: { activityXid } }); + + /* -------------------------------- + * 6️⃣ SAVE NEW ACTIVITY MEDIA + * -------------------------------- */ + if (Array.isArray(payload.media) && payload.media.length) { + await tx.activitiesMedia.createMany({ + data: payload.media.map((m, index) => ({ + activityXid, + mediaType: m.mediaType ?? 'unknown', + mediaFileName: m.mediaFileName, + displayOrder: index + 1, + })), + }); + } + + /* -------------------------------- + * 7️⃣ CLEAN OLD VENUES & RELATED DATA + * -------------------------------- */ + const oldVenueIds = ( + await tx.activityVenues.findMany({ + where: { activityXid }, + select: { id: true }, + }) + ).map((v) => v.id); + + if (oldVenueIds.length) { + // Clean venue artifacts (media) + await tx.activityVenueArtifacts.deleteMany({ + where: { activityVenueXid: { in: oldVenueIds } }, + }); + + // Clean price taxes and prices + const priceIds = ( + await tx.activityPrices.findMany({ + where: { activityVenueXid: { in: oldVenueIds } }, + select: { id: true }, + }) + ).map((p) => p.id); + + if (priceIds.length) { + await tx.activityPriceTaxes.deleteMany({ + where: { activityPriceXid: { in: priceIds } }, + }); + await tx.activityPrices.deleteMany({ + where: { id: { in: priceIds } }, + }); + } + + // Clean venues + await tx.activityVenues.deleteMany({ + where: { id: { in: oldVenueIds } }, + }); + } + + /* -------------------------------- + * 8️⃣ CREATE VENUES WITH MEDIA & PRICES + * -------------------------------- */ + for (const venue of payload.venues ?? []) { + const venueRow = await tx.activityVenues.create({ + data: { + activityXid, + venueName: venue.venueName, + venueCapacity: toNumber(venue.venueCapacity) ?? 0, + availableSeats: toNumber(venue.availableSeats) ?? 0, + isMinPeopleReqMandatory: venue.isMinPeopleReqMandatory, + minPeopleRequired: toNumber(venue.minPeopleRequired) ?? null, + minReqfullfilledBeforeMins: + toNumber(venue.minReqfullfilledBeforeMins) ?? null, + venueDescription: venue.venueDescription ?? null, + }, + }); + + // Create venue media/artifacts + if (Array.isArray(venue.media) && venue.media.length) { + await tx.activityVenueArtifacts.createMany({ + data: venue.media.map((m) => ({ + activityVenueXid: venueRow.id, + mediaType: m.mediaType ?? 'image', + mediaFileName: m.mediaFileName, + })), + }); + } + + // Create venue prices with taxes + for (const price of venue.prices ?? []) { + const sellPrice = Number(price.sellPrice); + const { basePrice, taxDetails } = computeBasePriceAndTaxes( + sellPrice, + rootTaxes, + ); + + const priceRow = await tx.activityPrices.create({ + data: { + activityVenueXid: venueRow.id, + noOfSession: price.noOfSession ?? 1, + isPackage: price.isPackage ?? false, + sessionValidity: price.sessionValidity ?? 0, + sessionValidityFrequency: price.sessionValidityFrequency ?? 'Days', + basePrice, + sellPrice, + }, + }); + + if (taxDetails.length) { + await tx.activityPriceTaxes.createMany({ + data: taxDetails.map((t) => ({ + activityPriceXid: priceRow.id, + taxXid: t.taxXid, + taxPer: t.taxPer, + taxAmount: t.taxAmount, + })), + }); + } + } + } + + /* -------------------------------- + * 9️⃣ CLEAN & CREATE EQUIPMENT WITH TAXES + * -------------------------------- */ + const oldEquipmentIds = ( + await tx.activityEquipments.findMany({ + where: { activityXid }, + select: { id: true }, + }) + ).map((e) => e.id); + + if (oldEquipmentIds.length) { + await tx.activityEquipmentTaxes.deleteMany({ + where: { activityEquipmentXid: { in: oldEquipmentIds } }, + }); + await tx.activityEquipments.deleteMany({ + where: { id: { in: oldEquipmentIds } }, + }); + } + + if (Array.isArray(payload.equipments) && payload.equipments.length) { + for (const eq of payload.equipments) { + const totalPrice = toNumber(eq.equipmentTotalPrice) ?? 0; + const { basePrice, taxDetails } = computeBasePriceAndTaxes( + totalPrice, + rootTaxes, + ); + + const equipment = await tx.activityEquipments.create({ + data: { + activityXid, + equipmentName: eq.equipmentName, + isEquipmentChargeable: toBool(eq.isEquipmentChargeable), + equipmentBasePrice: basePrice, + equipmentTotalPrice: totalPrice, + }, + }); + + if (taxDetails.length) { + await tx.activityEquipmentTaxes.createMany({ + data: taxDetails.map((t) => ({ + activityEquipmentXid: equipment.id, + taxXid: t.taxXid, + taxPer: t.taxPer, + taxAmount: t.taxAmount, + })), + }); + } + } + } + + /* -------------------------------- + * 🔟 CLEAN & CREATE TRAINER WITH TAXES + * -------------------------------- */ + const oldTrainerIds = ( + await tx.activityTrainers.findMany({ + where: { activityXid }, + select: { id: true }, + }) + ).map((t) => t.id); + + if (oldTrainerIds.length) { + await tx.activityTrainerTaxes.deleteMany({ + where: { activityTrainerXid: { in: oldTrainerIds } }, + }); + await tx.activityTrainers.deleteMany({ + where: { id: { in: oldTrainerIds } }, + }); + } + + if (payload.trainerAvailable) { + const { basePrice, taxDetails } = computeBasePriceAndTaxes( + payload.trainerTotalAmount, + rootTaxes, + ); + + const trainer = await tx.activityTrainers.create({ + data: { + activityXid, + baseAmount: basePrice, + totalAmount: payload.trainerTotalAmount, + }, + }); + + if (taxDetails.length) { + await tx.activityTrainerTaxes.createMany({ + data: taxDetails.map((t) => ({ + activityTrainerXid: trainer.id, + taxXid: t.taxXid, + taxPer: t.taxPer, + taxAmount: t.taxAmount, + })), + }); + } + } + + /* -------------------------------- + * 1️⃣1️⃣ CLEAN & CREATE PICKUP/DROP TRANSPORTS WITH DETAILS & TAXES + * -------------------------------- */ + const oldTransportIds = ( + await tx.activityPickUpTransport.findMany({ + where: { activityXid }, + select: { id: true }, + }) + ).map((t) => t.id); + + if (oldTransportIds.length) { + // Get all pickup details for these transports + const oldPickupDetailIds = ( + await tx.activityPickUpDetails.findMany({ + where: { activityPickUpTransportXid: { in: oldTransportIds } }, + select: { id: true }, + }) + ).map((p) => p.id); + + if (oldPickupDetailIds.length) { + // Delete taxes first + await tx.activityPickUpTransportTaxes.deleteMany({ + where: { activityPickUpDetailsXid: { in: oldPickupDetailIds } }, + }); + // Delete pickup details + await tx.activityPickUpDetails.deleteMany({ + where: { id: { in: oldPickupDetailIds } }, + }); + } + + // Delete transports + await tx.activityPickUpTransport.deleteMany({ + where: { id: { in: oldTransportIds } }, + }); + } + + if ( + Array.isArray(payload.pickupTransports) && + payload.pickupTransports.length + ) { + for (const transport of payload.pickupTransports) { + // Create transport mode + const transportRow = await tx.activityPickUpTransport.create({ + data: { + activityXid, + transportModeXid: transport.transportModeXid, + isTransportModeChargeable: toBool( + transport.isTransportModeChargeable, + ), + }, + }); + + // Create pickup details for this transport + if ( + Array.isArray(transport.pickupDetails) && + transport.pickupDetails.length + ) { + for (const detail of transport.pickupDetails) { + const totalPrice = toNumber(detail.transportTotalPrice) ?? 0; + const { basePrice, taxDetails } = computeBasePriceAndTaxes( + totalPrice, + rootTaxes, + ); + + const pickupDetail = await tx.activityPickUpDetails.create({ + data: { + activityPickUpTransportXid: transportRow.id, + isPickUp: toBool(detail.isPickUp), + locationLat: toNumber(detail.locationLat), + locationLong: toNumber(detail.locationLong), + locationAddress: detail.locationAddress ?? null, + transportBasePrice: basePrice, + transportTotalPrice: totalPrice, + }, + }); + + if (taxDetails.length) { + await tx.activityPickUpTransportTaxes.createMany({ + data: taxDetails.map((t) => ({ + activityPickUpDetailsXid: pickupDetail.id, + taxXid: t.taxXid, + taxPer: t.taxPer, + taxAmount: t.taxAmount, + })), + }); + } + } + } + } + } + + /* -------------------------------- + * 1️⃣2️⃣ CLEAN & CREATE NAVIGATION MODES WITH TAXES + * -------------------------------- */ + const oldNavIds = ( + await tx.activityNavigationModes.findMany({ + where: { activityXid }, + select: { id: true }, + }) + ).map((n) => n.id); + + if (oldNavIds.length) { + await tx.activityNavigationModesTaxes.deleteMany({ + where: { activityNavigationModeXid: { in: oldNavIds } }, + }); + await tx.activityNavigationModes.deleteMany({ + where: { id: { in: oldNavIds } }, + }); + } + + if ( + Array.isArray(payload.navigationModes) && + payload.navigationModes.length + ) { + const totalPrice = toNumber(payload.navigationModeTotalPrice) ?? 0; + const { basePrice, taxDetails } = computeBasePriceAndTaxes( + totalPrice, + rootTaxes, + ); + + for (const modeId of payload.navigationModes) { + const navMode = await tx.activityNavigationModes.create({ + data: { + activityXid, + navigationModeXid: modeId, + isInActivityChargeable: toBool(payload.navigationModeIsChargeable), + navigationModesBasePrice: basePrice, + navigationModesTotalPrice: totalPrice, + }, + }); + + if (taxDetails.length) { + await tx.activityNavigationModesTaxes.createMany({ + data: taxDetails.map((t) => ({ + activityNavigationModeXid: navMode.id, + taxXid: t.taxXid, + taxPer: t.taxPer, + taxAmount: t.taxAmount, + })), + }); + } + } + } + + /* -------------------------------- + * 1️⃣3️⃣ CLEAN & CREATE AMENITIES + * -------------------------------- */ + await tx.activityAmenities.deleteMany({ where: { activityXid } }); + + if (Array.isArray(payload.amenitiesIds) && payload.amenitiesIds.length) { + await tx.activityAmenities.createMany({ + data: payload.amenitiesIds.map((amenityId) => ({ + activityXid, + amenitiesXid: amenityId, + })), + }); + } + + /* -------------------------------- + * 1️⃣4️⃣ CLEAN & CREATE ELIGIBILITY + * -------------------------------- */ + await tx.activityEligibility.deleteMany({ where: { activityXid } }); + + if (payload.eligibility) { + await tx.activityEligibility.create({ + data: { + activityXid, + isAgeRestriction: toBool(payload.eligibility.isAgeRestriction), + ageRestrictionXid: toNumber(payload.eligibility.ageRestrictionXid), + isWeightRestriction: toBool(payload.eligibility.isWeightRestriction), + weightRestrictionName: payload.eligibility.weightRestrictionName ?? null, + weightEntered: toNumber(payload.eligibility.weightEntered), + weightIn: payload.eligibility.weightIn ?? null, + minWeight: toNumber(payload.eligibility.minWeight), + maxWeight: toNumber(payload.eligibility.maxWeight), + isHeightRestriction: toBool(payload.eligibility.isHeightRestriction), + heightRestrictionName: payload.eligibility.heightRestrictionName ?? null, + heightEntered: toNumber(payload.eligibility.heightEntered), + heightIn: payload.eligibility.heightIn ?? null, + minHeight: toNumber(payload.eligibility.minHeight), + maxHeight: toNumber(payload.eligibility.maxHeight), + }, + }); + } + + /* -------------------------------- + * 1️⃣5️⃣ CLEAN & CREATE OTHER DETAILS + * -------------------------------- */ + await tx.activityOtherDetails.deleteMany({ where: { activityXid } }); + + if (payload.otherDetails) { + await tx.activityOtherDetails.create({ + data: { + activityXid, + exclusiveNotes: payload.otherDetails.exclusiveNotes ?? null, + dosNotes: payload.otherDetails.dosNotes ?? null, + dontsNotes: payload.otherDetails.dontsNotes ?? null, + tipsNotes: payload.otherDetails.tipsNotes ?? null, + termsAndCondition: payload.otherDetails.termsAndCondition ?? null, + }, + }); + } + + /* -------------------------------- + * 1️⃣6️⃣ CLEAN & CREATE FOOD TYPES + * -------------------------------- */ + await tx.activityFoodTypes.deleteMany({ where: { activityXid } }); + + if (Array.isArray(payload.foodTypeIds) && payload.foodTypeIds.length) { + await tx.activityFoodTypes.createMany({ + data: payload.foodTypeIds.map((foodTypeId) => ({ + activityXid, + foodTypeXid: foodTypeId, + })), + }); + } + + /* -------------------------------- + * 1️⃣7️⃣ CLEAN & CREATE CUISINES + * -------------------------------- */ + await tx.activityCuisine.deleteMany({ where: { activityXid } }); + + if (Array.isArray(payload.cuisineIds) && payload.cuisineIds.length) { + await tx.activityCuisine.createMany({ + data: payload.cuisineIds.map((cuisineId) => ({ + activityXid, + foodCuisineXid: cuisineId, + })), + }); + } + + /* -------------------------------- + * 1️⃣8️⃣ ACTIVITY TRACK + * -------------------------------- */ + await tx.activityTrack.create({ + data: { + activityXid, + trackType: 'ACTIVITY', + trackStatus: activityInternalStatus, + updatedByXid: userId, + updatedByRole: ROLE_NAME.HOST, + updatedOn: new Date(), + }, + }); + + /* -------------------------------- + * 1️⃣9️⃣ RESPONSE + * -------------------------------- */ + return { + activityXid, + activityRefNumber: activity.activityRefNumber, + status: isDraft ? 'ACTIVITY_SAVED_AS_DRAFT' : 'ACTIVITY_SUBMITTED', + }; + }); +} async getAllPQUpdatedResponse(activityXid: number) { const pqqHeaderData = await this.prisma.activityPQQheader.findMany({ where: { @@ -1805,10 +2690,10 @@ export class HostService { select: { id: true, categoryName: true, - displayOrder: true - } - } - } + displayOrder: true, + }, + }, + }, }, // 🔥 ALL ANSWER OPTIONS FOR THIS QUESTION @@ -1818,11 +2703,11 @@ export class HostService { id: true, answerName: true, answerPoints: true, - displayOrder: true + displayOrder: true, }, - orderBy: { displayOrder: "asc" } - } - } + orderBy: { displayOrder: 'asc' }, + }, + }, }, ActivityPQQSuggestions: { where: { isActive: true }, @@ -1830,19 +2715,19 @@ export class HostService { id: true, title: true, comments: true, - activityPqqHeaderXid: true - } + activityPqqHeaderXid: true, + }, }, ActivityPQQSupportings: { where: { isActive: true }, select: { id: true, mediaType: true, - mediaFileName: true - } + mediaFileName: true, + }, }, }, - orderBy: { id: "asc" } + orderBy: { id: 'asc' }, }); // ---------- GROUPING START ---------- @@ -1860,11 +2745,11 @@ export class HostService { id: cat.id, categoryName: cat.categoryName, displayOrder: cat.displayOrder, - activityPqqHeaderId: item.id, // ✅ Added to match AM response - pqqsubCategories: [] + activityPqqHeaderId: item.id, // ✅ Added to match AM response + pqqsubCategories: [], }; } else if (!grouped[cat.id].activityPqqHeaderId) { - grouped[cat.id].activityPqqHeaderId = item.id; // Ensures it's set if missing + grouped[cat.id].activityPqqHeaderId = item.id; // Ensures it's set if missing } const category = grouped[cat.id]; @@ -1876,7 +2761,7 @@ export class HostService { id: sub.id, subCategoryName: sub.subCategoryName, displayOrder: sub.displayOrder, - questions: [] + questions: [], }; category.pqqsubCategories.push(subCat); } @@ -1889,18 +2774,21 @@ export class HostService { pqqAnswerXid: item.pqqAnswerXid, comments: item.comments || null, displayOrder: q.displayOrder, - allAnswerOptions: q.PQQAnswers || [], // 🔥 All answers + allAnswerOptions: q.PQQAnswers || [], // 🔥 All answers suggestions: item.ActivityPQQSuggestions, supportings: item.ActivityPQQSupportings, }); } // ---------- SORTING ---------- - const sortedCategories: any = Object.values(grouped) - .sort((a: any, b: any) => a.displayOrder - b.displayOrder); + const sortedCategories: any = Object.values(grouped).sort( + (a: any, b: any) => a.displayOrder - b.displayOrder, + ); for (const cat of sortedCategories) { - cat.pqqsubCategories.sort((a: any, b: any) => a.displayOrder - b.displayOrder); + cat.pqqsubCategories.sort( + (a: any, b: any) => a.displayOrder - b.displayOrder, + ); for (const sub of cat.pqqsubCategories) { sub.questions.sort((a: any, b: any) => a.displayOrder - b.displayOrder); @@ -1915,8 +2803,8 @@ export class HostService { for (const doc of q.supportings) { if (doc.mediaFileName) { const filePath = doc.mediaFileName; - const key = filePath.startsWith("http") - ? filePath.split(".com/")[1] + const key = filePath.startsWith('http') + ? filePath.split('.com/')[1] : filePath; doc.presignedUrl = await getPresignedUrl(bucket, key); diff --git a/src/modules/host/services/resendOTPEmail.service.ts b/src/modules/host/services/resendOTPEmail.service.ts index f7a2a34..1561e7f 100644 --- a/src/modules/host/services/resendOTPEmail.service.ts +++ b/src/modules/host/services/resendOTPEmail.service.ts @@ -15,8 +15,8 @@ export async function resendOtpEmail( const htmlContent = `

Dear ${role},

Your new OTP is: ${otp}

-

This code is valid for 5 minutes. Please do not share it with anyone.

-

Best regards,
Minglar Team

+

This code will be valid for the next 5 minutes.

+

Warm regards,
Minglar Team

`; try { diff --git a/src/modules/host/services/sendOTPEmail.service.ts b/src/modules/host/services/sendOTPEmail.service.ts index 15c1e70..e1d9b1f 100644 --- a/src/modules/host/services/sendOTPEmail.service.ts +++ b/src/modules/host/services/sendOTPEmail.service.ts @@ -13,9 +13,10 @@ export async function sendOtpEmailForHost( const htmlContent = `

Dear Host,

-

Your OTP for registration is: ${otp}

-

This code is valid for 5 minutes. Please do not share it with anyone.

-

Best regards,
Minglar Team

+

You’re almost all set! 🎉

+

Enter ${otp} to wrap your registration.

+

This code will be valid for the next 5 minutes.

+

Warm regards,
Minglar Team

`; try { diff --git a/src/modules/minglaradmin/handlers/hosthub/hosts/acceptHostApplication.ts b/src/modules/minglaradmin/handlers/hosthub/hosts/acceptHostApplication.ts index 803d32d..f3fef25 100644 --- a/src/modules/minglaradmin/handlers/hosthub/hosts/acceptHostApplication.ts +++ b/src/modules/minglaradmin/handlers/hosthub/hosts/acceptHostApplication.ts @@ -47,7 +47,7 @@ export const handler = safeHandler(async ( // Add suggestion using service await minglarService.acceptHostApplication(hostXid, userInfo.id); const hostDetails = await minglarService.getUserDetails(hostXid) - await sendEmailToHostForApprovedApplication(hostDetails.emailAddress) + await sendEmailToHostForApprovedApplication(hostDetails.emailAddress, hostDetails.firstName) return { statusCode: 200, diff --git a/src/modules/minglaradmin/handlers/hosthub/hosts/acceptPQByAM.ts b/src/modules/minglaradmin/handlers/hosthub/hosts/acceptPQByAM.ts index f5ff82a..d447556 100644 --- a/src/modules/minglaradmin/handlers/hosthub/hosts/acceptPQByAM.ts +++ b/src/modules/minglaradmin/handlers/hosthub/hosts/acceptPQByAM.ts @@ -4,6 +4,7 @@ import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda import { prismaClient } from '../../../../../common/database/prisma.lambda.service'; import { safeHandler } from '../../../../../common/utils/handlers/safeHandler'; import ApiError from '../../../../../common/utils/helper/ApiError'; +import { sendAMPQQAcceptanceMailtoHost } from '../../../../minglaradmin/services/approvalMailtoHost.service'; const minglarService = new MinglarService(prismaClient); @@ -39,6 +40,9 @@ export const handler = safeHandler(async ( Number(activityId), Number(userInfo.id) ); + const hostXid = await minglarService.getHostXidByActivityId(activityId) + const hostDetails = await minglarService.getUserDetails(hostXid) + await sendAMPQQAcceptanceMailtoHost(hostDetails.emailAddress, hostDetails.firstName) return { statusCode: 201, diff --git a/src/modules/minglaradmin/handlers/hosthub/hosts/rejectHostApplicationAM.ts b/src/modules/minglaradmin/handlers/hosthub/hosts/rejectHostApplicationAM.ts index 2539f75..4e47a59 100644 --- a/src/modules/minglaradmin/handlers/hosthub/hosts/rejectHostApplicationAM.ts +++ b/src/modules/minglaradmin/handlers/hosthub/hosts/rejectHostApplicationAM.ts @@ -47,7 +47,7 @@ export const handler = safeHandler(async ( // Add suggestion using service await minglarService.rejectHostApplicationAM(hostXid, userInfo.id); const hostDetails = await minglarService.getUserDetails(hostXid) - await sendAMRejectionMailtoHost(hostDetails.emailAddress) + await sendAMRejectionMailtoHost(hostDetails.emailAddress, hostDetails.firstName) return { statusCode: 200, diff --git a/src/modules/minglaradmin/handlers/hosthub/hosts/rejectPQQbyAM.ts b/src/modules/minglaradmin/handlers/hosthub/hosts/rejectPQQbyAM.ts index 322c1f8..38e4886 100644 --- a/src/modules/minglaradmin/handlers/hosthub/hosts/rejectPQQbyAM.ts +++ b/src/modules/minglaradmin/handlers/hosthub/hosts/rejectPQQbyAM.ts @@ -4,6 +4,7 @@ import { verifyMinglarAdminToken } from '../../../../../common/middlewares/jwt/a import { safeHandler } from '../../../../../common/utils/handlers/safeHandler'; import ApiError from '../../../../../common/utils/helper/ApiError'; import { MinglarService } from '../../../services/minglar.service'; +import { sendAMPQQRejectionMailtoHost } from '../../../../minglaradmin/services/rejectionMailtoHost.service'; const minglarService = new MinglarService(prismaClient); @@ -39,6 +40,9 @@ export const handler = safeHandler(async ( Number(activityId), Number(userInfo.id) ); + const hostXid = await minglarService.getHostXidByActivityId(activityId) + const hostDetails = await minglarService.getUserDetails(hostXid) + await sendAMPQQRejectionMailtoHost(hostDetails.emailAddress, hostDetails.firstName) return { statusCode: 201, diff --git a/src/modules/minglaradmin/handlers/hosthub/onboarding/showSuggestionToAM.ts b/src/modules/minglaradmin/handlers/hosthub/onboarding/showSuggestionToAM.ts new file mode 100644 index 0000000..dd83b3d --- /dev/null +++ b/src/modules/minglaradmin/handlers/hosthub/onboarding/showSuggestionToAM.ts @@ -0,0 +1,51 @@ +import { verifyMinglarAdminToken } from '@/common/middlewares/jwt/authForMinglarAdmin'; +import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda'; +import { prismaClient } from '../../../../../common/database/prisma.lambda.service'; +import { safeHandler } from '../../../../../common/utils/handlers/safeHandler'; +import ApiError from '../../../../../common/utils/helper/ApiError'; +import { MinglarService } from '../../../../minglaradmin/services/minglar.service'; + +const minglarService = new MinglarService(prismaClient); + +/** + * Get suggestions handler + * Retrieves suggestions based on user's role and host assignments + */ +export const handler = safeHandler(async ( + event: APIGatewayProxyEvent, + context?: Context +): Promise => { + // Verify authentication token + const token = event.headers['x-auth-token'] || event.headers['X-Auth-Token']; + if (!token) { + throw new ApiError(401, 'This is a protected route. Please provide a valid token.'); + } + + // Verify token and get user info + await verifyMinglarAdminToken(token); + + const hostXid = Number(event.pathParameters?.hostXid) + + if (!hostXid) { + throw new ApiError( + 400, + 'Host ID is required in path parameters.', + ); + } + + // Get suggestions using service + const suggestions = await minglarService.getSuggestionsForAM(hostXid); + + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + success: true, + message: 'Suggestions retrieved successfully', + data: suggestions, + }), + }; +}); diff --git a/src/modules/minglaradmin/services/amNotification.service.ts b/src/modules/minglaradmin/services/amNotification.service.ts index d38bf39..2990159 100644 --- a/src/modules/minglaradmin/services/amNotification.service.ts +++ b/src/modules/minglaradmin/services/amNotification.service.ts @@ -1,10 +1,10 @@ import { Injectable } from '@nestjs/common'; -import { PrismaService } from '../../../common/database/prisma.lambda.service'; +import { prismaClient } from '../../../common/database/prisma.lambda.service'; import { sendAMEmailForHostAssign } from './AMEmail.service'; @Injectable() export class AMNotificationService { - constructor(private prisma: PrismaService) {} + private prisma = prismaClient; /** * Fetch account manager email by id and send assignment email. diff --git a/src/modules/minglaradmin/services/approvalMailtoHost.service.ts b/src/modules/minglaradmin/services/approvalMailtoHost.service.ts index 30a8e24..06a169a 100644 --- a/src/modules/minglaradmin/services/approvalMailtoHost.service.ts +++ b/src/modules/minglaradmin/services/approvalMailtoHost.service.ts @@ -4,6 +4,7 @@ import config from "../../../config/config"; export async function sendEmailToHostForApprovedApplication( emailAddress: string, + name: string ): Promise<{ sent: boolean; // messageId: string @@ -12,7 +13,7 @@ export async function sendEmailToHostForApprovedApplication( const subject = "Approval for your application"; const htmlContent = ` -

Dear Host,

+

Dear ${name},

Congratulations, Your application to minglar admin has been approved.

You can start onboarding your activities through the host panel.

You can login to your account using the link below:
@@ -73,3 +74,41 @@ export async function sendEmailToHostForMinglarApproval( throw new ApiError(500, "Failed to send OTP to minglar admin via email."); } } + +export async function sendAMPQQAcceptanceMailtoHost( + emailAddress: string, + name: string +): Promise<{ + sent: boolean; + // messageId: string +}> { + + const subject = "Approval for your activity onboarding application"; + + const htmlContent = ` +

Dear ${name},

+

Congratulations, Your activity onboarding application to minglar admin has been approved.

+

You can start adding other details of your activity through the host panel.

+

You can login to your account using the link below:
+ Link: ${config.HOST_LINK}

+

Best regards,
Minglar Team

+ `; + + try { + const result = await brevoService.sendEmail({ + recipients: [{ email: emailAddress }], + subject, + htmlContent, + }); + + console.log("📧 Email sent successfully:", result); + + return { + sent: true, + // messageId: result.messageId + }; + } catch (err) { + console.error("Brevo email send failed:", err); + throw new ApiError(500, "Failed to send OTP to minglar admin via email."); + } +} diff --git a/src/modules/minglaradmin/services/minglar.service.ts b/src/modules/minglaradmin/services/minglar.service.ts index 19f1153..6608294 100644 --- a/src/modules/minglaradmin/services/minglar.service.ts +++ b/src/modules/minglaradmin/services/minglar.service.ts @@ -136,6 +136,13 @@ export class MinglarService { return this.prisma.user.findUnique({ where: { emailAddress: email } }); } + async getHostXidByActivityId(activityId: number) { + const activityDetails = await this.prisma.activities.findFirst({ + where: { id: activityId } + }) + return activityDetails.hostXid; + } + async getUserDetails(id: number) { const hostDetail = await this.prisma.hostHeader.findFirst({ where: { id: id } @@ -741,21 +748,60 @@ export class MinglarService { }; /** SEARCH FILTER **/ + // if (search?.trim()) { + // const term = search.trim(); + + // if (/^\d+$/.test(term)) { + // filters.id = Number(term); + // } else { + // filters.user = { + // ...filters.user, + // OR: [ + // { emailAddress: { contains: term, mode: 'insensitive' } }, + // { firstName: { contains: term, mode: 'insensitive' } }, + // { lastName: { contains: term, mode: 'insensitive' } }, + // ], + // }; + // } + // } if (search?.trim()) { const term = search.trim(); - - if (/^\d+$/.test(term)) { - filters.id = Number(term); - } else { - filters.user = { - ...filters.user, + filters.AND = [ + { OR: [ - { emailAddress: { contains: term, mode: 'insensitive' } }, - { firstName: { contains: term, mode: 'insensitive' } }, - { lastName: { contains: term, mode: 'insensitive' } }, + { + companyName: { + contains: term, + mode: 'insensitive', + }, + }, + { + user: { + OR: [ + { + firstName: { + contains: term, + mode: 'insensitive', + }, + }, + { + lastName: { + contains: term, + mode: 'insensitive', + }, + }, + { + userRefNumber: { + contains: term, + mode: 'insensitive', + }, + }, + ], + }, + }, ], - }; - } + }, + ]; } /** USER STATUS FILTER **/ @@ -1416,6 +1462,25 @@ export class MinglarService { return suggestions; } + async getSuggestionsForAM(hostXid: number) { + const suggestions = await this.prisma.hostSuggestion.findMany({ + where: { hostXid: hostXid, isreviewed: false, isActive: true }, + select: { + id: true, + title: true, + comments: true, + isparent: true, + isreviewed: true, + reviewOn: true, + }, + orderBy: { + id: 'asc', + }, + }); + + return suggestions; + } + async acceptHostApplication(host_xid: number, user_xid: number) { return await this.prisma.$transaction(async (tx) => { await this.prisma.hostHeader.update({ diff --git a/src/modules/minglaradmin/services/rejectionMailtoHost.service.ts b/src/modules/minglaradmin/services/rejectionMailtoHost.service.ts index 62ddb32..8f50509 100644 --- a/src/modules/minglaradmin/services/rejectionMailtoHost.service.ts +++ b/src/modules/minglaradmin/services/rejectionMailtoHost.service.ts @@ -39,6 +39,7 @@ export async function sendEmailToHostForRejectedApplication( export async function sendAMRejectionMailtoHost( emailAddress: string, + name: string ): Promise<{ sent: boolean; // messageId: string @@ -47,11 +48,15 @@ export async function sendAMRejectionMailtoHost( const subject = "Improvement of your application"; const htmlContent = ` -

Dear Host,

+

Dear ${name},

Your account manager has reviewed your application and provided some suggestions.
Please make the necessary improvements and re-submit your application to proceed with the onboarding process on Minglar.

You may access your application using the link below:
- Link: ${config.HOST_LINK}

+ Link: + + ${config.HOST_LINK} + +

If you have any questions, please feel free to contact the Minglar Support Team.

Best regards,
Minglar Team

@@ -75,3 +80,49 @@ export async function sendAMRejectionMailtoHost( throw new ApiError(500, "Failed to send OTP to minglar admin via email."); } } + + +export async function sendAMPQQRejectionMailtoHost( + emailAddress: string, + name: string +): Promise<{ + sent: boolean; + // messageId: string +}> { + + const subject = "Improvement of your activity onboarding application"; + + const htmlContent = ` +

Dear ${name},

+ +

Your account manager has reviewed your activity application and provided some suggestions.
+ Please make the necessary improvements and re-submit your activity application along with the pre-qualification answers to proceed with the onboarding process on Minglar.

+ +

You may access your activity onboarding application using the link below:
+ Link: ${config.HOST_LINK}

+ +

If you have any questions, please feel free to contact the Minglar Support Team.

+ +

Best regards,
+ Minglar Team

+ + `; + + try { + const result = await brevoService.sendEmail({ + recipients: [{ email: emailAddress }], + subject, + htmlContent, + }); + + console.log("📧 Email sent successfully:", result); + + return { + sent: true, + // messageId: result.messageId + }; + } catch (err) { + console.error("Brevo email send failed:", err); + throw new ApiError(500, "Failed to send OTP to minglar admin via email."); + } +} diff --git a/src/modules/prepopulate/handlers/getAddActivityPrePopulate.ts b/src/modules/prepopulate/handlers/getAddActivityPrePopulate.ts new file mode 100644 index 0000000..c23def5 --- /dev/null +++ b/src/modules/prepopulate/handlers/getAddActivityPrePopulate.ts @@ -0,0 +1,38 @@ +import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda'; +import { prismaClient } from '../../../common/database/prisma.lambda.service'; +import { verifyMinglarAdminHostToken } from '../../../common/middlewares/jwt/authForMinglarAdminHost'; +import { safeHandler } from '../../../common/utils/handlers/safeHandler'; +import ApiError from '../../../common/utils/helper/ApiError'; +import { PrePopulateService } from '../services/prepopulate.service'; + +const prePopulateService = new PrePopulateService(prismaClient); + +export const handler = safeHandler(async ( + event: APIGatewayProxyEvent, + context?: Context +): Promise => { + // Extract token from headers + const token = event.headers['x-auth-token'] || event.headers['X-Auth-Token'] + if (!token) { + throw new ApiError(400, 'This is a protected route. Please provide a valid token.'); + } + + // Authenticate user using the shared authForHost function + await verifyMinglarAdminHostToken(token); + + const result = await prePopulateService.getAllPrePopulateDataForAddActivity(); + + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + success: true, + message: 'Data retrieved successfully', + data: result, + }), + }; +}); + diff --git a/src/modules/prepopulate/services/prepopulate.service.ts b/src/modules/prepopulate/services/prepopulate.service.ts index b62e2ae..563fb85 100644 --- a/src/modules/prepopulate/services/prepopulate.service.ts +++ b/src/modules/prepopulate/services/prepopulate.service.ts @@ -141,4 +141,62 @@ export class PrePopulateService { }, }); } + + async getAllPrePopulateDataForAddActivity() { + const [ + foodType, + cuisineDetails, + vehicleType, + navigationMode, + taxDetails, + energyLevel, + aminitiesDetails, + allowedEntryType, + ageRestrictionDetails + ] = + await this.prisma.$transaction([ + this.prisma.foodTypes.findMany({ + where: { isActive: true }, + orderBy: { foodTypeName: 'asc' }, + }), + this.prisma.foodCuisines.findMany({ + where: { isActive: true }, + }), + this.prisma.transportModes.findMany({ + where: { isActive: true }, + }), + this.prisma.navigationModes.findMany({ + where: { isActive: true }, + }), + this.prisma.taxes.findMany({ + where: { isActive: true }, + }), + this.prisma.energyLevels.findMany({ + where: { isActive: true }, + }), + this.prisma.amenities.findMany({ + where: { isActive: true }, + }), + this.prisma.allowedEntryTypes.findMany({ + where: { isActive: true }, + orderBy: { allowedEntryTypeName: 'asc' } + }), + this.prisma.ageRestrictions.findMany({ + where: { isActive: true }, + orderBy: { ageRestrictionName: 'asc' } + }), + ]); + + return { + foodType, + cuisineDetails, + vehicleType, + navigationMode, + taxDetails, + energyLevel, + aminitiesDetails, + allowedEntryType, + ageRestrictionDetails + }; + } }