From 2a5629c2ae8899c378dda1c4baa7b47d2909be6e Mon Sep 17 00:00:00 2001 From: Ravi Gulhane <> Date: Tue, 2 Jun 2026 10:43:50 +0530 Subject: [PATCH] first commit --- bedrock-iam-policy.example.json | 22 + package-lock.json | 2689 ++++++++++++++++- package.json | 19 +- public/embed-demo.html | 21 + public/embed/concierge-widget.css | 54 + public/embed/concierge-widget.js | 138 + scripts/generate-permission-json-files.js | 57 + src/app.js | 92 +- src/config/constants.js | 2 +- src/config/database.js | 44 +- src/config/eventbuzz_plans.source.csv | 36 + src/config/loadPermissionFeatures.js | 40 + .../permissions/abstract_management.json | 6 + src/config/permissions/agenda_builder.json | 6 + .../permissions/ai_engagement_insights.json | 6 + src/config/permissions/ai_summarization.json | 6 + .../permissions/bring_your_wa_number.json | 6 + .../collect_payment_using_your_upi_qr.json | 6 + .../permissions/content_management.json | 6 + .../custom_badge_and_print_file_creation.json | 6 + src/config/permissions/custom_domain.json | 6 + .../custom_registration_workflow.json | 6 + .../custom_template_for_wa_messaging.json | 6 + src/config/permissions/custom_theme.json | 6 + .../permissions/customise_event_website.json | 6 + .../email_confirmation_reminders.json | 6 + .../permissions/email_outreach_campaigns.json | 7 + src/config/permissions/event_website.json | 13 + .../feedback_forms_and_surveys.json | 6 + src/config/permissions/gamification.json | 6 + src/config/permissions/invites_rsvp.json | 6 + src/config/permissions/live_polls.json | 6 + .../permissions/multi_track_agenda.json | 6 + .../permissions/multiple_quiz_modes.json | 6 + src/config/permissions/participants.json | 7 + .../payment_gateway_integration.json | 6 + .../permissions/push_notifications.json | 6 + .../permissions/qna_with_moderation.json | 6 + .../ratings_for_sessions_and_speakers.json | 6 + ...registration_forms_with_custom_fields.json | 6 + .../permissions/speaker_management.json | 6 + .../permissions/ticketing_free_and_paid.json | 6 + .../whatsapp_confirmation_reminders.json | 6 + src/config/permissions/word_cloud.json | 6 + src/config/planFeatureGroups.js | 50 + src/config/planFeatureMatrix.js | 127 + src/config/redisConnection.js | 93 + src/config/subscriptionCatalog.js | 321 +- src/config/waTemplateMergeKeys.js | 16 + src/controllers/attendance.controller.js | 302 ++ src/controllers/auth.controller.js | 104 +- src/controllers/badgeBatch.controller.js | 182 ++ src/controllers/campaign.controller.js | 467 ++- src/controllers/checkins.controller.js | 18 + src/controllers/conciergeBot.controller.js | 439 +++ src/controllers/event.controller.js | 388 ++- src/controllers/eventTicket.controller.js | 147 + .../eventTicketingManual.controller.js | 173 ++ src/controllers/form.controller.js | 250 +- src/controllers/organization.controller.js | 164 +- src/controllers/participantList.controller.js | 98 +- src/controllers/platformAdmin.controller.js | 371 +++ .../publicAttendance.controller.js | 88 + src/controllers/publicConcierge.controller.js | 134 + .../publicEventTicket.controller.js | 378 +++ src/controllers/question.controller.js | 12 +- src/controllers/subscription.controller.js | 179 +- src/controllers/ticketManual.controller.js | 149 + src/controllers/visitor.controller.js | 9 +- .../whatsappTemplate.controller.js | 191 ++ src/jobs/subscriptionExpiry.job.js | 27 + src/jobs/whatsappTemplateSync.job.js | 26 + src/models/Attendance.js | 40 + src/models/AttendanceParticipant.js | 48 + src/models/BadgeBatch.js | 64 + src/models/ChatSubscriber.js | 32 + src/models/ConciergeBot.js | 50 + src/models/ContextChunk.js | 27 + src/models/DesignTemplate.js | 6 + src/models/EmailQueue.js | 46 + src/models/Event.js | 30 + src/models/EventTicket.js | 58 + src/models/Form.js | 2 +- src/models/KnowledgeSource.js | 40 + src/models/MessagingCampaign.js | 11 +- src/models/Organization.js | 42 +- src/models/PaymentTransaction.js | 9 + src/models/Session.js | 4 + src/models/SubscriptionPlan.js | 38 + src/models/TicketManualOrder.js | 35 + src/models/User.js | 6 + src/models/WebChatSession.js | 32 + src/models/WhatsAppQueue.js | 52 + src/models/WhatsAppTemplate.js | 50 + src/routes/attendance.routes.js | 58 + src/routes/auth.routes.js | 2 +- src/routes/badgeBatch.routes.js | 63 + src/routes/campaign.routes.js | 78 +- src/routes/checkins.routes.js | 5 + src/routes/conciergeBot.routes.js | 68 + src/routes/designTemplate.routes.js | 1 + src/routes/event.routes.js | 40 +- src/routes/eventTicket.routes.js | 91 + src/routes/eventTicketingManual.routes.js | 47 + src/routes/form.routes.js | 20 +- src/routes/organization.routes.js | 37 +- src/routes/platformAdmin.routes.js | 198 ++ src/routes/publicAttendance.routes.js | 24 + src/routes/publicConcierge.routes.js | 29 + src/routes/publicEventTicket.routes.js | 112 + src/routes/session.routes.js | 2 + src/routes/subscription.routes.js | 80 +- src/routes/ticketManual.routes.js | 51 + src/routes/whatsappTemplate.routes.js | 66 + src/services/attendanceCheckIn.service.js | 260 ++ src/services/checkins.service.js | 113 + src/services/conciergeQueue.js | 39 + src/services/conciergeRag.service.js | 124 + src/services/featureAccess.js | 47 +- src/services/payments/createOrderCore.js | 40 +- src/services/payments/resolver.js | 62 +- src/services/payments/verifyPaymentCore.js | 30 +- src/services/subscriptionPayment.service.js | 173 ++ src/services/ticketPurchaseFulfillment.js | 192 ++ src/services/ticketSaleNotify.js | 105 + .../ticketingManualEnablement.service.js | 87 + src/services/whatsappTemplateMetaSync.js | 59 + src/utils/aiEventMergeAndGaps.js | 185 ++ src/utils/assertOrgBillingManager.js | 18 + src/utils/assertPlatformAdmin.js | 11 + src/utils/badgeRecipients.js | 157 + src/utils/badgeRender.js | 330 ++ src/utils/bedrockClient.js | 87 + src/utils/bedrockConverse.js | 37 + src/utils/bedrockEmbed.js | 125 + src/utils/bedrockExtractEvent.js | 239 +- src/utils/campaignContent.js | 10 + src/utils/campaignMerge.js | 103 + src/utils/campaignRecipients.js | 281 ++ src/utils/conciergeChunking.js | 32 + src/utils/conciergeDocumentExtract.js | 82 + src/utils/conciergeEventKnowledge.js | 254 ++ src/utils/conciergeEventKnowledge.test.js | 123 + src/utils/conciergeEventSnapshot.js | 45 + src/utils/conciergeIngest.js | 111 + src/utils/conciergeUrlCrawl.js | 422 +++ src/utils/conciergeUrlCrawl.test.js | 93 + src/utils/csvAttendance.js | 44 + src/utils/eventAgendaData.js | 129 + src/utils/eventRatingsSummary.js | 101 + src/utils/eventTicketPricing.js | 44 + src/utils/eventTicketingGateway.js | 20 + src/utils/mail.js | 196 +- src/utils/manualTicketingFee.js | 9 + src/utils/metaWhatsApp.js | 660 ++++ src/utils/micrositeBuild.js | 47 +- src/utils/parseParticipantQrPayload.js | 38 + src/utils/participantListQuery.js | 106 + src/utils/participantMembership.js | 20 + src/utils/phone.js | 25 + src/utils/publicTicketCheckoutShared.js | 113 + src/utils/s3Media.js | 21 +- src/utils/smtpCrypto.js | 59 + src/utils/ticketFormValidation.js | 41 + src/utils/ticketingManualPayment.js | 20 + src/utils/waTemplateSendParams.js | 174 ++ src/workers/badgeBatchWorker.js | 106 + src/workers/emailQueueWorker.js | 118 + src/workers/whatsappQueueWorker.js | 210 ++ 169 files changed, 15978 insertions(+), 511 deletions(-) create mode 100644 bedrock-iam-policy.example.json create mode 100644 public/embed-demo.html create mode 100644 public/embed/concierge-widget.css create mode 100644 public/embed/concierge-widget.js create mode 100644 scripts/generate-permission-json-files.js create mode 100644 src/config/eventbuzz_plans.source.csv create mode 100644 src/config/loadPermissionFeatures.js create mode 100644 src/config/permissions/abstract_management.json create mode 100644 src/config/permissions/agenda_builder.json create mode 100644 src/config/permissions/ai_engagement_insights.json create mode 100644 src/config/permissions/ai_summarization.json create mode 100644 src/config/permissions/bring_your_wa_number.json create mode 100644 src/config/permissions/collect_payment_using_your_upi_qr.json create mode 100644 src/config/permissions/content_management.json create mode 100644 src/config/permissions/custom_badge_and_print_file_creation.json create mode 100644 src/config/permissions/custom_domain.json create mode 100644 src/config/permissions/custom_registration_workflow.json create mode 100644 src/config/permissions/custom_template_for_wa_messaging.json create mode 100644 src/config/permissions/custom_theme.json create mode 100644 src/config/permissions/customise_event_website.json create mode 100644 src/config/permissions/email_confirmation_reminders.json create mode 100644 src/config/permissions/email_outreach_campaigns.json create mode 100644 src/config/permissions/event_website.json create mode 100644 src/config/permissions/feedback_forms_and_surveys.json create mode 100644 src/config/permissions/gamification.json create mode 100644 src/config/permissions/invites_rsvp.json create mode 100644 src/config/permissions/live_polls.json create mode 100644 src/config/permissions/multi_track_agenda.json create mode 100644 src/config/permissions/multiple_quiz_modes.json create mode 100644 src/config/permissions/participants.json create mode 100644 src/config/permissions/payment_gateway_integration.json create mode 100644 src/config/permissions/push_notifications.json create mode 100644 src/config/permissions/qna_with_moderation.json create mode 100644 src/config/permissions/ratings_for_sessions_and_speakers.json create mode 100644 src/config/permissions/registration_forms_with_custom_fields.json create mode 100644 src/config/permissions/speaker_management.json create mode 100644 src/config/permissions/ticketing_free_and_paid.json create mode 100644 src/config/permissions/whatsapp_confirmation_reminders.json create mode 100644 src/config/permissions/word_cloud.json create mode 100644 src/config/planFeatureGroups.js create mode 100644 src/config/planFeatureMatrix.js create mode 100644 src/config/redisConnection.js create mode 100644 src/config/waTemplateMergeKeys.js create mode 100644 src/controllers/attendance.controller.js create mode 100644 src/controllers/badgeBatch.controller.js create mode 100644 src/controllers/checkins.controller.js create mode 100644 src/controllers/conciergeBot.controller.js create mode 100644 src/controllers/eventTicket.controller.js create mode 100644 src/controllers/eventTicketingManual.controller.js create mode 100644 src/controllers/platformAdmin.controller.js create mode 100644 src/controllers/publicAttendance.controller.js create mode 100644 src/controllers/publicConcierge.controller.js create mode 100644 src/controllers/publicEventTicket.controller.js create mode 100644 src/controllers/ticketManual.controller.js create mode 100644 src/controllers/whatsappTemplate.controller.js create mode 100644 src/jobs/subscriptionExpiry.job.js create mode 100644 src/jobs/whatsappTemplateSync.job.js create mode 100644 src/models/Attendance.js create mode 100644 src/models/AttendanceParticipant.js create mode 100644 src/models/BadgeBatch.js create mode 100644 src/models/ChatSubscriber.js create mode 100644 src/models/ConciergeBot.js create mode 100644 src/models/ContextChunk.js create mode 100644 src/models/EmailQueue.js create mode 100644 src/models/EventTicket.js create mode 100644 src/models/KnowledgeSource.js create mode 100644 src/models/SubscriptionPlan.js create mode 100644 src/models/TicketManualOrder.js create mode 100644 src/models/WebChatSession.js create mode 100644 src/models/WhatsAppQueue.js create mode 100644 src/models/WhatsAppTemplate.js create mode 100644 src/routes/attendance.routes.js create mode 100644 src/routes/badgeBatch.routes.js create mode 100644 src/routes/checkins.routes.js create mode 100644 src/routes/conciergeBot.routes.js create mode 100644 src/routes/eventTicket.routes.js create mode 100644 src/routes/eventTicketingManual.routes.js create mode 100644 src/routes/platformAdmin.routes.js create mode 100644 src/routes/publicAttendance.routes.js create mode 100644 src/routes/publicConcierge.routes.js create mode 100644 src/routes/publicEventTicket.routes.js create mode 100644 src/routes/ticketManual.routes.js create mode 100644 src/routes/whatsappTemplate.routes.js create mode 100644 src/services/attendanceCheckIn.service.js create mode 100644 src/services/checkins.service.js create mode 100644 src/services/conciergeQueue.js create mode 100644 src/services/conciergeRag.service.js create mode 100644 src/services/subscriptionPayment.service.js create mode 100644 src/services/ticketPurchaseFulfillment.js create mode 100644 src/services/ticketSaleNotify.js create mode 100644 src/services/ticketingManualEnablement.service.js create mode 100644 src/services/whatsappTemplateMetaSync.js create mode 100644 src/utils/aiEventMergeAndGaps.js create mode 100644 src/utils/assertOrgBillingManager.js create mode 100644 src/utils/assertPlatformAdmin.js create mode 100644 src/utils/badgeRecipients.js create mode 100644 src/utils/badgeRender.js create mode 100644 src/utils/bedrockClient.js create mode 100644 src/utils/bedrockConverse.js create mode 100644 src/utils/bedrockEmbed.js create mode 100644 src/utils/campaignContent.js create mode 100644 src/utils/campaignMerge.js create mode 100644 src/utils/campaignRecipients.js create mode 100644 src/utils/conciergeChunking.js create mode 100644 src/utils/conciergeDocumentExtract.js create mode 100644 src/utils/conciergeEventKnowledge.js create mode 100644 src/utils/conciergeEventKnowledge.test.js create mode 100644 src/utils/conciergeEventSnapshot.js create mode 100644 src/utils/conciergeIngest.js create mode 100644 src/utils/conciergeUrlCrawl.js create mode 100644 src/utils/conciergeUrlCrawl.test.js create mode 100644 src/utils/csvAttendance.js create mode 100644 src/utils/eventAgendaData.js create mode 100644 src/utils/eventRatingsSummary.js create mode 100644 src/utils/eventTicketPricing.js create mode 100644 src/utils/eventTicketingGateway.js create mode 100644 src/utils/manualTicketingFee.js create mode 100644 src/utils/metaWhatsApp.js create mode 100644 src/utils/parseParticipantQrPayload.js create mode 100644 src/utils/participantListQuery.js create mode 100644 src/utils/phone.js create mode 100644 src/utils/publicTicketCheckoutShared.js create mode 100644 src/utils/smtpCrypto.js create mode 100644 src/utils/ticketFormValidation.js create mode 100644 src/utils/ticketingManualPayment.js create mode 100644 src/utils/waTemplateSendParams.js create mode 100644 src/workers/badgeBatchWorker.js create mode 100644 src/workers/emailQueueWorker.js create mode 100644 src/workers/whatsappQueueWorker.js diff --git a/bedrock-iam-policy.example.json b/bedrock-iam-policy.example.json new file mode 100644 index 0000000..5b7d259 --- /dev/null +++ b/bedrock-iam-policy.example.json @@ -0,0 +1,22 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "BedrockInvokeLlamaForEventBuzz", + "Effect": "Allow", + "Action": [ + "bedrock:InvokeModel", + "bedrock:InvokeModelWithResponseStream", + "bedrock:Converse", + "bedrock:ConverseStream" + ], + "Resource": [ + "arn:aws:bedrock:ap-south-1::foundation-model/meta.llama3-8b-instruct-v1:0", + "arn:aws:bedrock:ap-south-1::foundation-model/meta.llama3-70b-instruct-v1:0", + "arn:aws:bedrock:ap-south-1::foundation-model/amazon.titan-embed-text-v2:0", + "arn:aws:bedrock:ap-south-1::foundation-model/amazon.titan-embed-text-v1:0", + "arn:aws:bedrock:ap-south-1::foundation-model/amazon.titan-embed-text-v1" + ] + } + ] +} diff --git a/package-lock.json b/package-lock.json index 520de67..93aa9a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,17 +12,29 @@ "@aws-sdk/client-s3": "^3.1012.0", "@aws-sdk/lib-storage": "^3.1012.0", "@hapi/hapi": "^21.3.0", + "@napi-rs/canvas": "^0.1.56", + "archiver": "^7.0.1", "bcrypt": "^5.1.1", + "bullmq": "^5.34.5", + "cheerio": "^1.0.0", "dotenv": "^16.3.1", "firebase-admin": "^13.0.0", "google-auth-library": "^9.14.0", "hapi-auth-jwt2": "^10.2.0", + "ioredis": "^5.4.2", "joi": "^17.13.0", "jsonwebtoken": "^9.0.2", + "mathjs": "^14.0.1", "mongoose": "^8.0.0", + "node-cron": "^3.0.3", "nodemailer": "^6.9.7", + "officeparser": "^5.1.1", "otplib": "^12.0.1", + "pdf-lib": "^1.17.1", + "pdf-parse": "^1.1.1", + "qrcode": "^1.5.4", "razorpay": "^2.9.6", + "sharp": "^0.33.5", "winston": "^3.11.0" }, "devDependencies": { @@ -1508,6 +1520,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -1583,6 +1604,16 @@ "kuler": "^2.0.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@fastify/busboy": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", @@ -2159,6 +2190,469 @@ "@hapi/hoek": "^11.0.2" } }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -2568,6 +3062,333 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz", + "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==", + "license": "MIT", + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.100", + "@napi-rs/canvas-darwin-arm64": "0.1.100", + "@napi-rs/canvas-darwin-x64": "0.1.100", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", + "@napi-rs/canvas-linux-arm64-musl": "0.1.100", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-musl": "0.1.100", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", + "@napi-rs/canvas-win32-x64-msvc": "0.1.100" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz", + "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz", + "integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz", + "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz", + "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz", + "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz", + "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz", + "integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz", + "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz", + "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz", + "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz", + "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -2651,6 +3472,34 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -3522,6 +4371,12 @@ "text-hex": "1.0.x" } }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -3736,6 +4591,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -3747,7 +4611,6 @@ "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", "license": "MIT", - "optional": true, "dependencies": { "event-target-shim": "^5.0.0" }, @@ -3796,7 +4659,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "devOptional": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3828,6 +4690,176 @@ "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", "license": "ISC" }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/archiver-utils/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/are-we-there-yet": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", @@ -3902,6 +4934,20 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/b4a": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -4034,6 +5080,97 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.1.tgz", + "integrity": "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.0.tgz", + "integrity": "sha512-JTjuZyNIDpw+GytMO4a6TK1VXdVKKJr6DRxEHasyuYyShV2deuiHJK/ahGZlebc+SG0/wJCB9XK8gprBGDFi/Q==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.0.tgz", + "integrity": "sha512-3zAJRZMDFGjdn+RVnNpF9kuELw+0Fl3lpndM4NcEOhb9zwtSo/deETfuIwMSE5BXanA0FrN1qVjffGwAg2Y7EA==", + "license": "Apache-2.0", + "dependencies": { + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.2.tgz", + "integrity": "sha512-/9a2j4ac6ckpmAHvod/ob7x439OAHst/drc2Clnq+reRYd/ovddwcF4LfoxHyNk5AuGBnPg+HqFjmE/Zpq6v0A==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -4103,6 +5240,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", @@ -4195,6 +5338,15 @@ "ieee754": "^1.1.4" } }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -4205,9 +5357,63 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, "license": "MIT" }, + "node_modules/bullmq": { + "version": "5.77.6", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.77.6.tgz", + "integrity": "sha512-WCpSoCD4vWyRD+btOsFrO7iBGInrTgG155gTZCV8qY0Yex2KtsbVtFERx6V1WZ2xWl/5ZxnLar8Z8ufnS4f5jg==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.10.1", + "msgpackr": "2.0.1", + "node-abort-controller": "3.1.1", + "semver": "7.8.0", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=12.22.0" + }, + "peerDependencies": { + "redis": ">=5.0.0" + }, + "peerDependenciesMeta": { + "redis": { + "optional": true + } + } + }, + "node_modules/bullmq/node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, + "node_modules/bullmq/node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -4252,7 +5458,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4306,6 +5511,48 @@ "node": ">=10" } }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -4378,6 +5625,15 @@ "node": ">=12" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -4413,7 +5669,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "devOptional": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -4426,7 +5681,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "devOptional": true, "license": "MIT" }, "node_modules/color-string": { @@ -4492,6 +5746,19 @@ "node": ">= 0.8" } }, + "node_modules/complex.js": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.4.3.tgz", + "integrity": "sha512-UrQVSUur14tNX6tiP4y8T4w4FeJAX3bi2cIv0pu/DTLFNxoq7z2Yh83Vfzztj6Px3X/lubqQ9IrPp7Bpn6p4MQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", @@ -4502,12 +5769,83 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/compress-commons/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -4541,6 +5879,77 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/crc32-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -4563,11 +5972,22 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -4578,6 +5998,34 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -4604,6 +6052,21 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -4644,6 +6107,15 @@ "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", "license": "MIT" }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -4684,6 +6156,67 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -4723,6 +6256,12 @@ "stream-shift": "^1.0.2" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -4764,6 +6303,19 @@ "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", "license": "MIT" }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -4774,6 +6326,18 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -4839,6 +6403,12 @@ "node": ">=6" } }, + "node_modules/escape-latex": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz", + "integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", @@ -4868,7 +6438,6 @@ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=6" } @@ -4882,6 +6451,15 @@ "node": ">=0.8.x" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -4953,6 +6531,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -5053,6 +6637,23 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/file-type": { + "version": "16.5.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", + "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==", + "license": "MIT", + "dependencies": { + "readable-web-to-node-stream": "^3.0.0", + "strtok3": "^6.2.4", + "token-types": "^4.1.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -5070,7 +6671,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, "license": "MIT", "dependencies": { "locate-path": "^5.0.0", @@ -5216,6 +6816,34 @@ } } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -5260,6 +6888,19 @@ "url": "https://ko-fi.com/tunnckoCore/commissions" } }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/fs-minipass": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", @@ -5413,7 +7054,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "devOptional": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -5616,7 +7256,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/gtoken": { @@ -5725,6 +7364,37 @@ "dev": true, "license": "MIT" }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-parser-js": { "version": "0.5.10", "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", @@ -5769,6 +7439,18 @@ "node": ">=10.17.0" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -5843,6 +7525,28 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ioredis": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.0.tgz", + "integrity": "sha512-EZBErytyVovD8f6pDfG3Kb37N6Y3lmDA9NNj+4+IP13CzzHGeX+OyeRM2Um13khRzoBSzzL+5lVnCX8V2RLeMg==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -5943,11 +7647,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -6037,6 +7746,27 @@ "node": ">=8" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/javascript-natural-sort": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", + "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==", + "license": "MIT" + }, "node_modules/jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", @@ -6804,6 +8534,48 @@ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", "license": "MIT" }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -6830,7 +8602,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, "license": "MIT", "dependencies": { "p-locate": "^4.1.0" @@ -6839,6 +8610,12 @@ "node": ">=8" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -6852,12 +8629,24 @@ "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "license": "MIT" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -6956,6 +8745,15 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -6999,6 +8797,29 @@ "node": ">= 0.4" } }, + "node_modules/mathjs": { + "version": "14.9.1", + "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-14.9.1.tgz", + "integrity": "sha512-xhqv8Xjf+caWG3WlaPekg4v8QFOR3D5+8ycfcjMcPcnCNDgAONQLaLfyGgrggJrcHx2yUGCpACRpiD4GmXwX+Q==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.26.10", + "complex.js": "^2.2.5", + "decimal.js": "^10.4.3", + "escape-latex": "^1.2.0", + "fraction.js": "^5.2.1", + "javascript-natural-sort": "^0.7.1", + "seedrandom": "^3.0.5", + "tiny-emitter": "^2.1.0", + "typed-function": "^4.2.1" + }, + "bin": { + "mathjs": "bin/cli.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/memory-pager": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", @@ -7258,6 +9079,37 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msgpackr": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.1.tgz", + "integrity": "sha512-9J+tqTEsbHqY8YohazYgty7LgerFIWxvMLpUjqETSmjHojtJm2WnX2kK/2a1fLI7CO7ERP1YSEUXMucz4j+yBA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -7265,12 +9117,39 @@ "dev": true, "license": "MIT" }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, "node_modules/node-addon-api": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", "license": "MIT" }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-cron/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -7291,6 +9170,12 @@ "node": ">=10.5.0" } }, + "node_modules/node-ensure": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz", + "integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==", + "license": "MIT" + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -7342,6 +9227,21 @@ "node": ">= 6.13.0" } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -7475,7 +9375,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7507,6 +9406,18 @@ "set-blocking": "^2.0.0" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -7539,6 +9450,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/officeparser": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/officeparser/-/officeparser-5.2.2.tgz", + "integrity": "sha512-5JrV1CZFqTv/27fXy2bcf+3g6BpDZiJ3XoSRW3fb2i2EFex0DduqjTxiU2RsJ08WBsk4Hp0nZoGi9ZtHMZFaPA==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.10", + "concat-stream": "^2.0.0", + "file-type": "^16.5.4", + "node-ensure": "^0.0.0", + "pdfjs-dist": "^5.3.31", + "yauzl": "^3.1.3" + }, + "bin": { + "officeparser": "officeParser.js" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7604,7 +9532,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, "license": "MIT", "dependencies": { "p-limit": "^2.2.0" @@ -7617,7 +9544,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -7633,12 +9559,23 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -7658,11 +9595,59 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7696,7 +9681,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7709,6 +9693,93 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/pdf-parse": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.4.tgz", + "integrity": "sha512-XRIRcLgk6ZnUbsHsYXExMw+krrPE81hJ6FQPLdBNhhBefqIQKXu/WeTgNBGSwPrfU0v+UCEwn7AoAUOsVKHFvQ==", + "license": "MIT", + "dependencies": { + "node-ensure": "^0.0.0" + }, + "engines": { + "node": ">=6.8.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/mehmet-kozan" + } + }, + "node_modules/pdfjs-dist": { + "version": "5.7.284", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.7.284.tgz", + "integrity": "sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.100" + } + }, + "node_modules/peek-readable": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", + "integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7752,6 +9823,15 @@ "node": ">=8" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -7780,6 +9860,21 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -7874,6 +9969,89 @@ ], "license": "MIT" }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", @@ -7920,6 +10098,92 @@ "node": ">= 6" } }, + "node_modules/readable-web-to-node-stream": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz", + "integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^4.7.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/readable-web-to-node-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/readable-web-to-node-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -7933,16 +10197,42 @@ "node": ">=8.10.0" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -8067,10 +10357,22 @@ "node": ">=10" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", + "license": "MIT" + }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -8085,11 +10387,72 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "license": "ISC" }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/sharp/node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/sharp/node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -8102,7 +10465,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8196,6 +10558,21 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -8285,6 +10662,12 @@ "node": ">=10" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/stream-browserify": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", @@ -8312,6 +10695,17 @@ "license": "MIT", "optional": true }, + "node_modules/streamx": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", + "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -8349,6 +10743,21 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -8361,6 +10770,19 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", @@ -8406,6 +10828,23 @@ ], "license": "MIT" }, + "node_modules/strtok3": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz", + "integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^4.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/stubs": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", @@ -8495,6 +10934,18 @@ "node": ">=10" } }, + "node_modules/tar-stream": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", + "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/tar/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -8532,6 +10983,15 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -8547,6 +11007,15 @@ "node": ">=8" } }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -8561,6 +11030,12 @@ "node": ">=0.2.6" } }, + "node_modules/tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -8581,6 +11056,23 @@ "node": ">=8.0" } }, + "node_modules/token-types": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", + "integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/touch": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", @@ -8641,6 +11133,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typed-function": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.2.tgz", + "integrity": "sha512-VwaXim9Gp1bngi/q3do8hgttYn2uC3MoT/gfuMWylnj1IeZBUAyPddHZlo1K05BDoj8DYPpMdiHqH1dDYdJf2A==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -8648,6 +11155,15 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", + "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -8770,6 +11286,28 @@ "node": ">=0.8.0" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "14.2.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", @@ -8787,7 +11325,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -8799,6 +11336,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -8862,6 +11405,24 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -8928,6 +11489,28 @@ "node": ">=12" } }, + "node_modules/yauzl": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.3.1.tgz", + "integrity": "sha512-RNPCUkiE/ZgO4w8i9U5yDQVHaFDdnzaFANElRvpJteCspvmv2VqrRb9lvS6odVD+jqI/zDsxAHJVsafpcheVQQ==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl/node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -8940,6 +11523,60 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/zip-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } } } } diff --git a/package.json b/package.json index 4853e54..9857f82 100644 --- a/package.json +++ b/package.json @@ -10,14 +10,26 @@ "test": "jest --coverage", "backfill:session-join-codes": "node scripts/backfill-session-join-codes.js", "backfill:join-codes": "node scripts/backfill-join-codes.js", - "backfill:tenant-organization": "node scripts/backfill-tenant-organization.js" + "backfill:tenant-organization": "node scripts/backfill-tenant-organization.js", + "email-worker": "node src/workers/emailQueueWorker.js", + "whatsapp-worker": "node src/workers/whatsappQueueWorker.js", + "concierge-ingest-worker": "node ../chatbot/workers/ingest.js", + "concierge-inbound-worker": "node ../chatbot/workers/inbound.js", + "badge-batch-worker": "node src/workers/badgeBatchWorker.js" }, "dependencies": { + "@napi-rs/canvas": "^0.1.56", + "archiver": "^7.0.1", "@aws-sdk/client-bedrock-runtime": "^3.1030.0", "@aws-sdk/client-s3": "^3.1012.0", "@aws-sdk/lib-storage": "^3.1012.0", "@hapi/hapi": "^21.3.0", "bcrypt": "^5.1.1", + "bullmq": "^5.34.5", + "cheerio": "^1.0.0", + "ioredis": "^5.4.2", + "mathjs": "^14.0.1", + "pdf-parse": "^1.1.1", "dotenv": "^16.3.1", "firebase-admin": "^13.0.0", "google-auth-library": "^9.14.0", @@ -25,9 +37,14 @@ "joi": "^17.13.0", "jsonwebtoken": "^9.0.2", "mongoose": "^8.0.0", + "pdf-lib": "^1.17.1", + "node-cron": "^3.0.3", "nodemailer": "^6.9.7", + "officeparser": "^5.1.1", "otplib": "^12.0.1", + "qrcode": "^1.5.4", "razorpay": "^2.9.6", + "sharp": "^0.33.5", "winston": "^3.11.0" }, "devDependencies": { diff --git a/public/embed-demo.html b/public/embed-demo.html new file mode 100644 index 0000000..2272db3 --- /dev/null +++ b/public/embed-demo.html @@ -0,0 +1,21 @@ + + + + + + EventBuzz Concierge Widget Demo + + +

Concierge widget demo

+

Set a real bot id below.

+ + + + + + diff --git a/public/embed/concierge-widget.css b/public/embed/concierge-widget.css new file mode 100644 index 0000000..7499859 --- /dev/null +++ b/public/embed/concierge-widget.css @@ -0,0 +1,54 @@ +:root{ + --ebc-bg:#ffffff; + --ebc-fg:#111827; + --ebc-muted:#6b7280; + --ebc-border:#e5e7eb; + --ebc-primary:#2563eb; + --ebc-primary-fg:#ffffff; +} +.ebc-launcher{ + position:fixed; right:18px; bottom:18px; + width:54px; height:54px; border-radius:999px; + border:1px solid var(--ebc-border); + background:var(--ebc-primary); color:var(--ebc-primary-fg); + cursor:pointer; box-shadow:0 10px 25px rgba(0,0,0,.12); + font:600 14px/1 system-ui,-apple-system,Segoe UI,Roboto,Arial; +} +.ebc-panel{ + position:fixed; right:18px; bottom:84px; + width:340px; height:460px; + border:1px solid var(--ebc-border); + background:var(--ebc-bg); color:var(--ebc-fg); + border-radius:14px; box-shadow:0 14px 40px rgba(0,0,0,.14); + display:none; overflow:hidden; + font:400 13px/1.35 system-ui,-apple-system,Segoe UI,Roboto,Arial; +} +.ebc-panel.ebc-open{ display:flex; flex-direction:column; } +.ebc-header{ + padding:12px 12px; + border-bottom:1px solid var(--ebc-border); + display:flex; align-items:center; justify-content:space-between; +} +.ebc-title{ font-weight:700; font-size:13px; } +.ebc-close{ border:0; background:transparent; cursor:pointer; color:var(--ebc-muted); font-size:18px; } +.ebc-messages{ padding:12px; gap:8px; display:flex; flex-direction:column; overflow:auto; flex:1; } +.ebc-msg{ max-width:85%; padding:9px 10px; border-radius:12px; border:1px solid var(--ebc-border); background:#f9fafb; } +.ebc-msg.ebc-user{ margin-left:auto; background:#eff6ff; border-color:#dbeafe; } +.ebc-meta{ font-size:11px; color:var(--ebc-muted); margin-top:2px; } +.ebc-inputbar{ + padding:10px; border-top:1px solid var(--ebc-border); + display:flex; gap:8px; align-items:center; +} +.ebc-input{ + flex:1; border:1px solid var(--ebc-border); + border-radius:10px; padding:9px 10px; + outline:none; +} +.ebc-send{ + border:1px solid var(--ebc-border); + background:var(--ebc-primary); color:var(--ebc-primary-fg); + border-radius:10px; padding:9px 12px; + cursor:pointer; font-weight:600; +} +.ebc-error{ color:#b91c1c; font-size:12px; padding:0 12px 10px; display:none; } + diff --git a/public/embed/concierge-widget.js b/public/embed/concierge-widget.js new file mode 100644 index 0000000..dabfa38 --- /dev/null +++ b/public/embed/concierge-widget.js @@ -0,0 +1,138 @@ +(function () { + var cfg = window.EB_CONCIERGE || {}; + var botId = cfg.botId; + var apiBase = (cfg.apiBase || '').replace(/\/$/, ''); + if (!botId || !apiBase) { + // eslint-disable-next-line no-console + console.error('[EventBuzz Concierge] Missing window.EB_CONCIERGE.botId or apiBase'); + return; + } + + var storageKey = 'ebc_session_' + botId; + var sessionId = null; + try { sessionId = localStorage.getItem(storageKey); } catch {} + + function el(tag, attrs, children) { + var n = document.createElement(tag); + if (attrs) for (var k in attrs) n.setAttribute(k, attrs[k]); + if (children) children.forEach(function (c) { n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); }); + return n; + } + + function addCssOnce() { + if (document.getElementById('ebc-css')) return; + var link = el('link', { id: 'ebc-css', rel: 'stylesheet', href: apiBase.replace(/\/api$/, '') + '/embed/concierge-widget.css' }); + document.head.appendChild(link); + } + + addCssOnce(); + + var launcher = el('button', { class: 'ebc-launcher', type: 'button', 'aria-label': 'Chat with event concierge' }, ['Chat']); + var panel = el('div', { class: 'ebc-panel', role: 'dialog', 'aria-label': 'Event concierge chat' }); + var header = el('div', { class: 'ebc-header' }); + var title = el('div', { class: 'ebc-title' }, ['Event Concierge']); + var closeBtn = el('button', { class: 'ebc-close', type: 'button', 'aria-label': 'Close' }, ['×']); + header.appendChild(title); + header.appendChild(closeBtn); + + var messages = el('div', { class: 'ebc-messages' }); + var error = el('div', { class: 'ebc-error' }, ['']); + var inputBar = el('div', { class: 'ebc-inputbar' }); + var input = el('input', { class: 'ebc-input', placeholder: 'Ask a question…', maxlength: '800' }); + var sendBtn = el('button', { class: 'ebc-send', type: 'button' }, ['Send']); + inputBar.appendChild(input); + inputBar.appendChild(sendBtn); + + panel.appendChild(header); + panel.appendChild(messages); + panel.appendChild(error); + panel.appendChild(inputBar); + document.body.appendChild(launcher); + document.body.appendChild(panel); + + function setError(msg) { + if (!msg) { + error.style.display = 'none'; + error.textContent = ''; + return; + } + error.style.display = 'block'; + error.textContent = msg; + } + + function addMessage(role, text) { + var bubble = el('div', { class: 'ebc-msg' + (role === 'user' ? ' ebc-user' : '') }); + bubble.textContent = text; + messages.appendChild(bubble); + messages.scrollTop = messages.scrollHeight; + } + + function openPanel() { + panel.classList.add('ebc-open'); + input.focus(); + } + function closePanel() { + panel.classList.remove('ebc-open'); + } + + launcher.addEventListener('click', function () { + if (panel.classList.contains('ebc-open')) closePanel(); + else openPanel(); + }); + closeBtn.addEventListener('click', closePanel); + + function http(path, body) { + return fetch(apiBase + path, { + method: body ? 'POST' : 'GET', + headers: { 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined, + credentials: 'omit' + }).then(function (r) { + return r.json().then(function (j) { return { status: r.status, json: j }; }); + }); + } + + // Load config + http('/public/concierge/' + encodeURIComponent(botId) + '/config').then(function (res) { + if (res.json && res.json.success && res.json.data) { + title.textContent = res.json.data.eventName ? ('Concierge · ' + res.json.data.eventName) : 'Event Concierge'; + var greet = res.json.data.greeting; + if (greet) addMessage('assistant', greet.replace(/\{eventName\}/g, res.json.data.eventName || 'the event')); + if (res.json.data.maxMessageLength) input.setAttribute('maxlength', String(res.json.data.maxMessageLength)); + } + }); + + function send() { + var text = String(input.value || '').trim(); + if (!text) return; + setError(null); + input.value = ''; + addMessage('user', text); + sendBtn.disabled = true; + + http('/public/concierge/' + encodeURIComponent(botId) + '/chat', { + sessionId: sessionId || undefined, + message: text + }).then(function (res) { + sendBtn.disabled = false; + if (!res.json || !res.json.success) { + setError((res.json && res.json.error && res.json.error.message) ? res.json.error.message : 'Request failed'); + return; + } + if (res.json.data && res.json.data.sessionId) { + sessionId = res.json.data.sessionId; + try { localStorage.setItem(storageKey, sessionId); } catch {} + } + addMessage('assistant', (res.json.data && res.json.data.reply) ? res.json.data.reply : '…'); + }).catch(function (e) { + sendBtn.disabled = false; + setError(e && e.message ? e.message : 'Network error'); + }); + } + + sendBtn.addEventListener('click', send); + input.addEventListener('keydown', function (e) { + if (e.key === 'Enter') send(); + }); +})(); + diff --git a/scripts/generate-permission-json-files.js b/scripts/generate-permission-json-files.js new file mode 100644 index 0000000..df080fc --- /dev/null +++ b/scripts/generate-permission-json-files.js @@ -0,0 +1,57 @@ +/** + * One-time generator: writes backend/src/config/permissions/*.json from the canonical catalog. + * Run: node backend/scripts/generate-permission-json-files.js + */ +const fs = require('fs'); +const path = require('path'); + +const DIR = path.join(__dirname, '../src/config/permissions'); + +const FEATURES = [ + ['participants', 'Participants', 'portfolio'], + ['event_website', 'Event Website', 'website'], + ['customise_event_website', 'Customise event website', 'website'], + ['custom_domain', 'Custom Domain', 'website'], + ['invites_rsvp', 'Invites +RSVP', 'registration'], + ['registration_forms_with_custom_fields', 'Registration forms with Custom fields', 'registration'], + ['custom_registration_workflow', 'Custom Registration workflow', 'registration'], + ['email_confirmation_reminders', 'Email Confirmation & Reminders', 'comms'], + ['email_outreach_campaigns', 'Email outreach campaigns', 'comms'], + ['custom_badge_and_print_file_creation', 'Custom Badge and print file creation', 'design'], + ['whatsapp_confirmation_reminders', 'WhatsApp confirmation & reminders', 'comms'], + ['bring_your_wa_number', 'Bring your WA number', 'comms'], + ['custom_template_for_wa_messaging', 'Custom template for WA messaging', 'comms'], + ['agenda_builder', 'Agenda builder', 'agenda'], + ['multi_track_agenda', 'Multi track agenda', 'agenda'], + ['speaker_management', 'Speaker management', 'agenda'], + ['abstract_management', 'Abstract management', 'agenda'], + ['ticketing_free_and_paid', 'Ticketing free & paid', 'payments'], + ['collect_payment_using_your_upi_qr', 'Collect Payment using your UPI QR', 'payments'], + ['payment_gateway_integration', 'Payment gateway integration', 'payments'], + ['qna_with_moderation', 'QnA with moderation', 'engagement'], + ['live_polls', 'Live Polls', 'engagement'], + ['multiple_quiz_modes', 'Multiple Quiz modes', 'engagement'], + ['word_cloud', 'Word Cloud', 'engagement'], + ['custom_theme', 'Custom Theme', 'branding'], + ['ai_summarization', 'AI Summarization', 'ai'], + ['ai_engagement_insights', 'AI Engagement insights', 'ai'], + ['content_management', 'Content management', 'content'], + ['ratings_for_sessions_and_speakers', 'Ratings for sessions and speakers', 'feedback'], + ['feedback_forms_and_surveys', 'Feedback forms & Surveys', 'feedback'], + ['gamification', 'Gamification', 'engagement'], + ['push_notifications', 'Push Notifications', 'comms'], +]; + +fs.mkdirSync(DIR, { recursive: true }); +for (const [key, label, category] of FEATURES) { + const file = path.join(DIR, `${key}.json`); + const doc = { + key, + label, + description: '', + category, + }; + fs.writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`, 'utf8'); + console.log('wrote', path.basename(file)); +} +console.log('done', FEATURES.length, 'files'); diff --git a/src/app.js b/src/app.js index 2ec18cf..6df330a 100644 --- a/src/app.js +++ b/src/app.js @@ -22,13 +22,26 @@ const contentCollectionRoutes = require('./routes/contentCollection.routes'); const paymentRoutes = require('./routes/payment.routes'); const organizationRoutes = require('./routes/organization.routes'); const subscriptionRoutes = require('./routes/subscription.routes'); +const platformAdminRoutes = require('./routes/platformAdmin.routes'); const adminRequestRoutes = require('./routes/adminRequest.routes'); const eventStaffRoutes = require('./routes/eventStaff.routes'); const websiteSettingsRoutes = require('./routes/websiteSettings.routes'); const eventMicrositeRoutes = require('./routes/eventMicrosite.routes'); const visitorRoutes = require('./routes/visitor.routes'); +const whatsappTemplateRoutes = require('./routes/whatsappTemplate.routes'); +const badgeBatchRoutes = require('./routes/badgeBatch.routes'); +const eventTicketRoutes = require('./routes/eventTicket.routes'); +const publicEventTicketRoutes = require('./routes/publicEventTicket.routes'); +const publicAttendanceRoutes = require('./routes/publicAttendance.routes'); +const attendanceRoutes = require('./routes/attendance.routes'); +const checkinsRoutes = require('./routes/checkins.routes'); +const ticketManualRoutes = require('./routes/ticketManual.routes'); +const eventTicketingManualRoutes = require('./routes/eventTicketingManual.routes'); +const conciergeBotRoutes = require('./routes/conciergeBot.routes'); +const publicConciergeRoutes = require('./routes/publicConcierge.routes'); const createServer = async () => { + /** Production: set CORS_ORIGIN (or FRONTEND_URL) to every origin that loads the SPA, e.g. https://console.example.com (no trailing slash). */ const fromEnv = (process.env.CORS_ORIGIN || process.env.FRONTEND_URL || '') .split(',') .map((s) => s.trim().replace(/\/$/, '')) @@ -63,7 +76,12 @@ const createServer = async () => { : localDevOrigins; /** Extra allowed origins (e.g. GrapesJS app on a different port/domain). Also set WEBSITE_BUILDER_CORS_ORIGINS in prod. */ - const allowedOrigins = [...new Set([...originList, ...builderExtras])]; + const conciergeExtras = (process.env.CONCIERGE_PUBLIC_ALLOWED_ORIGINS || '') + .split(',') + .map((s) => s.trim().replace(/\/$/, '')) + .filter(Boolean); + + const allowedOrigins = [...new Set([...originList, ...builderExtras, ...conciergeExtras])]; /** In development, allow any http://localhost or http://127.0.0.1 port (Vite may use 5175, 5176, …). Production: whitelist only. */ function resolveCorsAllowOrigin(rawOrigin) { @@ -167,7 +185,40 @@ const createServer = async () => { options: { auth: false }, }); + // Embeddable concierge widget assets (served directly from backend/public). + const fs = require('fs/promises'); + const path = require('path'); + const PUBLIC_DIR = path.join(__dirname, '..', 'public'); + const sendFile = async (h, relPath, contentType) => { + const abs = path.join(PUBLIC_DIR, relPath); + const body = await fs.readFile(abs, 'utf8'); + return h.response(body).type(contentType); + }; + server.route([ + { + method: 'GET', + path: '/embed/concierge-widget.js', + options: { auth: false }, + handler: async (request, h) => sendFile(h, 'embed/concierge-widget.js', 'application/javascript'), + }, + { + method: 'GET', + path: '/embed/concierge-widget.css', + options: { auth: false }, + handler: async (request, h) => sendFile(h, 'embed/concierge-widget.css', 'text/css'), + }, + { + method: 'GET', + path: '/embed/demo', + options: { auth: false }, + handler: async (request, h) => sendFile(h, 'embed-demo.html', 'text/html'), + }, + ]); + server.route(visitorRoutes); + server.route(publicEventTicketRoutes); + server.route(publicAttendanceRoutes); + server.route(publicConciergeRoutes); server.route(authRoutes); server.route(eventRoutes); @@ -189,10 +240,19 @@ const createServer = async () => { server.route(paymentRoutes); server.route(organizationRoutes); server.route(subscriptionRoutes); + server.route(platformAdminRoutes); server.route(adminRequestRoutes); server.route(eventStaffRoutes); server.route(websiteSettingsRoutes); server.route(eventMicrositeRoutes); + server.route(whatsappTemplateRoutes); + server.route(badgeBatchRoutes); + server.route(eventTicketRoutes); + server.route(ticketManualRoutes); + server.route(eventTicketingManualRoutes); + server.route(conciergeBotRoutes); + server.route(attendanceRoutes); + server.route(checkinsRoutes); server.ext('onPreResponse', (request, h) => { const response = request.response; @@ -231,7 +291,37 @@ const app = { async initialize() { await connectDB(); + const { warmPlanCache } = require('./config/subscriptionCatalog'); + await warmPlanCache(); this._server = await createServer(); + const cron = require('node-cron'); + const { runSubscriptionExpiry } = require('./jobs/subscriptionExpiry.job'); + cron.schedule( + '0 3 * * *', + async () => { + try { + const n = await runSubscriptionExpiry(); + if (n > 0) console.log(`[cron] subscription expiry: ${n} organisation(s) set to free`); + } catch (err) { + console.error('[cron] subscription expiry failed', err); + } + }, + { timezone: process.env.CRON_TZ || undefined }, + ); + const { runWhatsAppTemplateSync } = require('./jobs/whatsappTemplateSync.job'); + const waCron = process.env.WHATSAPP_TEMPLATE_SYNC_CRON || '*/12 * * * *'; + cron.schedule( + waCron, + async () => { + try { + const n = await runWhatsAppTemplateSync(); + if (n > 0) console.log(`[cron] whatsapp templates: synced ${n} organisation(s)`); + } catch (err) { + console.error('[cron] whatsapp template sync failed', err); + } + }, + { timezone: process.env.CRON_TZ || undefined }, + ); }, async start() { diff --git a/src/config/constants.js b/src/config/constants.js index e54ba08..4f1187a 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -5,7 +5,7 @@ const PARTICIPANT_STATUS = ['pending', 'confirmed', 'checked_in', 'cancelled']; const SESSION_TYPES = ['keynote', 'workshop', 'panel', 'breakout', 'networking']; const SESSION_STATUS = ['scheduled', 'ongoing', 'completed', 'cancelled']; const FORM_TYPES = ['registration', 'feedback', 'survey', 'custom', 'other']; -const FIELD_TYPES = ['text', 'email', 'phone', 'select', 'radio', 'checkbox', 'checkboxes', 'file']; +const FIELD_TYPES = ['text', 'email', 'phone', 'select', 'stars', 'radio', 'checkbox', 'checkboxes', 'file']; /** Registration field → Participant property mapping (or `custom` + customMapsKey). */ const FORM_FIELD_MAPS_TO = [ 'firstName', diff --git a/src/config/database.js b/src/config/database.js index b586c39..64efdff 100644 --- a/src/config/database.js +++ b/src/config/database.js @@ -1,13 +1,43 @@ const mongoose = require('mongoose'); +let connectPromise = null; + +/** + * Single shared MongoDB connection for API, workers, and chatbot service. + * Always require models from this backend package so they use this mongoose instance. + */ const connectDB = async () => { - try { - const conn = await mongoose.connect(process.env.MONGODB_URI); - console.log(`MongoDB connected: ${conn.connection.host}`); - } catch (error) { - console.error('MongoDB connection error:', error); - process.exit(1); + if (mongoose.connection.readyState === 1) { + return mongoose.connection; } + if (connectPromise) { + return connectPromise; + } + + const uri = process.env.MONGODB_URI; + if (!uri) { + throw new Error('MONGODB_URI is not configured'); + } + + connectPromise = mongoose + .connect(uri, { + maxPoolSize: Number(process.env.MONGODB_MAX_POOL_SIZE) || 50, + serverSelectionTimeoutMS: Number(process.env.MONGODB_SERVER_SELECTION_MS) || 30000, + socketTimeoutMS: Number(process.env.MONGODB_SOCKET_TIMEOUT_MS) || 45000, + }) + .then((conn) => { + console.log(`MongoDB connected: ${conn.connection.host}`); + return conn; + }) + .catch((err) => { + console.error('MongoDB connection error:', err); + throw err; + }) + .finally(() => { + connectPromise = null; + }); + + return connectPromise; }; -module.exports = { connectDB }; +module.exports = { connectDB, mongoose }; diff --git a/src/config/eventbuzz_plans.source.csv b/src/config/eventbuzz_plans.source.csv new file mode 100644 index 0000000..d33ce94 --- /dev/null +++ b/src/config/eventbuzz_plans.source.csv @@ -0,0 +1,36 @@ +,Free,Starter,Growth,Pro,Enterprise +MANAGE,,,,, +Participants,50,300,500,1000,Unlimited +Event Website,Event Page,Event microsite,Event microsite,Custom domain event microsite,Custom Website +Customise event website,No,No,Yes,Yes,Yes +Custom Domain,No,No,Yes,Yes,Yes +Invites +RSVP,Yes,Yes,Yes,Yes,Yes +Registration forms with Custom fields,Yes,Yes,Yes,Yes,Yes +Custom Registration workflow,No,No,No,Yes,Yes +Email Confirmation & Reminders,Yes,Yes,Yes,Yes,Yes +Email outreach campaigns,Upto 500 emails,Upto 5000 emails,Upto 10K emails,Upto 10K emails,Unlimited +Custom Badge and print file creation,Yes,Yes,Yes,Yes,Yes +WhatsApp confirmation & reminders,No,No,Yes,Yes,Yes +Bring your WA number,No,No,No,Yes,Yes +Custom template for WA messaging,No,No,No,Yes,Yes +Agenda builder,Yes,Yes,Yes,Yes,Yes +Multi track agenda,No,Yes,Yes,Yes,Yes +Speaker management,No,Yes,Yes,Yes,Yes +Abstract management,No,No,No,Yes,Yes +Ticketing free & paid,Yes,Yes,Yes,Yes,Yes +Collect Payment using your UPI QR,Add on,Yes,Yes,Yes,Yes +Payment gateway integration,No,No,Yes,Yes,Yes +,,,,, +ENGAGE,,,,, +QnA with moderation,Yes,Yes,Yes,Yes,Yes +Live Polls,Yes,Yes,Yes,Yes,Yes +Multiple Quiz modes,Yes,Yes,Yes,Yes,Yes +Word Cloud,Yes,Yes,Yes,Yes,Yes +Custom Theme,No,No,No,Yes,Yes +AI Summarization,No,No,Yes,Yes,Yes +AI Engagement insights ,No,No,Yes,Yes,Yes +Content management,No,No,Yes,Yes,Yes +Ratings for sessions and speakers,Yes,Yes,Yes,Yes,Yes +Feedback forms & Surveys,Yes,Yes,Yes,Yes,Yes +Gamification,No,No,Yes,Yes,Yes +Push Notifications,Yes,Yes,Yes,Yes,Yes diff --git a/src/config/loadPermissionFeatures.js b/src/config/loadPermissionFeatures.js new file mode 100644 index 0000000..e0cc2e3 --- /dev/null +++ b/src/config/loadPermissionFeatures.js @@ -0,0 +1,40 @@ +/** + * Loads subscription feature definitions from backend/src/config/permissions/*.json + * (one file per feature). Used by subscriptionCatalog and featureAccess. + */ +const fs = require('fs'); +const path = require('path'); + +const PERMISSIONS_DIR = path.join(__dirname, 'permissions'); + +/** + * @returns {{ FEATURES: Record, ORDERED_KEYS: string[] }} + */ +function loadPermissionFeatures() { + if (!fs.existsSync(PERMISSIONS_DIR)) { + return { FEATURES: {}, ORDERED_KEYS: [] }; + } + const files = fs.readdirSync(PERMISSIONS_DIR).filter((f) => f.endsWith('.json')).sort(); + const FEATURES = {}; + const ORDERED_KEYS = []; + for (const file of files) { + const raw = JSON.parse(fs.readFileSync(path.join(PERMISSIONS_DIR, file), 'utf8')); + if (!raw.key || !raw.label) continue; + if (FEATURES[raw.key]) { + throw new Error(`Duplicate permission key "${raw.key}" in ${file}`); + } + const entry = { + key: raw.key, + label: raw.label, + description: raw.description || '', + category: raw.category || 'general', + }; + if (raw.configKind) entry.configKind = raw.configKind; + if (Array.isArray(raw.options)) entry.options = raw.options; + FEATURES[raw.key] = entry; + ORDERED_KEYS.push(raw.key); + } + return { FEATURES, ORDERED_KEYS }; +} + +module.exports = { loadPermissionFeatures, PERMISSIONS_DIR }; diff --git a/src/config/permissions/abstract_management.json b/src/config/permissions/abstract_management.json new file mode 100644 index 0000000..1e6a09b --- /dev/null +++ b/src/config/permissions/abstract_management.json @@ -0,0 +1,6 @@ +{ + "key": "abstract_management", + "label": "Abstract management", + "description": "", + "category": "agenda" +} diff --git a/src/config/permissions/agenda_builder.json b/src/config/permissions/agenda_builder.json new file mode 100644 index 0000000..71749ae --- /dev/null +++ b/src/config/permissions/agenda_builder.json @@ -0,0 +1,6 @@ +{ + "key": "agenda_builder", + "label": "Agenda builder", + "description": "", + "category": "agenda" +} diff --git a/src/config/permissions/ai_engagement_insights.json b/src/config/permissions/ai_engagement_insights.json new file mode 100644 index 0000000..96cc5b0 --- /dev/null +++ b/src/config/permissions/ai_engagement_insights.json @@ -0,0 +1,6 @@ +{ + "key": "ai_engagement_insights", + "label": "AI Engagement insights", + "description": "", + "category": "ai" +} diff --git a/src/config/permissions/ai_summarization.json b/src/config/permissions/ai_summarization.json new file mode 100644 index 0000000..79e1425 --- /dev/null +++ b/src/config/permissions/ai_summarization.json @@ -0,0 +1,6 @@ +{ + "key": "ai_summarization", + "label": "AI Summarization", + "description": "", + "category": "ai" +} diff --git a/src/config/permissions/bring_your_wa_number.json b/src/config/permissions/bring_your_wa_number.json new file mode 100644 index 0000000..f85f5ba --- /dev/null +++ b/src/config/permissions/bring_your_wa_number.json @@ -0,0 +1,6 @@ +{ + "key": "bring_your_wa_number", + "label": "Bring your WA number", + "description": "", + "category": "comms" +} diff --git a/src/config/permissions/collect_payment_using_your_upi_qr.json b/src/config/permissions/collect_payment_using_your_upi_qr.json new file mode 100644 index 0000000..7830a1b --- /dev/null +++ b/src/config/permissions/collect_payment_using_your_upi_qr.json @@ -0,0 +1,6 @@ +{ + "key": "collect_payment_using_your_upi_qr", + "label": "Collect Payment using your UPI QR", + "description": "", + "category": "payments" +} diff --git a/src/config/permissions/content_management.json b/src/config/permissions/content_management.json new file mode 100644 index 0000000..4f95bc2 --- /dev/null +++ b/src/config/permissions/content_management.json @@ -0,0 +1,6 @@ +{ + "key": "content_management", + "label": "Content management", + "description": "", + "category": "content" +} diff --git a/src/config/permissions/custom_badge_and_print_file_creation.json b/src/config/permissions/custom_badge_and_print_file_creation.json new file mode 100644 index 0000000..ade026d --- /dev/null +++ b/src/config/permissions/custom_badge_and_print_file_creation.json @@ -0,0 +1,6 @@ +{ + "key": "custom_badge_and_print_file_creation", + "label": "Custom Badge and print file creation", + "description": "", + "category": "design" +} diff --git a/src/config/permissions/custom_domain.json b/src/config/permissions/custom_domain.json new file mode 100644 index 0000000..7b5e524 --- /dev/null +++ b/src/config/permissions/custom_domain.json @@ -0,0 +1,6 @@ +{ + "key": "custom_domain", + "label": "Custom Domain", + "description": "", + "category": "website" +} diff --git a/src/config/permissions/custom_registration_workflow.json b/src/config/permissions/custom_registration_workflow.json new file mode 100644 index 0000000..ec31642 --- /dev/null +++ b/src/config/permissions/custom_registration_workflow.json @@ -0,0 +1,6 @@ +{ + "key": "custom_registration_workflow", + "label": "Custom Registration workflow", + "description": "", + "category": "registration" +} diff --git a/src/config/permissions/custom_template_for_wa_messaging.json b/src/config/permissions/custom_template_for_wa_messaging.json new file mode 100644 index 0000000..5ecf224 --- /dev/null +++ b/src/config/permissions/custom_template_for_wa_messaging.json @@ -0,0 +1,6 @@ +{ + "key": "custom_template_for_wa_messaging", + "label": "Custom template for WA messaging", + "description": "", + "category": "comms" +} diff --git a/src/config/permissions/custom_theme.json b/src/config/permissions/custom_theme.json new file mode 100644 index 0000000..92f48d8 --- /dev/null +++ b/src/config/permissions/custom_theme.json @@ -0,0 +1,6 @@ +{ + "key": "custom_theme", + "label": "Custom Theme", + "description": "", + "category": "branding" +} diff --git a/src/config/permissions/customise_event_website.json b/src/config/permissions/customise_event_website.json new file mode 100644 index 0000000..482ee22 --- /dev/null +++ b/src/config/permissions/customise_event_website.json @@ -0,0 +1,6 @@ +{ + "key": "customise_event_website", + "label": "Customise event website", + "description": "", + "category": "website" +} diff --git a/src/config/permissions/email_confirmation_reminders.json b/src/config/permissions/email_confirmation_reminders.json new file mode 100644 index 0000000..601794f --- /dev/null +++ b/src/config/permissions/email_confirmation_reminders.json @@ -0,0 +1,6 @@ +{ + "key": "email_confirmation_reminders", + "label": "Email Confirmation & Reminders", + "description": "", + "category": "comms" +} diff --git a/src/config/permissions/email_outreach_campaigns.json b/src/config/permissions/email_outreach_campaigns.json new file mode 100644 index 0000000..4564785 --- /dev/null +++ b/src/config/permissions/email_outreach_campaigns.json @@ -0,0 +1,7 @@ +{ + "key": "email_outreach_campaigns", + "label": "Email outreach campaigns", + "description": "Maximum outreach emails (CSV: Upto N / Unlimited). null = unlimited.", + "category": "comms", + "configKind": "number" +} diff --git a/src/config/permissions/event_website.json b/src/config/permissions/event_website.json new file mode 100644 index 0000000..033ca49 --- /dev/null +++ b/src/config/permissions/event_website.json @@ -0,0 +1,13 @@ +{ + "key": "event_website", + "label": "Event Website", + "description": "Tier from CSV (Event Page → Custom Website).", + "category": "website", + "configKind": "select", + "options": [ + { "value": "event_page", "label": "Event Page" }, + { "value": "event_microsite", "label": "Event microsite" }, + { "value": "custom_domain_microsite", "label": "Custom domain event microsite" }, + { "value": "custom_website", "label": "Custom Website" } + ] +} diff --git a/src/config/permissions/feedback_forms_and_surveys.json b/src/config/permissions/feedback_forms_and_surveys.json new file mode 100644 index 0000000..5251558 --- /dev/null +++ b/src/config/permissions/feedback_forms_and_surveys.json @@ -0,0 +1,6 @@ +{ + "key": "feedback_forms_and_surveys", + "label": "Feedback forms & Surveys", + "description": "", + "category": "feedback" +} diff --git a/src/config/permissions/gamification.json b/src/config/permissions/gamification.json new file mode 100644 index 0000000..d7db317 --- /dev/null +++ b/src/config/permissions/gamification.json @@ -0,0 +1,6 @@ +{ + "key": "gamification", + "label": "Gamification", + "description": "", + "category": "engagement" +} diff --git a/src/config/permissions/invites_rsvp.json b/src/config/permissions/invites_rsvp.json new file mode 100644 index 0000000..fd6dfe2 --- /dev/null +++ b/src/config/permissions/invites_rsvp.json @@ -0,0 +1,6 @@ +{ + "key": "invites_rsvp", + "label": "Invites +RSVP", + "description": "", + "category": "registration" +} diff --git a/src/config/permissions/live_polls.json b/src/config/permissions/live_polls.json new file mode 100644 index 0000000..4f0a318 --- /dev/null +++ b/src/config/permissions/live_polls.json @@ -0,0 +1,6 @@ +{ + "key": "live_polls", + "label": "Live Polls", + "description": "", + "category": "engagement" +} diff --git a/src/config/permissions/multi_track_agenda.json b/src/config/permissions/multi_track_agenda.json new file mode 100644 index 0000000..f3fac07 --- /dev/null +++ b/src/config/permissions/multi_track_agenda.json @@ -0,0 +1,6 @@ +{ + "key": "multi_track_agenda", + "label": "Multi track agenda", + "description": "", + "category": "agenda" +} diff --git a/src/config/permissions/multiple_quiz_modes.json b/src/config/permissions/multiple_quiz_modes.json new file mode 100644 index 0000000..e853a24 --- /dev/null +++ b/src/config/permissions/multiple_quiz_modes.json @@ -0,0 +1,6 @@ +{ + "key": "multiple_quiz_modes", + "label": "Multiple Quiz modes", + "description": "", + "category": "engagement" +} diff --git a/src/config/permissions/participants.json b/src/config/permissions/participants.json new file mode 100644 index 0000000..7ec10b2 --- /dev/null +++ b/src/config/permissions/participants.json @@ -0,0 +1,7 @@ +{ + "key": "participants", + "label": "Participants", + "description": "Max participants per event (CSV cap). Edited via plan participant limit.", + "category": "portfolio", + "configKind": "participants_limit" +} diff --git a/src/config/permissions/payment_gateway_integration.json b/src/config/permissions/payment_gateway_integration.json new file mode 100644 index 0000000..2d3cbee --- /dev/null +++ b/src/config/permissions/payment_gateway_integration.json @@ -0,0 +1,6 @@ +{ + "key": "payment_gateway_integration", + "label": "Payment gateway integration", + "description": "", + "category": "payments" +} diff --git a/src/config/permissions/push_notifications.json b/src/config/permissions/push_notifications.json new file mode 100644 index 0000000..f6b729a --- /dev/null +++ b/src/config/permissions/push_notifications.json @@ -0,0 +1,6 @@ +{ + "key": "push_notifications", + "label": "Push Notifications", + "description": "", + "category": "comms" +} diff --git a/src/config/permissions/qna_with_moderation.json b/src/config/permissions/qna_with_moderation.json new file mode 100644 index 0000000..68ca2d2 --- /dev/null +++ b/src/config/permissions/qna_with_moderation.json @@ -0,0 +1,6 @@ +{ + "key": "qna_with_moderation", + "label": "QnA with moderation", + "description": "", + "category": "engagement" +} diff --git a/src/config/permissions/ratings_for_sessions_and_speakers.json b/src/config/permissions/ratings_for_sessions_and_speakers.json new file mode 100644 index 0000000..94fcc14 --- /dev/null +++ b/src/config/permissions/ratings_for_sessions_and_speakers.json @@ -0,0 +1,6 @@ +{ + "key": "ratings_for_sessions_and_speakers", + "label": "Ratings for sessions and speakers", + "description": "", + "category": "feedback" +} diff --git a/src/config/permissions/registration_forms_with_custom_fields.json b/src/config/permissions/registration_forms_with_custom_fields.json new file mode 100644 index 0000000..328895c --- /dev/null +++ b/src/config/permissions/registration_forms_with_custom_fields.json @@ -0,0 +1,6 @@ +{ + "key": "registration_forms_with_custom_fields", + "label": "Registration forms with Custom fields", + "description": "", + "category": "registration" +} diff --git a/src/config/permissions/speaker_management.json b/src/config/permissions/speaker_management.json new file mode 100644 index 0000000..9f9b9ee --- /dev/null +++ b/src/config/permissions/speaker_management.json @@ -0,0 +1,6 @@ +{ + "key": "speaker_management", + "label": "Speaker management", + "description": "", + "category": "agenda" +} diff --git a/src/config/permissions/ticketing_free_and_paid.json b/src/config/permissions/ticketing_free_and_paid.json new file mode 100644 index 0000000..1470a4c --- /dev/null +++ b/src/config/permissions/ticketing_free_and_paid.json @@ -0,0 +1,6 @@ +{ + "key": "ticketing_free_and_paid", + "label": "Ticketing free & paid", + "description": "", + "category": "payments" +} diff --git a/src/config/permissions/whatsapp_confirmation_reminders.json b/src/config/permissions/whatsapp_confirmation_reminders.json new file mode 100644 index 0000000..22e6315 --- /dev/null +++ b/src/config/permissions/whatsapp_confirmation_reminders.json @@ -0,0 +1,6 @@ +{ + "key": "whatsapp_confirmation_reminders", + "label": "WhatsApp confirmation & reminders", + "description": "", + "category": "comms" +} diff --git a/src/config/permissions/word_cloud.json b/src/config/permissions/word_cloud.json new file mode 100644 index 0000000..270dbaf --- /dev/null +++ b/src/config/permissions/word_cloud.json @@ -0,0 +1,6 @@ +{ + "key": "word_cloud", + "label": "Word Cloud", + "description": "", + "category": "engagement" +} diff --git a/src/config/planFeatureGroups.js b/src/config/planFeatureGroups.js new file mode 100644 index 0000000..344996a --- /dev/null +++ b/src/config/planFeatureGroups.js @@ -0,0 +1,50 @@ +/** + * Feature order for subscription plans (Manage & Engage). Keys match permissions/*.json. + * Used for catalog ordering and Super Admin plan editor grouping. + */ + +const MANAGE_KEYS = [ + 'participants', + 'event_website', + 'customise_event_website', + 'custom_domain', + 'invites_rsvp', + 'registration_forms_with_custom_fields', + 'custom_registration_workflow', + 'email_confirmation_reminders', + 'email_outreach_campaigns', + 'custom_badge_and_print_file_creation', + 'whatsapp_confirmation_reminders', + 'bring_your_wa_number', + 'custom_template_for_wa_messaging', + 'agenda_builder', + 'multi_track_agenda', + 'speaker_management', + 'abstract_management', + 'ticketing_free_and_paid', + 'collect_payment_using_your_upi_qr', + 'payment_gateway_integration', +]; + +const ENGAGE_KEYS = [ + 'qna_with_moderation', + 'live_polls', + 'multiple_quiz_modes', + 'word_cloud', + 'custom_theme', + 'ai_summarization', + 'ai_engagement_insights', + 'content_management', + 'ratings_for_sessions_and_speakers', + 'feedback_forms_and_surveys', + 'gamification', + 'push_notifications', +]; + +const COMBINED_PLAN_KEYS = [...MANAGE_KEYS, ...ENGAGE_KEYS]; + +module.exports = { + MANAGE_KEYS, + ENGAGE_KEYS, + COMBINED_PLAN_KEYS, +}; diff --git a/src/config/planFeatureMatrix.js b/src/config/planFeatureMatrix.js new file mode 100644 index 0000000..316608a --- /dev/null +++ b/src/config/planFeatureMatrix.js @@ -0,0 +1,127 @@ +/** + * Feature inclusion per plan — synced with eventbuzz_plans.csv (Manage / Engage rows). + * Source CSV columns: Free, Starter, Growth, Pro, Enterprise. + * + * Rules: cell "No" (or empty where the row is a yes/no row) → feature excluded for that tier. + * "Yes", tier text, "Add on", "Upto …", numeric caps → feature included. + * Value-only rows (Participants, Event Website, Email outreach) are always included when the tier exists; + * their values live in `defaultFeatureValuesByPlanId` + `defaultLimits.maxParticipants`. + * + * Tier key counts: free 17, starter 19, growth 27, pro/enterprise 32 (all permission keys). + */ +const { COMBINED_PLAN_KEYS } = require('./planFeatureGroups'); + +/** Keys not included in the Free tier (CSV column “Free” = No / empty). */ +const FREE_EXCLUDE = new Set([ + 'customise_event_website', + 'custom_domain', + 'custom_registration_workflow', + 'whatsapp_confirmation_reminders', + 'bring_your_wa_number', + 'custom_template_for_wa_messaging', + 'multi_track_agenda', + 'speaker_management', + 'abstract_management', + 'payment_gateway_integration', + 'custom_theme', + 'ai_summarization', + 'ai_engagement_insights', + 'content_management', + 'gamification', +]); + +/** Keys not included in Starter (CSV column “Starter”). */ +const STARTER_EXCLUDE = new Set([ + 'customise_event_website', + 'custom_domain', + 'custom_registration_workflow', + 'whatsapp_confirmation_reminders', + 'bring_your_wa_number', + 'custom_template_for_wa_messaging', + 'abstract_management', + 'payment_gateway_integration', + 'custom_theme', + 'ai_summarization', + 'ai_engagement_insights', + 'content_management', + 'gamification', +]); + +/** Keys not included in Growth (CSV column “Growth”) — Pro adds the remainder. */ +const GROWTH_EXCLUDE = new Set([ + 'custom_registration_workflow', + 'abstract_management', + 'bring_your_wa_number', + 'custom_template_for_wa_messaging', + 'custom_theme', +]); + +/** Event website tier per CSV row “Event Website”. Values stored on plans as `featureValues.event_website`. */ +const EVENT_WEBSITE_DEFAULT = { + free: 'event_page', + starter: 'event_microsite', + growth: 'event_microsite', + pro: 'custom_domain_microsite', + enterprise: 'custom_website', +}; + +/** Email outreach caps per “Email outreach campaigns” row; null = unlimited. */ +const EMAIL_OUTREACH_MAX_DEFAULT = { + free: 500, + starter: 5000, + growth: 10000, + pro: 10000, + enterprise: null, +}; + +function allKeysOrdered(FEATURES) { + return COMBINED_PLAN_KEYS.filter((k) => FEATURES[k]); +} + +function buildProEnterpriseKeys(FEATURES) { + return allKeysOrdered(FEATURES); +} + +/** + * Default structured values for features that are not simple on/off (CSV-driven). + * `participants` uses `defaultLimits.maxParticipants` elsewhere; this object holds the rest. + * + * @param {string} planId + */ +function defaultFeatureValuesByPlanId(planId) { + const ew = + EVENT_WEBSITE_DEFAULT[planId] != null ? EVENT_WEBSITE_DEFAULT[planId] : EVENT_WEBSITE_DEFAULT.free; + const maxEmails = + Object.prototype.hasOwnProperty.call(EMAIL_OUTREACH_MAX_DEFAULT, planId) ? + EMAIL_OUTREACH_MAX_DEFAULT[planId] + : EMAIL_OUTREACH_MAX_DEFAULT.free; + return { + event_website: ew, + email_outreach_campaigns: { maxEmails }, + }; +} + +/** + * @param {Record} FEATURES - loaded feature map + */ +function defaultFeatureKeysByPlanId(FEATURES) { + const proEnterprise = buildProEnterpriseKeys(FEATURES); + return { + free: COMBINED_PLAN_KEYS.filter((k) => FEATURES[k] && !FREE_EXCLUDE.has(k)), + starter: COMBINED_PLAN_KEYS.filter((k) => FEATURES[k] && !STARTER_EXCLUDE.has(k)), + growth: COMBINED_PLAN_KEYS.filter((k) => FEATURES[k] && !GROWTH_EXCLUDE.has(k)), + pro: [...proEnterprise], + enterprise: [...proEnterprise], + }; +} + +module.exports = { + FREE_EXCLUDE, + STARTER_EXCLUDE, + GROWTH_EXCLUDE, + EVENT_WEBSITE_DEFAULT, + EMAIL_OUTREACH_MAX_DEFAULT, + defaultFeatureKeysByPlanId, + defaultFeatureValuesByPlanId, + buildProEnterpriseKeys, +}; diff --git a/src/config/redisConnection.js b/src/config/redisConnection.js new file mode 100644 index 0000000..41b115d --- /dev/null +++ b/src/config/redisConnection.js @@ -0,0 +1,93 @@ +/** + * BullMQ / ioredis connection for concierge queues. + * DigitalOcean Managed Redis (port 25061) requires TLS — use rediss:// or REDIS_TLS=true. + */ +function parseRedisUrl(raw) { + const url = String(raw || 'redis://127.0.0.1:6379').trim(); + try { + return new URL(url); + } catch { + return null; + } +} + +function shouldUseTls(parsed) { + if (!parsed) return false; + const force = + String(process.env.REDIS_TLS || '').trim().toLowerCase() === '1' || + String(process.env.REDIS_TLS || '').trim().toLowerCase() === 'true'; + if (force) return true; + if (parsed.protocol === 'rediss:') return true; + if (parsed.port === '25061') return true; + if (parsed.hostname && parsed.hostname.includes('ondigitalocean.com')) return true; + return false; +} + +/** + * Options for BullMQ `connection` (ioredis). maxRetriesPerRequest must be null for workers. + * @returns {import('ioredis').RedisOptions} + */ +function getBullMQConnection() { + const raw = process.env.REDIS_URL || 'redis://127.0.0.1:6379'; + const parsed = parseRedisUrl(raw); + + const conn = { + maxRetriesPerRequest: null, + enableReadyCheck: true, + retryStrategy(times) { + return Math.min(times * 300, 8000); + }, + }; + + if (!parsed) { + return { ...conn, host: '127.0.0.1', port: 6379 }; + } + + Object.assign(conn, { + host: parsed.hostname, + port: Number(parsed.port) || 6379, + username: parsed.username || undefined, + password: parsed.password ? decodeURIComponent(parsed.password) : undefined, + }); + + if (parsed.pathname && parsed.pathname.length > 1) { + const db = Number(parsed.pathname.slice(1)); + if (!Number.isNaN(db)) conn.db = db; + } + + if (shouldUseTls(parsed)) { + conn.tls = {}; + if (!parsed.port) conn.port = 25061; + } + + return conn; +} + +/** + * Verify Redis is reachable at startup (workers). + */ +async function verifyRedisConnection() { + const Redis = require('ioredis'); + const opts = getBullMQConnection(); + const client = new Redis(opts); + + try { + await client.ping(); + const tlsNote = opts.tls ? ' (TLS)' : ''; + console.log(`[redis] connected: ${opts.host}:${opts.port}${tlsNote}`); + } catch (err) { + const hint = opts.tls + ? '' + : '\n Hint: DigitalOcean Managed Redis needs TLS. Set REDIS_TLS=true or use rediss:// in REDIS_URL.'; + throw new Error(`Redis connection failed: ${err.message}${hint}`); + } finally { + client.disconnect(); + } +} + +module.exports = { + getBullMQConnection, + verifyRedisConnection, + shouldUseTls, + parseRedisUrl, +}; diff --git a/src/config/subscriptionCatalog.js b/src/config/subscriptionCatalog.js index d93ad8b..f1e6f95 100644 --- a/src/config/subscriptionCatalog.js +++ b/src/config/subscriptionCatalog.js @@ -1,59 +1,310 @@ /** - * Code-first subscription catalog. Plans reference feature keys from FEATURES. - * Align Organization.subscription.plan with PLAN ids (free | pro | enterprise). + * Subscription catalog: feature metadata from permissions/*.json; plans from MongoDB `SubscriptionPlan`. + * Feature tiers per plan: `planFeatureMatrix.js` (from eventbuzz_plans.csv). Manage/Engage order: `planFeatureGroups.js`. */ -const FEATURES = { - basic_analytics: { key: 'basic_analytics', label: 'Basic analytics', description: 'Core event metrics' }, - advanced_analytics: { key: 'advanced_analytics', label: 'Advanced analytics', description: 'Deep insights' }, - custom_forms: { key: 'custom_forms', label: 'Custom forms', description: 'Advanced form builder' }, - email_support: { key: 'email_support', label: 'Email support', description: 'Standard email support' }, - priority_support: { key: 'priority_support', label: 'Priority support', description: 'Faster response' }, - white_label: { key: 'white_label', label: 'White-label', description: 'Remove EventBuzz branding' }, - api_access: { key: 'api_access', label: 'API access', description: 'REST API' }, - dedicated_support: { key: 'dedicated_support', label: 'Dedicated support', description: 'Named CSM' }, - content_library: { key: 'content_library', label: 'Content library', description: 'Portfolio media' }, +const { loadPermissionFeatures } = require('./loadPermissionFeatures'); +const { COMBINED_PLAN_KEYS } = require('./planFeatureGroups'); +const { defaultFeatureKeysByPlanId, defaultFeatureValuesByPlanId } = require('./planFeatureMatrix'); + +const { FEATURES } = loadPermissionFeatures(); + +/** Canonical order (Manage → Engage); only keys present in loaded FEATURES are kept. */ +const ALL_KEYS = COMBINED_PLAN_KEYS.filter((k) => FEATURES[k]); + +const PLAN_FEATURE_KEYS = defaultFeatureKeysByPlanId(FEATURES); + +/** Maps legacy catalog keys to canonical keys. */ +const LEGACY_FEATURE_TO_CANONICAL = { + basic_analytics: 'live_polls', + advanced_analytics: 'ai_engagement_insights', + custom_forms: 'registration_forms_with_custom_fields', + email_support: 'email_confirmation_reminders', + priority_support: 'email_outreach_campaigns', + white_label: 'custom_theme', + api_access: 'payment_gateway_integration', + dedicated_support: 'speaker_management', + content_library: 'content_management', }; -const PLANS = { +function canonicalizeFeatureKey(k) { + if (typeof k !== 'string' || !k.trim()) return null; + const trimmed = k.trim(); + if (FEATURES[trimmed]) return trimmed; + const mapped = LEGACY_FEATURE_TO_CANONICAL[trimmed]; + if (mapped && FEATURES[mapped]) return mapped; + return null; +} + +/** + * Default pricing for payment integration (amount = INR major units; Razorpay etc. can multiply by 100 for paise). + * Enterprise: contact sales, no fixed amount. + */ +const DEFAULT_PRICING = { + free: { amount: 0, currency: 'INR', interval: 'none', contactSales: false }, + starter: { amount: 1499, currency: 'INR', interval: 'month', contactSales: false }, + growth: { amount: 4999, currency: 'INR', interval: 'month', contactSales: false }, + pro: { amount: 9999, currency: 'INR', interval: 'month', contactSales: false }, + enterprise: { amount: null, currency: 'INR', interval: 'custom', contactSales: true }, +}; + +/** + * Five default commercial tiers (planId slugs). + */ +const DEFAULT_PLANS = { free: { id: 'free', + name: 'Free', + tagline: '₹0\nforever · up to 50 participants/event', + featureKeys: PLAN_FEATURE_KEYS.free, + defaultLimits: { maxEvents: null, maxParticipants: 50 }, + featureValues: defaultFeatureValuesByPlanId('free'), + pricing: DEFAULT_PRICING.free, + }, + starter: { + id: 'starter', name: 'Starter', - featureKeys: ['basic_analytics', 'email_support', 'content_library'], - defaultLimits: { maxEvents: 3, maxParticipants: 500 }, + tagline: '₹1,499\nper month · up to 300 participants', + featureKeys: PLAN_FEATURE_KEYS.starter, + defaultLimits: { maxEvents: null, maxParticipants: 300 }, + featureValues: defaultFeatureValuesByPlanId('starter'), + pricing: DEFAULT_PRICING.starter, + }, + growth: { + id: 'growth', + name: 'Growth', + tagline: '₹4,999\nper month · up to 500 participants', + featureKeys: PLAN_FEATURE_KEYS.growth, + defaultLimits: { maxEvents: null, maxParticipants: 500 }, + featureValues: defaultFeatureValuesByPlanId('growth'), + pricing: DEFAULT_PRICING.growth, }, pro: { id: 'pro', name: 'Pro', - featureKeys: [ - 'basic_analytics', - 'advanced_analytics', - 'custom_forms', - 'email_support', - 'priority_support', - 'content_library', - ], - defaultLimits: { maxEvents: 20, maxParticipants: 5000 }, + tagline: '₹9999\nper month · up to 1,000 participants', + featureKeys: PLAN_FEATURE_KEYS.pro, + defaultLimits: { maxEvents: null, maxParticipants: 1000 }, + featureValues: defaultFeatureValuesByPlanId('pro'), + pricing: DEFAULT_PRICING.pro, }, enterprise: { id: 'enterprise', name: 'Enterprise', - featureKeys: Object.keys(FEATURES), + tagline: 'Custom', + featureKeys: PLAN_FEATURE_KEYS.enterprise, defaultLimits: { maxEvents: null, maxParticipants: null }, + featureValues: defaultFeatureValuesByPlanId('enterprise'), + pricing: DEFAULT_PRICING.enterprise, }, }; +const DEFAULT_PLAN_ORDER = ['free', 'starter', 'growth', 'pro', 'enterprise']; + +/** Merged DB + defaults; null until warmPlanCache() runs. */ +let mergedPlansById = null; + +function mergeFeatureValuesFromDoc(doc, def, planId) { + const fallback = def?.featureValues || defaultFeatureValuesByPlanId(planId); + const p = doc?.featureValues && typeof doc.featureValues === 'object' ? doc.featureValues : {}; + const emIn = p.email_outreach_campaigns && typeof p.email_outreach_campaigns === 'object' ? p.email_outreach_campaigns : {}; + return { + event_website: + p.event_website !== undefined ? String(p.event_website) : fallback.event_website, + email_outreach_campaigns: { + maxEmails: + emIn.maxEmails !== undefined ? emIn.maxEmails : fallback.email_outreach_campaigns.maxEmails, + }, + }; +} + +function mergePricingFromDoc(doc, def) { + const d = def?.pricing || { + amount: null, + currency: 'INR', + interval: 'month', + contactSales: false, + }; + const p = doc?.pricing && typeof doc.pricing === 'object' ? doc.pricing : {}; + return { + amount: p.amount !== undefined ? p.amount : d.amount, + currency: p.currency != null ? String(p.currency) : d.currency, + interval: p.interval != null ? String(p.interval) : d.interval, + contactSales: typeof p.contactSales === 'boolean' ? p.contactSales : d.contactSales, + }; +} + +function buildMergedRow(planId, doc) { + const def = DEFAULT_PLANS[planId]; + if (!doc) { + return def || null; + } + const base = def || { + id: planId, + name: doc.name || planId, + tagline: doc.tagline || '', + featureKeys: Array.isArray(doc.featureKeys) ? doc.featureKeys : [], + defaultLimits: { maxEvents: null, maxParticipants: null }, + featureValues: defaultFeatureValuesByPlanId(planId), + pricing: { + amount: null, + currency: 'INR', + interval: 'month', + contactSales: false, + }, + }; + return { + id: planId, + name: doc.name != null && String(doc.name).trim() ? String(doc.name).trim() : base.name, + tagline: + doc.tagline != null && String(doc.tagline).trim() !== '' + ? String(doc.tagline) + : base.tagline || '', + featureKeys: + Array.isArray(doc.featureKeys) && doc.featureKeys.length ? doc.featureKeys : base.featureKeys, + defaultLimits: + doc.defaultLimits && typeof doc.defaultLimits === 'object' + ? { + maxEvents: + doc.defaultLimits.maxEvents !== undefined + ? doc.defaultLimits.maxEvents + : base.defaultLimits.maxEvents, + maxParticipants: + doc.defaultLimits.maxParticipants !== undefined + ? doc.defaultLimits.maxParticipants + : base.defaultLimits.maxParticipants, + } + : base.defaultLimits, + featureValues: mergeFeatureValuesFromDoc(doc, def, planId), + pricing: mergePricingFromDoc(doc, def || base), + }; +} + +/** + * Upsert each canonical plan; backfill tagline / pricing when missing (legacy DBs). + */ +async function upsertCanonicalPlans() { + const SubscriptionPlan = require('../models/SubscriptionPlan'); + for (const planId of DEFAULT_PLAN_ORDER) { + const d = DEFAULT_PLANS[planId]; + await SubscriptionPlan.findOneAndUpdate( + { planId }, + { + $setOnInsert: { + planId, + name: d.name, + tagline: d.tagline, + featureKeys: d.featureKeys, + defaultLimits: d.defaultLimits, + featureValues: d.featureValues, + pricing: d.pricing, + }, + }, + { upsert: true }, + ); + } + for (const planId of DEFAULT_PLAN_ORDER) { + const d = DEFAULT_PLANS[planId]; + const doc = await SubscriptionPlan.findOne({ planId }).lean(); + if (!doc) continue; + const patch = {}; + if (doc.tagline === undefined || doc.tagline === null || String(doc.tagline).trim() === '') { + patch.tagline = d.tagline; + patch.name = d.name; + patch.defaultLimits = d.defaultLimits; + } + if (!doc.pricing || typeof doc.pricing !== 'object') { + patch.pricing = d.pricing; + } + if (!doc.featureValues || typeof doc.featureValues !== 'object') { + patch.featureValues = d.featureValues; + } + if (Object.keys(patch).length) { + await SubscriptionPlan.updateOne({ planId }, { $set: patch }); + } + } +} + +async function warmPlanCache() { + const SubscriptionPlan = require('../models/SubscriptionPlan'); + await upsertCanonicalPlans(); + const rows = await SubscriptionPlan.find().lean(); + mergedPlansById = {}; + for (const row of rows) { + const planId = row.planId; + mergedPlansById[planId] = buildMergedRow(planId, row); + } + for (const planId of DEFAULT_PLAN_ORDER) { + if (!mergedPlansById[planId]) { + mergedPlansById[planId] = buildMergedRow(planId, null); + } + } +} + +function invalidatePlanCache() { + mergedPlansById = null; +} + +/** + * @param {string} planKey + */ +function getPlanForAccess(planKey) { + const key = typeof planKey === 'string' && planKey.trim() ? planKey.trim() : 'free'; + if (mergedPlansById && mergedPlansById[key]) return mergedPlansById[key]; + if (DEFAULT_PLANS[key]) return DEFAULT_PLANS[key]; + if (mergedPlansById && mergedPlansById.free) return mergedPlansById.free; + return DEFAULT_PLANS.free; +} + function listFeatures() { - return Object.values(FEATURES); + return ALL_KEYS.map((k) => FEATURES[k]).filter(Boolean); } -function listPlans() { - return Object.values(PLANS).map((p) => ({ - id: p.id, - name: p.name, - featureKeys: p.featureKeys, - defaultLimits: p.defaultLimits, - features: p.featureKeys.map((k) => FEATURES[k]).filter(Boolean), - })); +async function listPlans() { + if (!mergedPlansById) await warmPlanCache(); + const ids = sortPlanIds(Object.keys(mergedPlansById)); + return ids.map((id, tierIndex) => { + const p = mergedPlansById[id]; + const featureKeys = p.featureKeys; + return { + id: p.id, + name: p.name, + tagline: p.tagline || '', + /** Sort order for tier comparison (0 = lowest). Used to hide upgrades to lower tiers. */ + tierIndex, + pricing: p.pricing || { + amount: null, + currency: 'INR', + interval: 'month', + contactSales: false, + }, + featureKeys, + defaultLimits: p.defaultLimits, + featureValues: p.featureValues || defaultFeatureValuesByPlanId(id), + features: featureKeys.map((k) => FEATURES[k]).filter(Boolean), + }; + }); } -module.exports = { FEATURES, PLANS, listFeatures, listPlans }; +function sortPlanIds(ids) { + const set = new Set(ids); + const out = []; + for (const id of DEFAULT_PLAN_ORDER) { + if (set.has(id)) out.push(id); + } + const rest = [...set].filter((id) => !DEFAULT_PLAN_ORDER.includes(id)).sort(); + return [...out, ...rest]; +} + +module.exports = { + FEATURES, + ORDERED_KEYS: ALL_KEYS, + DEFAULT_PLANS, + LEGACY_FEATURE_TO_CANONICAL, + canonicalizeFeatureKey, + listFeatures, + listPlans, + getPlanForAccess, + warmPlanCache, + invalidatePlanCache, + sortPlanIds, +}; diff --git a/src/config/waTemplateMergeKeys.js b/src/config/waTemplateMergeKeys.js new file mode 100644 index 0000000..199dcae --- /dev/null +++ b/src/config/waTemplateMergeKeys.js @@ -0,0 +1,16 @@ +/** Allowed `variableBindings` merge keys — must match campaignMerge paths. */ +const ALLOWED_MERGE_KEYS = [ + 'contact.firstName', + 'contact.lastName', + 'contact.name', + 'contact.email', + 'contact.organization', + 'contact.phone', + 'event.name', + 'event.startDate', + 'event.endDate', + 'event.venue', + 'event.virtualLink', +]; + +module.exports = { ALLOWED_MERGE_KEYS }; diff --git a/src/controllers/attendance.controller.js b/src/controllers/attendance.controller.js new file mode 100644 index 0000000..1412af8 --- /dev/null +++ b/src/controllers/attendance.controller.js @@ -0,0 +1,302 @@ +const mongoose = require('mongoose'); +const Attendance = require('../models/Attendance'); +const AttendanceParticipant = require('../models/AttendanceParticipant'); +const Event = require('../models/Event'); +const Participant = require('../models/Participant'); +const { getMembership } = require('../utils/participantMembership'); +const { participantDisplayName } = require('../services/attendanceCheckIn.service'); +const { success, error } = require('../utils/response'); +const { + performCheckIn, + getAttendanceStats, + searchEventParticipantsForAttendance, +} = require('../services/attendanceCheckIn.service'); +const { buildAttendanceCheckInsCsv } = require('../utils/csvAttendance'); + +async function assertEventAccess(eventId, user) { + const event = await Event.findById(eventId); + if (!event) return { error: (h) => h.response(error('NOT_FOUND', 'Event not found')).code(404) }; + if ( + user.role !== 'super_admin' && + event.organization?.toString() !== (user.organization?._id || user.organization)?.toString() + ) { + return { error: (h) => h.response(error('FORBIDDEN', 'Access denied')).code(403) }; + } + return { event }; +} + +async function loadAttendanceForEvent(eventId, attendanceId) { + if (!mongoose.Types.ObjectId.isValid(String(attendanceId))) { + return { err: error('VALIDATION_ERROR', 'Invalid attendance id'), status: 400 }; + } + const attendance = await Attendance.findOne({ _id: attendanceId, event: eventId }).lean(); + if (!attendance) return { err: error('NOT_FOUND', 'Attendance session not found'), status: 404 }; + return { attendance }; +} + +const list = async (request, h) => { + const { eventId } = request.params; + const user = request.auth.credentials.user; + const r = await assertEventAccess(eventId, user); + if (r.error) return r.error(h); + + const items = await Attendance.find({ event: eventId }).sort({ createdAt: -1 }).lean(); + const withStats = await Promise.all( + items.map(async (item) => { + const stats = await getAttendanceStats(item._id); + return { ...item, ...stats }; + }) + ); + return h.response(success({ attendances: withStats })); +}; + +const create = async (request, h) => { + const { eventId } = request.params; + const user = request.auth.credentials.user; + const r = await assertEventAccess(eventId, user); + if (r.error) return r.error(h); + + const { name, description, settings } = request.payload; + const publicToken = Attendance.generatePublicToken(); + const item = await Attendance.create({ + event: eventId, + organization: r.event.organization, + name, + description: description || '', + settings: { + allowDuplicateCheckIn: Boolean(settings?.allowDuplicateCheckIn), + publicLinkEnabled: settings?.publicLinkEnabled !== false, + }, + publicToken, + status: 'active', + }); + return h.response(success({ ...item.toObject(), uniqueParticipants: 0, totalCheckedIn: 0 }, 'Attendance created', 201)).code(201); +}; + +const get = async (request, h) => { + const { eventId, id } = request.params; + const user = request.auth.credentials.user; + const r = await assertEventAccess(eventId, user); + if (r.error) return r.error(h); + + const loaded = await loadAttendanceForEvent(eventId, id); + if (loaded.err) return h.response(loaded.err).code(loaded.status); + + const stats = await getAttendanceStats(loaded.attendance._id); + return h.response(success({ ...loaded.attendance, ...stats })); +}; + +const update = async (request, h) => { + const { eventId, id } = request.params; + const user = request.auth.credentials.user; + const r = await assertEventAccess(eventId, user); + if (r.error) return r.error(h); + + const item = await Attendance.findOne({ _id: id, event: eventId }); + if (!item) return h.response(error('NOT_FOUND', 'Attendance session not found')).code(404); + + const { name, description, settings, status } = request.payload; + if (name !== undefined) item.name = name; + if (description !== undefined) item.description = description; + if (status !== undefined) item.status = status; + if (settings) { + item.settings = item.settings || {}; + if (settings.allowDuplicateCheckIn !== undefined) { + item.settings.allowDuplicateCheckIn = Boolean(settings.allowDuplicateCheckIn); + } + if (settings.publicLinkEnabled !== undefined) { + item.settings.publicLinkEnabled = Boolean(settings.publicLinkEnabled); + } + } + await item.save(); + const stats = await getAttendanceStats(item._id); + return h.response(success({ ...item.toObject(), ...stats }, 'Attendance updated')); +}; + +const remove = async (request, h) => { + const { eventId, id } = request.params; + const user = request.auth.credentials.user; + const r = await assertEventAccess(eventId, user); + if (r.error) return r.error(h); + + const item = await Attendance.findOne({ _id: id, event: eventId }); + if (!item) return h.response(error('NOT_FOUND', 'Attendance session not found')).code(404); + item.status = 'archived'; + await item.save(); + return h.response(success(null, 'Attendance archived')); +}; + +const regenerateToken = async (request, h) => { + const { eventId, id } = request.params; + const user = request.auth.credentials.user; + const r = await assertEventAccess(eventId, user); + if (r.error) return r.error(h); + + const item = await Attendance.findOne({ _id: id, event: eventId }); + if (!item) return h.response(error('NOT_FOUND', 'Attendance session not found')).code(404); + item.publicToken = Attendance.generatePublicToken(); + await item.save(); + return h.response(success({ publicToken: item.publicToken }, 'Public link token regenerated')); +}; + +const listCheckIns = async (request, h) => { + const { eventId, id } = request.params; + const user = request.auth.credentials.user; + const r = await assertEventAccess(eventId, user); + if (r.error) return r.error(h); + + const loaded = await loadAttendanceForEvent(eventId, id); + if (loaded.err) return h.response(loaded.err).code(loaded.status); + + const { page = 1, limit = 20 } = request.query; + const limitNum = Math.min(Math.max(parseInt(limit, 10) || 20, 1), 100); + const pageNum = Math.max(parseInt(page, 10) || 1, 1); + const skip = (pageNum - 1) * limitNum; + + const filter = { attendance: loaded.attendance._id }; + const total = await AttendanceParticipant.countDocuments(filter); + const rows = await AttendanceParticipant.find(filter) + .sort({ updatedAt: -1 }) + .skip(skip) + .limit(limitNum) + .lean(); + + const checkIns = rows.map((row) => ({ + _id: String(row._id), + participantId: String(row.participant), + firstName: row.firstName, + lastName: row.lastName, + email: row.email, + phone: row.phone, + checkIns: row.checkIns || [], + checkInCount: (row.checkIns || []).length, + lastCheckedInAt: + row.checkIns?.length > 0 ? row.checkIns[row.checkIns.length - 1].checkedInAt : null, + })); + + return h.response(success({ checkIns, total, page: pageNum, limit: limitNum })); +}; + +const searchParticipants = async (request, h) => { + const { eventId, id } = request.params; + const user = request.auth.credentials.user; + const r = await assertEventAccess(eventId, user); + if (r.error) return r.error(h); + + const loaded = await loadAttendanceForEvent(eventId, id); + if (loaded.err) return h.response(loaded.err).code(loaded.status); + + const { q } = request.query; + const hasQuery = Boolean(q && String(q).trim()); + const data = await searchEventParticipantsForAttendance({ + eventId, + organizationId: r.event.organization, + q, + attendanceId: loaded.attendance._id, + limit: hasQuery ? 50 : 5000, + maxCap: 5000, + }); + return h.response(success(data)); +}; + +const getParticipantPreview = async (request, h) => { + const { eventId, id, participantId } = request.params; + const user = request.auth.credentials.user; + const r = await assertEventAccess(eventId, user); + if (r.error) return r.error(h); + + const loaded = await loadAttendanceForEvent(eventId, id); + if (loaded.err) return h.response(loaded.err).code(loaded.status); + + if (!mongoose.Types.ObjectId.isValid(String(participantId))) { + return h.response(error('VALIDATION_ERROR', 'Invalid participant id')).code(400); + } + + const participant = await Participant.findById(participantId).lean(); + if (!participant) return h.response(error('NOT_FOUND', 'Participant not found')).code(404); + if (!getMembership(participant, eventId)) { + return h.response(error('BAD_REQUEST', 'Participant is not registered for this event')).code(400); + } + + const ap = await AttendanceParticipant.findOne({ + attendance: loaded.attendance._id, + participant: participantId, + }) + .select('checkIns') + .lean(); + + const checkIns = ap?.checkIns || []; + const checkInCount = checkIns.length; + const lastCheckedInAt = checkInCount > 0 ? checkIns[checkInCount - 1].checkedInAt : null; + + return h.response( + success({ + _id: String(participant._id), + firstName: participant.firstName, + lastName: participant.lastName, + email: participant.email, + phone: participant.phone, + displayName: participantDisplayName(participant), + alreadyCheckedIn: checkInCount > 0, + checkInCount, + lastCheckedInAt, + allowDuplicateCheckIn: Boolean(loaded.attendance.settings?.allowDuplicateCheckIn), + }) + ); +}; + +const checkIn = async (request, h) => { + const { eventId, id } = request.params; + const user = request.auth.credentials.user; + const r = await assertEventAccess(eventId, user); + if (r.error) return r.error(h); + + const { participantId } = request.payload; + const outcome = await performCheckIn({ + attendanceId: id, + eventId, + participantId, + source: 'dashboard', + checkedInBy: user._id?.toString() || user.id, + }); + if (outcome.err) return h.response(outcome.err).code(outcome.statusCode || 400); + return h.response(success(outcome.result, 'Checked in')); +}; + +const exportCsv = async (request, h) => { + const { eventId, id } = request.params; + const user = request.auth.credentials.user; + const r = await assertEventAccess(eventId, user); + if (r.error) return r.error(h); + + const loaded = await loadAttendanceForEvent(eventId, id); + if (loaded.err) return h.response(loaded.err).code(loaded.status); + + const rows = await AttendanceParticipant.find({ attendance: loaded.attendance._id }) + .sort({ updatedAt: -1 }) + .lean(); + + const safeName = String(loaded.attendance.name || 'attendance') + .replace(/[^a-z0-9-_]+/gi, '-') + .slice(0, 40); + const csv = buildAttendanceCheckInsCsv(loaded.attendance.name, rows); + return h + .response(csv) + .type('text/csv; charset=utf-8') + .header('Content-Disposition', `attachment; filename="${safeName}-check-ins.csv"`) + .code(200); +}; + +module.exports = { + list, + create, + get, + update, + remove, + regenerateToken, + listCheckIns, + searchParticipants, + getParticipantPreview, + checkIn, + exportCsv, +}; diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js index 21d83b7..cde9ff2 100644 --- a/src/controllers/auth.controller.js +++ b/src/controllers/auth.controller.js @@ -16,6 +16,50 @@ const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'default-refresh-se const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '15m'; const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || '7d'; +function resolveOrganizationName(g, { teamName, organizationName, firstName, lastName } = {}) { + const explicit = String(teamName || organizationName || '').trim(); + if (explicit) return explicit; + const fn = String(firstName || g.firstName || '').trim(); + const ln = String(lastName || g.lastName || '').trim(); + const fullName = [fn, ln].filter(Boolean).join(' '); + if (fullName) return `${fullName}'s Organization`; + const local = String(g.email || '').split('@')[0] || ''; + if (local) return `${local}'s Organization`; + return 'My Organization'; +} + +async function createGoogleUserAccount(g, opts = {}) { + const { teamName, firstName, lastName, phone, organizationName } = opts; + const existing = await User.findOne({ + $or: [{ email: g.email }, { googleId: g.sub }], + }); + if (existing) { + const err = new Error('Already registered'); + err.code = 'ALREADY_REGISTERED'; + throw err; + } + const org = await Organization.create({ + name: resolveOrganizationName(g, { teamName, organizationName, firstName, lastName }), + }); + const user = await User.create({ + email: g.email, + googleId: g.sub, + firstName: firstName || g.firstName, + lastName: lastName || g.lastName, + phone: phone || undefined, + profileImage: g.picture || undefined, + organization: org._id, + role: 'super_admin', + }); + await OrganizationUserMap.create({ + user: user._id, + organization: org._id, + role: 'super_admin', + eventAccess: [], + }); + return User.findById(user._id).select('-password').populate('organization'); +} + const register = async (request, h) => { try { const { email, password, firstName, lastName, teamName } = request.payload; @@ -90,7 +134,20 @@ const googleLogin = async (request, h) => { user = await User.findOne({ email: g.email }).populate('organization'); } if (!user) { - return h.response(error('NOT_REGISTERED', 'No account for this Google user. Register first.')).code(400); + try { + user = await createGoogleUserAccount(g, {}); + } catch (createErr) { + if (createErr.code === 'ALREADY_REGISTERED') { + user = await User.findOne({ + $or: [{ email: g.email }, { googleId: g.sub }], + }).populate('organization'); + } else { + throw createErr; + } + } + if (!user) { + return h.response(error('VALIDATION_ERROR', 'Could not create account')).code(400); + } } if (user.googleId && user.googleId !== g.sub) { return h.response(error('ACCOUNT_MISMATCH', 'This email is linked to a different Google account.')).code(400); @@ -137,36 +194,23 @@ const googleRegister = async (request, h) => { return h.response(error('UNAUTHORIZED', e.message || 'Invalid Google credential')).code(401); } const { teamName, firstName, lastName, phone } = request.payload; - const organizationName = teamName || request.payload.organizationName; - if (!organizationName || !String(organizationName).trim()) { - return h.response(error('VALIDATION_ERROR', 'Team Name is required')).code(400); + let populated; + try { + populated = await createGoogleUserAccount(g, { + teamName, + firstName, + lastName, + phone, + organizationName: request.payload.organizationName, + }); + } catch (createErr) { + if (createErr.code === 'ALREADY_REGISTERED') { + return h.response(error('VALIDATION_ERROR', 'Already registered — sign in with Google')).code(400); + } + throw createErr; } - const existing = await User.findOne({ - $or: [{ email: g.email }, { googleId: g.sub }], - }); - if (existing) { - return h.response(error('VALIDATION_ERROR', 'Already registered — sign in with Google')).code(400); - } - const org = await Organization.create({ name: String(organizationName).trim() }); - const user = await User.create({ - email: g.email, - googleId: g.sub, - firstName: firstName || g.firstName, - lastName: lastName || g.lastName, - phone: phone || undefined, - profileImage: g.picture || undefined, - organization: org._id, - role: 'super_admin', - }); - await OrganizationUserMap.create({ - user: user._id, - organization: org._id, - role: 'super_admin', - eventAccess: [], - }); - const token = jwt.sign({ id: user._id }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN }); - const refreshToken = jwt.sign({ id: user._id }, JWT_REFRESH_SECRET, { expiresIn: JWT_REFRESH_EXPIRES_IN }); - const populated = await User.findById(user._id).select('-password').populate('organization'); + const token = jwt.sign({ id: populated._id }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN }); + const refreshToken = jwt.sign({ id: populated._id }, JWT_REFRESH_SECRET, { expiresIn: JWT_REFRESH_EXPIRES_IN }); return h.response(success({ user: populated, token, diff --git a/src/controllers/badgeBatch.controller.js b/src/controllers/badgeBatch.controller.js new file mode 100644 index 0000000..e1f55d7 --- /dev/null +++ b/src/controllers/badgeBatch.controller.js @@ -0,0 +1,182 @@ +const mongoose = require('mongoose'); +const BadgeBatch = require('../models/BadgeBatch'); +const Event = require('../models/Event'); +const Organization = require('../models/Organization'); +const { success, error } = require('../utils/response'); +const { assertFeature } = require('../services/featureAccess'); + +const MAX_BADGE_RECIPIENTS = parseInt(process.env.BADGE_BATCH_MAX_RECIPIENTS || '1500', 10); + +function userOrgId(user) { + return user.organization?._id || user.organization; +} + +function resolveOrganizationId(user, payloadOrg) { + if (user.role === 'super_admin' && payloadOrg && mongoose.Types.ObjectId.isValid(String(payloadOrg))) { + return new mongoose.Types.ObjectId(String(payloadOrg)); + } + const oid = userOrgId(user); + if (!oid) return null; + return oid instanceof mongoose.Types.ObjectId ? oid : new mongoose.Types.ObjectId(String(oid)); +} + +function canManageBadges(user) { + return user.role === 'super_admin' || user.role === 'admin' || user.role === 'moderator'; +} + +async function allowedEventIdsForOrg(user) { + const oid = userOrgId(user); + if (user.role === 'super_admin') { + const all = await Event.find({}).select('_id').lean(); + return all.map((e) => e._id); + } + if (!oid) return []; + const evs = await Event.find({ organization: oid }).select('_id').lean(); + return evs.map((e) => e._id); +} + +const list = async (request, h) => { + const user = request.auth.credentials.user; + const orgId = resolveOrganizationId(user, request.query.organization); + if (!orgId) return h.response(error('VALIDATION_ERROR', 'Organization required')).code(400); + + const filter = { organization: orgId }; + if (request.query.event && mongoose.Types.ObjectId.isValid(String(request.query.event))) { + filter.event = new mongoose.Types.ObjectId(String(request.query.event)); + } + + const { page = 1, limit = 20 } = request.query; + const lim = Math.min(parseInt(limit, 10) || 20, 100); + const skip = (Math.max(parseInt(page, 10) || 1, 1) - 1) * lim; + + const [batches, total] = await Promise.all([ + BadgeBatch.find(filter).sort({ createdAt: -1 }).skip(skip).limit(lim).populate('event', 'name').lean(), + BadgeBatch.countDocuments(filter), + ]); + + return h.response(success({ batches, total, page: parseInt(page, 10), limit: lim })); +}; + +const get = async (request, h) => { + const user = request.auth.credentials.user; + const orgId = resolveOrganizationId(user, request.query.organization); + if (!orgId) return h.response(error('VALIDATION_ERROR', 'Organization required')).code(400); + + const batch = await BadgeBatch.findById(request.params.id).populate('event', 'name').populate('createdBy', 'firstName lastName email'); + if (!batch) return h.response(error('NOT_FOUND', 'Badge batch not found')).code(404); + if (batch.organization.toString() !== orgId.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + return h.response(success(batch)); +}; + +const create = async (request, h) => { + const user = request.auth.credentials.user; + if (!canManageBadges(user)) { + return h.response(error('FORBIDDEN', 'Insufficient permissions to create badge batches')).code(403); + } + + const orgId = resolveOrganizationId(user, request.payload.organization); + if (!orgId) return h.response(error('VALIDATION_ERROR', 'Organization required')).code(400); + + const orgDoc = await Organization.findById(orgId).lean(); + if (!orgDoc) return h.response(error('NOT_FOUND', 'Organization not found')).code(404); + if (!assertFeature(orgDoc, 'custom_badge_and_print_file_creation')) { + return h.response(error('FORBIDDEN', 'This feature is not included in your plan.')).code(403); + } + + const p = request.payload; + if (!p.includeFaculty && !p.includeParticipants) { + return h.response(error('VALIDATION_ERROR', 'Select at least Faculty and/or Participants')).code(400); + } + if (p.includeParticipants && p.participantScope === 'participant_list' && !p.participantListId) { + return h.response(error('VALIDATION_ERROR', 'Select a contact list for participants')).code(400); + } + if (p.backgroundMode === 'image' && !String(p.backgroundImageUrl || '').trim()) { + return h.response(error('VALIDATION_ERROR', 'Background image URL is required when using image background')).code(400); + } + + const event = await Event.findById(p.event).select('organization').lean(); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + if (String(event.organization) !== orgId.toString()) { + return h.response(error('FORBIDDEN', 'Event does not belong to this organisation')).code(403); + } + + const allowed = await allowedEventIdsForOrg(user); + if (!allowed.some((id) => String(id) === String(p.event))) { + return h.response(error('FORBIDDEN', 'You cannot create badge batches for this event')).code(403); + } + + const { resolveBadgeRecipients } = require('../utils/badgeRecipients'); + const prelim = await resolveBadgeRecipients( + { + includeFaculty: Boolean(p.includeFaculty), + includeParticipants: Boolean(p.includeParticipants), + participantScope: p.participantScope, + participantListId: p.participantListId, + }, + p.event, + event.organization, + allowed, + ); + if (prelim.error) { + return h.response(error(prelim.error.code, prelim.error.message)).code(400); + } + const count = (prelim.rows || []).length; + if (count > MAX_BADGE_RECIPIENTS) { + return h + .response( + error( + 'VALIDATION_ERROR', + `Too many recipients (${count}). Maximum per batch is ${MAX_BADGE_RECIPIENTS}. Narrow your lists or contact support.`, + ), + ) + .code(400); + } + if (count === 0) { + return h.response(error('VALIDATION_ERROR', 'No recipients match your selection for this event')).code(400); + } + + const listOid = + p.participantListId && mongoose.Types.ObjectId.isValid(String(p.participantListId)) + ? new mongoose.Types.ObjectId(String(p.participantListId)) + : undefined; + + const config = { + sizePreset: p.sizePreset, + widthMm: p.widthMm, + heightMm: p.heightMm, + orientation: p.orientation, + includeFaculty: Boolean(p.includeFaculty), + includeParticipants: Boolean(p.includeParticipants), + participantScope: p.participantScope, + participantListId: listOid, + backgroundMode: p.backgroundMode, + backgroundImageUrl: String(p.backgroundImageUrl || '').trim(), + backgroundColor: p.backgroundColor || '#0B4DD1', + fields: Array.isArray(p.fields) ? p.fields : [], + outputFormat: p.outputFormat, + badgeTypeLabel: p.badgeTypeLabel || 'Attendee', + }; + + const batch = await BadgeBatch.create({ + organization: orgId, + event: p.event, + createdBy: user._id, + name: p.name.trim(), + status: 'pending', + config, + }); + + return h + .response( + success( + batch, + 'We have received your request. Badge generation runs in the background; refresh this page in a few minutes to download the zip.', + 201, + ), + ) + .code(201); +}; + +module.exports = { list, get, create }; diff --git a/src/controllers/campaign.controller.js b/src/controllers/campaign.controller.js index 31e1b15..3e0e4c9 100644 --- a/src/controllers/campaign.controller.js +++ b/src/controllers/campaign.controller.js @@ -1,27 +1,64 @@ +const mongoose = require('mongoose'); const MessagingCampaign = require('../models/MessagingCampaign'); +const EmailQueue = require('../models/EmailQueue'); +const WhatsAppQueue = require('../models/WhatsAppQueue'); const Event = require('../models/Event'); +const DesignTemplate = require('../models/DesignTemplate'); +const WhatsAppTemplate = require('../models/WhatsAppTemplate'); +const Participant = require('../models/Participant'); const { success, error } = require('../utils/response'); +const { mergeCampaignContent } = require('../utils/campaignMerge'); +const { resolveRecipientsForCampaign, resolveRecipientsForWhatsAppCampaign } = require('../utils/campaignRecipients'); +const { assertWhatsAppCampaignContentReady, buildWhatsAppTemplatePayload } = require('../utils/waTemplateSendParams'); +const { getOrgWhatsAppCredentials, assertPhoneNumberOnWaba } = require('../utils/metaWhatsApp'); +const { normalizeWhatsAppTo } = require('../utils/phone'); +const { getCampaignContentObject } = require('../utils/campaignContent'); + +async function getEventForUser(eventId, user) { + const event = await Event.findById(eventId).lean(); + if (!event) return { error: { code: 'NOT_FOUND', message: 'Event not found', status: 404 } }; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return { error: { code: 'FORBIDDEN', message: 'Access denied', status: 403 } }; + } + return { event }; +} + +async function allowedEventIdsForOrg(user) { + const oid = user.organization?._id || user.organization; + if (user.role === 'super_admin') { + const all = await Event.find({}).select('_id').lean(); + return all.map((e) => e._id); + } + if (!oid) return []; + const evs = await Event.find({ organization: oid }).select('_id').lean(); + return evs.map((e) => e._id); +} const list = async (request, h) => { const { eventId } = request.params; - const event = await Event.findById(eventId); - if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); - const user = request.auth.credentials.user; - if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { - return h.response(error('FORBIDDEN', 'Access denied')).code(403); - } + const { error: err } = await getEventForUser(eventId, request.auth.credentials.user); + if (err) return h.response(error(err.code, err.message)).code(err.status); const campaigns = await MessagingCampaign.find({ event: eventId }).populate('createdBy').sort({ createdAt: -1 }).lean(); return h.response(success({ campaigns })); }; +/** GET /api/campaigns?channel=email */ +const listByOrg = async (request, h) => { + const user = request.auth.credentials.user; + const channel = request.query.channel; + const ids = await allowedEventIdsForOrg(user); + if (!ids.length) return h.response(success({ campaigns: [] })); + const q = { event: { $in: ids } }; + if (channel) q.channel = channel; + const campaigns = await MessagingCampaign.find(q).populate('event', 'name startDate').populate('createdBy').sort({ createdAt: -1 }).lean(); + return h.response(success({ campaigns })); +}; + const create = async (request, h) => { const { eventId } = request.params; - const event = await Event.findById(eventId); - if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const { error: err } = await getEventForUser(eventId, request.auth.credentials.user); + if (err) return h.response(error(err.code, err.message)).code(err.status); const user = request.auth.credentials.user; - if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { - return h.response(error('FORBIDDEN', 'Access denied')).code(403); - } const campaign = await MessagingCampaign.create({ ...request.payload, event: eventId, @@ -33,26 +70,33 @@ const create = async (request, h) => { const get = async (request, h) => { const campaign = await MessagingCampaign.findById(request.params.id).populate('event createdBy'); if (!campaign) return h.response(error('NOT_FOUND', 'Campaign not found')).code(404); - const event = await Event.findById(campaign.event); - const user = request.auth.credentials.user; - if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { - return h.response(error('FORBIDDEN', 'Access denied')).code(403); - } + const { error: err } = await getEventForUser(campaign.event, request.auth.credentials.user); + if (err) return h.response(error(err.code, err.message)).code(err.status); return h.response(success(campaign)); }; const update = async (request, h) => { const campaign = await MessagingCampaign.findById(request.params.id); if (!campaign) return h.response(error('NOT_FOUND', 'Campaign not found')).code(404); - const event = await Event.findById(campaign.event); - const user = request.auth.credentials.user; - if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { - return h.response(error('FORBIDDEN', 'Access denied')).code(403); + const { error: err } = await getEventForUser(campaign.event, request.auth.credentials.user); + if (err) return h.response(error(err.code, err.message)).code(err.status); + if (campaign.status === 'stopped') { + return h.response(error('VALIDATION_ERROR', 'Cannot update a stopped campaign')).code(400); } - if (campaign.status === 'sent') { - return h.response(error('VALIDATION_ERROR', 'Cannot update sent campaign')).code(400); + if (campaign.status !== 'draft') { + return h.response(error('VALIDATION_ERROR', 'Only draft campaigns can be edited')).code(400); + } + const p = request.payload; + if (p.name != null) campaign.name = p.name; + if (p.channel != null) campaign.channel = p.channel; + if (p.type != null) campaign.type = p.type; + if (p.triggerEvent != null) campaign.triggerEvent = p.triggerEvent; + if (p.recipients != null) campaign.recipients = p.recipients; + if (p.content != null) { + const cur = campaign.content && typeof campaign.content.toObject === 'function' ? campaign.content.toObject() : campaign.content || {}; + campaign.content = { ...cur, ...p.content }; + campaign.markModified('content'); } - Object.assign(campaign, request.payload); await campaign.save(); return h.response(success(campaign)); }; @@ -60,34 +104,287 @@ const update = async (request, h) => { const remove = async (request, h) => { const campaign = await MessagingCampaign.findById(request.params.id); if (!campaign) return h.response(error('NOT_FOUND', 'Campaign not found')).code(404); - const event = await Event.findById(campaign.event); - const user = request.auth.credentials.user; - if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { - return h.response(error('FORBIDDEN', 'Access denied')).code(403); + const { error: err } = await getEventForUser(campaign.event, request.auth.credentials.user); + if (err) return h.response(error(err.code, err.message)).code(err.status); + if (!['draft', 'stopped'].includes(campaign.status)) { + return h.response(error('VALIDATION_ERROR', 'Delete only allowed for draft or stopped campaigns')).code(400); } + await EmailQueue.deleteMany({ campaign: campaign._id }); + await WhatsAppQueue.deleteMany({ campaign: campaign._id }); await campaign.deleteOne(); return h.response(success(null, 'Campaign deleted')).code(200); }; + +async function getTemplateBodies(campaign) { + let subject = campaign.content?.subject || ''; + let body = campaign.content?.body || ''; + const tplId = campaign.content?.template; + if (tplId && mongoose.Types.ObjectId.isValid(String(tplId))) { + const tpl = await DesignTemplate.findById(tplId).lean(); + if (tpl?.content != null) { + const c = tpl.content; + if (typeof c === 'string') body = body || c; + else if (c && typeof c.html === 'string') body = body || c.html; + if (c && typeof c === 'object') { + const subjTpl = String(c.subjectLine || c.subject || '').trim(); + if (subjTpl) subject = subject || subjTpl; + } + } + } + return { subject, body }; +} + +/** When using a design template only, subject may be omitted; merge still needs a line. */ +function resolveSubjectForSend(campaignDoc, subjTpl) { + const s = String(subjTpl || '').trim(); + if (s) return s; + const n = campaignDoc.name && String(campaignDoc.name).trim(); + if (n) return n; + return 'News from {{event.name}}'; +} + +async function enqueueCampaignEmails(campaignDoc, processAfter) { + const eventDoc = await Event.findById(campaignDoc.event).lean(); + if (!eventDoc) return { error: { code: 'NOT_FOUND', message: 'Event not found' } }; + const idsForList = await Event.find({ organization: eventDoc.organization }).select('_id').lean().then((r) => r.map((x) => x._id)); + + const tenantOrg = eventDoc.organization; + const resolved = await resolveRecipientsForCampaign(campaignDoc, tenantOrg, idsForList); + if (resolved.error) return resolved; + const rows = resolved.rows || []; + const { subject: subjTpl, body: bodyTpl } = await getTemplateBodies(campaignDoc); + if (!String(bodyTpl).trim()) { + return { error: { code: 'VALIDATION_ERROR', message: 'Message body is required (add content or pick a design template with HTML)' } }; + } + const subjResolved = resolveSubjectForSend(campaignDoc, subjTpl); + + const seen = new Set(); + const docs = []; + for (const { participant, flat } of rows) { + const email = String(flat.email || '').trim().toLowerCase(); + if (!email || seen.has(email)) continue; + seen.add(email); + const { subject, body } = mergeCampaignContent(subjResolved, bodyTpl, flat, eventDoc); + docs.push({ + campaign: campaignDoc._id, + event: campaignDoc.event, + organization: eventDoc.organization, + participant: participant._id, + to: email, + subject, + body, + status: 'pending', + processAfter: processAfter || new Date(), + }); + } + + if (!docs.length) { + return { error: { code: 'VALIDATION_ERROR', message: 'No recipients with email addresses' } }; + } + + await EmailQueue.insertMany(docs); + return { count: docs.length }; +} + +/** + * @param {import('mongoose').Document} campaignDoc + * @param {Date} [processAfter] + */ +async function enqueueWhatsAppSends(campaignDoc, processAfter) { + const eventDoc = await Event.findById(campaignDoc.event).lean(); + if (!eventDoc) return { error: { code: 'NOT_FOUND', message: 'Event not found' } }; + const idsForList = await Event.find({ organization: eventDoc.organization }) + .select('_id') + .lean() + .then((r) => r.map((x) => x._id)); + + const content = getCampaignContentObject(campaignDoc); + const tplId = content.whatsappTemplateId; + if (!tplId || !mongoose.Types.ObjectId.isValid(String(tplId))) { + return { error: { code: 'VALIDATION_ERROR', message: 'Select a WhatsApp template (content.whatsappTemplateId).' } }; + } + const tpl = await WhatsAppTemplate.findById(tplId).lean(); + if (!tpl) return { error: { code: 'NOT_FOUND', message: 'WhatsApp template not found' } }; + if (String(tpl.organization) !== String(eventDoc.organization)) { + return { error: { code: 'FORBIDDEN', message: 'Template belongs to another organisation' } }; + } + const ready = assertWhatsAppCampaignContentReady(tpl, content); + if (!ready.ok) { + return { error: { code: 'VALIDATION_ERROR', message: ready.message } }; + } + + const creds = await getOrgWhatsAppCredentials(String(eventDoc.organization)); + if (!creds || !creds.accessToken) { + return { error: { code: 'VALIDATION_ERROR', message: 'WhatsApp is not configured (access token).' } }; + } + if (!String(creds.phoneNumberId || '').trim()) { + return { + error: { + code: 'VALIDATION_ERROR', + message: 'Set WhatsApp phone number ID in Settings or META_WHATSAPP_DEFAULT_PHONE_NUMBER_ID.', + }, + }; + } + const phoneChk = await assertPhoneNumberOnWaba(creds.wabaId, creds.phoneNumberId, creds.accessToken); + if (!phoneChk.ok) { + return { error: { code: 'VALIDATION_ERROR', message: phoneChk.message } }; + } + + const tenantOrg = eventDoc.organization; + const resolved = await resolveRecipientsForWhatsAppCampaign(campaignDoc, tenantOrg, idsForList); + if (resolved.error) return resolved; + const rows = resolved.rows || []; + if (!rows.length) { + return { error: { code: 'VALIDATION_ERROR', message: 'No recipients with WhatsApp phone numbers' } }; + } + + const when = processAfter || new Date(); + const docs = rows.map(({ participant, flat }) => ({ + campaign: campaignDoc._id, + event: campaignDoc.event, + organization: eventDoc.organization, + participant: participant._id, + to: normalizeWhatsAppTo(flat.phone), + status: 'pending', + processAfter: when, + })); + + if (rows[0]) { + try { + buildWhatsAppTemplatePayload(tpl, content, rows[0].flat, eventDoc); + } catch (e) { + return { error: { code: 'VALIDATION_ERROR', message: e.message || 'Invalid template or merge for send' } }; + } + } + + await WhatsAppQueue.insertMany(docs); + return { count: docs.length }; +} + +/** + * Apply optional name/recipients/content to a draft (same rules as PUT) before send. + * @param {import('mongoose').Document} campaign + * @param {object} p + */ +async function applyDraftPatchIfPresent(campaign, p) { + if (!p || typeof p !== 'object') return { ok: true }; + const has = + (p.name !== undefined && p.name !== null) || + p.recipients !== undefined || + p.content !== undefined; + if (!has) return { ok: true }; + if (campaign.status !== 'draft') { + return { + ok: false, + message: + 'Cannot update campaign fields in the same request as Send unless the campaign is a draft. Save changes first or cancel the schedule.', + }; + } + if (p.name != null) campaign.name = p.name; + if (p.recipients != null) campaign.recipients = p.recipients; + if (p.content != null) { + const cur = + campaign.content && typeof campaign.content.toObject === 'function' + ? campaign.content.toObject() + : campaign.content || {}; + campaign.content = { ...cur, ...p.content }; + campaign.markModified('content'); + } + await campaign.save(); + return { ok: true }; +} + const send = async (request, h) => { const campaign = await MessagingCampaign.findById(request.params.id); if (!campaign) return h.response(error('NOT_FOUND', 'Campaign not found')).code(404); - campaign.status = 'sending'; - await campaign.save(); - // TODO: queue actual send (nodemailer / WhatsApp API) - campaign.status = 'sent'; - campaign.sentAt = new Date(); - campaign.stats = campaign.stats || { total: 0, sent: 0, delivered: 0, opened: 0, clicked: 0, failed: 0 }; - campaign.stats.sent = campaign.stats.total || 0; - await campaign.save(); - return h.response(success(campaign, 'Campaign sent')); + const { error: err } = await getEventForUser(campaign.event, request.auth.credentials.user); + if (err) return h.response(error(err.code, err.message)).code(err.status); + if (campaign.status === 'stopped') { + return h.response(error('VALIDATION_ERROR', 'Campaign is stopped')).code(400); + } + if (!['draft', 'scheduled'].includes(campaign.status)) { + return h.response(error('VALIDATION_ERROR', 'Campaign cannot be sent in current status')).code(400); + } + const patch = await applyDraftPatchIfPresent(campaign, request.payload); + if (!patch.ok) return h.response(error('VALIDATION_ERROR', patch.message)).code(400); + if (campaign.channel === 'email') { + await EmailQueue.deleteMany({ campaign: campaign._id, status: 'pending' }); + const enq = await enqueueCampaignEmails(campaign, new Date()); + if (enq.error) return h.response(error(enq.error.code, enq.error.message)).code(400); + campaign.status = 'sending'; + campaign.scheduledFor = undefined; + campaign.stats = { + total: enq.count, + sent: 0, + delivered: 0, + opened: 0, + clicked: 0, + failed: 0, + }; + await campaign.save(); + return h.response(success(campaign, 'Campaign queued for sending')); + } + if (campaign.channel === 'whatsapp') { + await WhatsAppQueue.deleteMany({ campaign: campaign._id, status: 'pending' }); + const enq = await enqueueWhatsAppSends(campaign, new Date()); + if (enq.error) return h.response(error(enq.error.code, enq.error.message)).code(400); + campaign.status = 'sending'; + campaign.scheduledFor = undefined; + campaign.stats = { + total: enq.count, + sent: 0, + delivered: 0, + opened: 0, + clicked: 0, + failed: 0, + }; + await campaign.save(); + return h.response(success(campaign, 'WhatsApp campaign queued for sending')); + } + return h.response(error('VALIDATION_ERROR', 'Unknown campaign channel')).code(400); }; const schedule = async (request, h) => { const campaign = await MessagingCampaign.findById(request.params.id); if (!campaign) return h.response(error('NOT_FOUND', 'Campaign not found')).code(404); + const { error: err } = await getEventForUser(campaign.event, request.auth.credentials.user); + if (err) return h.response(error(err.code, err.message)).code(err.status); + if (campaign.status === 'stopped') { + return h.response(error('VALIDATION_ERROR', 'Campaign is stopped')).code(400); + } + if (!['draft'].includes(campaign.status)) { + return h.response(error('VALIDATION_ERROR', 'Only draft campaigns can be scheduled')).code(400); + } + if (campaign.channel !== 'email' && campaign.channel !== 'whatsapp') { + return h.response(error('VALIDATION_ERROR', 'Only email and WhatsApp campaigns can be scheduled')).code(400); + } + + const scheduledFor = new Date(request.payload.scheduledFor); + if (Number.isNaN(scheduledFor.getTime()) || scheduledFor.getTime() < Date.now()) { + return h.response(error('VALIDATION_ERROR', 'scheduledFor must be a future date')).code(400); + } + + let enq; + if (campaign.channel === 'email') { + await EmailQueue.deleteMany({ campaign: campaign._id, status: 'pending' }); + enq = await enqueueCampaignEmails(campaign, scheduledFor); + } else { + await WhatsAppQueue.deleteMany({ campaign: campaign._id, status: 'pending' }); + enq = await enqueueWhatsAppSends(campaign, scheduledFor); + } + if (enq.error) return h.response(error(enq.error.code, enq.error.message)).code(400); + campaign.status = 'scheduled'; - campaign.scheduledFor = request.payload.scheduledFor; + campaign.scheduledFor = scheduledFor; + campaign.stats = { + total: enq.count, + sent: 0, + delivered: 0, + opened: 0, + clicked: 0, + failed: 0, + }; await campaign.save(); return h.response(success(campaign, 'Campaign scheduled')); }; @@ -95,19 +392,107 @@ const schedule = async (request, h) => { const cancel = async (request, h) => { const campaign = await MessagingCampaign.findById(request.params.id); if (!campaign) return h.response(error('NOT_FOUND', 'Campaign not found')).code(404); + const { error: err } = await getEventForUser(campaign.event, request.auth.credentials.user); + if (err) return h.response(error(err.code, err.message)).code(err.status); + if (campaign.status === 'stopped') { + return h.response(error('VALIDATION_ERROR', 'Stopped campaigns cannot be unscheduled this way')).code(400); + } if (campaign.status !== 'scheduled') { return h.response(error('VALIDATION_ERROR', 'Only scheduled campaigns can be cancelled')).code(400); } + await EmailQueue.updateMany({ campaign: campaign._id, status: 'pending' }, { $set: { status: 'cancelled' } }); + await WhatsAppQueue.updateMany({ campaign: campaign._id, status: 'pending' }, { $set: { status: 'cancelled' } }); campaign.status = 'draft'; campaign.scheduledFor = undefined; + campaign.stats = undefined; await campaign.save(); return h.response(success(campaign, 'Campaign cancelled')); }; -const analytics = async (request, h) => { - const campaign = await MessagingCampaign.findById(request.params.id).select('stats name status sentAt'); +const stop = async (request, h) => { + const campaign = await MessagingCampaign.findById(request.params.id); if (!campaign) return h.response(error('NOT_FOUND', 'Campaign not found')).code(404); - return h.response(success(campaign)); + const { error: err } = await getEventForUser(campaign.event, request.auth.credentials.user); + if (err) return h.response(error(err.code, err.message)).code(err.status); + if (campaign.status === 'stopped') { + return h.response(success(campaign, 'Already stopped')); + } + campaign.status = 'stopped'; + campaign.stoppedAt = new Date(); + campaign.scheduledFor = undefined; + await campaign.save(); + await EmailQueue.updateMany( + { campaign: campaign._id, status: { $in: ['pending', 'processing'] } }, + { $set: { status: 'cancelled' } } + ); + await WhatsAppQueue.updateMany( + { campaign: campaign._id, status: { $in: ['pending', 'processing'] } }, + { $set: { status: 'cancelled' } } + ); + return h.response(success(campaign, 'Campaign stopped')); }; -module.exports = { list, create, get, update, remove, send, schedule, cancel, analytics }; +const analytics = async (request, h) => { + const campaign = await MessagingCampaign.findById(request.params.id) + .select('stats name status sentAt stoppedAt event channel') + .lean(); + if (!campaign) return h.response(error('NOT_FOUND', 'Campaign not found')).code(404); + const { error: err } = await getEventForUser(campaign.event, request.auth.credentials.user); + if (err) return h.response(error(err.code, err.message)).code(err.status); + const out = { ...campaign }; + if (campaign.channel === 'whatsapp') { + out.whatsappQueue = await WhatsAppQueue.find({ campaign: campaign._id }) + .select('to status whatsappMessageId recipientWaId sentAt lastError') + .sort({ createdAt: 1 }) + .lean(); + } + return h.response(success(out)); +}; + +const preview = async (request, h) => { + const campaign = await MessagingCampaign.findById(request.params.id).lean(); + if (!campaign) return h.response(error('NOT_FOUND', 'Campaign not found')).code(404); + if (campaign.channel === 'whatsapp') { + return h.response(error('VALIDATION_ERROR', 'Preview is not available for WhatsApp campaigns')).code(400); + } + const user = request.auth.credentials.user; + const { error: err } = await getEventForUser(campaign.event, user); + if (err) return h.response(error(err.code, err.message)).code(err.status); + + const eventDoc = await Event.findById(campaign.event).lean(); + const participantId = request.payload && request.payload.participantId; + let flat = { + firstName: 'Jane', + lastName: 'Participant', + email: 'preview@example.com', + organization: 'Example Co', + phone: '', + }; + if (participantId && mongoose.Types.ObjectId.isValid(String(participantId))) { + const p = await Participant.findById(participantId).lean(); + if (p) { + const { flattenParticipantForEvent } = require('../utils/participantMembership'); + const f = flattenParticipantForEvent(p, campaign.event); + if (f) flat = f; + } + } + const { subject: subjTpl, body: bodyTpl } = await getTemplateBodies(campaign); + const subjResolved = resolveSubjectForSend(campaign, subjTpl); + const merged = mergeCampaignContent(subjResolved, bodyTpl, flat, eventDoc); + return h.response(success(merged)); +}; + +module.exports = { + list, + listByOrg, + create, + get, + update, + remove, + send, + schedule, + cancel, + stop, + analytics, + preview, +}; diff --git a/src/controllers/checkins.controller.js b/src/controllers/checkins.controller.js new file mode 100644 index 0000000..6c35b13 --- /dev/null +++ b/src/controllers/checkins.controller.js @@ -0,0 +1,18 @@ +const { success, error } = require('../utils/response'); +const { resolveScopedOrganizationId } = require('../utils/tenantListScope'); +const { listEventsWithAttendance } = require('../services/checkins.service'); + +const listEvents = async (request, h) => { + const user = request.auth.credentials.user; + const scoped = resolveScopedOrganizationId(user, request.query.organization); + if (scoped.error) { + return h.response(error(scoped.error.code, scoped.error.message)).code(400); + } + + const events = await listEventsWithAttendance(user, scoped.organizationId); + return h.response(success({ events })); +}; + +module.exports = { + listEvents, +}; diff --git a/src/controllers/conciergeBot.controller.js b/src/controllers/conciergeBot.controller.js new file mode 100644 index 0000000..0f86ee9 --- /dev/null +++ b/src/controllers/conciergeBot.controller.js @@ -0,0 +1,439 @@ +const mongoose = require('mongoose'); +const ConciergeBot = require('../models/ConciergeBot'); +const KnowledgeSource = require('../models/KnowledgeSource'); +const ContextChunk = require('../models/ContextChunk'); +const ChatSubscriber = require('../models/ChatSubscriber'); +const Event = require('../models/Event'); +const { success, error } = require('../utils/response'); +const { getOrgWhatsAppCredentials, assertPhoneNumberOnWaba } = require('../utils/metaWhatsApp'); +const { resolveScopedOrganizationId } = require('../utils/tenantListScope'); + +function enqueueConciergeIngest(data) { + const { enqueueConciergeIngest: enqueue } = require('../services/conciergeQueue'); + return enqueue(data); +} + +function userCanAccessOrganization(user, organizationId) { + if (user.role === 'super_admin') return true; + const scoped = resolveScopedOrganizationId(user, null); + if (scoped.error) return false; + return String(organizationId) === String(scoped.organizationId); +} + +function buildListFilter(user, queryOrganization) { + if (queryOrganization) { + const scoped = resolveScopedOrganizationId(user, queryOrganization); + if (!scoped.error) return { organization: scoped.organizationId }; + } + const scoped = resolveScopedOrganizationId(user, null); + if (!scoped.error) return { organization: scoped.organizationId }; + if (user.role === 'super_admin') return {}; + return null; +} + +async function getBotForUser(botId, user) { + const bot = await ConciergeBot.findById(botId).populate('event', 'name startDate endDate'); + if (!bot) return { err: { code: 'NOT_FOUND', message: 'Chatbot not found', status: 404 } }; + if (!userCanAccessOrganization(user, bot.organization)) { + return { err: { code: 'FORBIDDEN', message: 'Access denied', status: 403 } }; + } + return { bot }; +} + +async function botAggregates(eventId) { + const [sourceCount, stagingChunkCount, subscriberCount] = await Promise.all([ + KnowledgeSource.countDocuments({ eventId }), + ContextChunk.countDocuments({ eventId, isActive: false }), + ChatSubscriber.countDocuments({ eventId }), + ]); + return { sourceCount, stagingChunkCount, subscriberCount }; +} + +const list = async (request, h) => { + const user = request.auth.credentials.user; + const filter = buildListFilter(user, request.query.organization); + if (filter == null) { + return h + .response( + error( + 'VALIDATION_ERROR', + 'List is scoped by organisation. Pass ?organization= or set organisation on your account.', + ), + ) + .code(400); + } + const bots = await ConciergeBot.find(filter) + .populate('event', 'name startDate endDate status') + .sort({ updatedAt: -1 }) + .lean(); + return h.response(success({ bots })); +}; + +const create = async (request, h) => { + const user = request.auth.credentials.user; + const { eventId, name, wabaPhoneNumberId, greeting, registration, knowledge, enabled } = + request.payload; + + if (!mongoose.Types.ObjectId.isValid(String(eventId))) { + return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400); + } + + const event = await Event.findById(eventId).lean(); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + if (!userCanAccessOrganization(user, event.organization)) { + return h.response(error('FORBIDDEN', 'Event not in your organisation')).code(403); + } + + const botOrganizationId = event.organization; + + const existing = await ConciergeBot.findOne({ event: eventId }); + if (existing) { + return h.response(error('VALIDATION_ERROR', 'This event already has a chatbot')).code(400); + } + + const strictWaba = + String(process.env.CONCIERGE_STRICT_WABA_VALIDATION || '').trim().toLowerCase() === '1' || + String(process.env.CONCIERGE_STRICT_WABA_VALIDATION || '').trim().toLowerCase() === 'true'; + + let wabaWarning = null; + try { + const creds = await getOrgWhatsAppCredentials(String(botOrganizationId)); + if (creds?.accessToken && wabaPhoneNumberId) { + const chk = await assertPhoneNumberOnWaba(creds.wabaId, wabaPhoneNumberId, creds.accessToken); + if (!chk.ok) { + if (strictWaba) { + return h.response(error('VALIDATION_ERROR', chk.message)).code(400); + } + wabaWarning = chk.message; + console.warn('[concierge] create bot: phone WABA check skipped:', chk.message); + } + } + } catch (metaErr) { + console.warn('[concierge] create bot: Meta check error (continuing):', metaErr.message); + if (strictWaba) { + return h.response(error('VALIDATION_ERROR', metaErr.message || 'WhatsApp validation failed')).code(400); + } + wabaWarning = metaErr.message || 'WhatsApp validation could not be completed'; + } + + let bot; + try { + bot = await ConciergeBot.create({ + organization: botOrganizationId, + event: eventId, + name: name || event.name, + wabaPhoneNumberId: String(wabaPhoneNumberId).trim(), + greeting, + registration, + knowledge, + enabled: Boolean(enabled), + status: enabled ? 'ACTIVE' : 'DRAFT', + }); + } catch (e) { + if (e && e.code === 11000) { + const msg = + e.keyPattern?.wabaPhoneNumberId != null + ? 'This WhatsApp phone number ID is already used by another chatbot.' + : 'This event already has a chatbot.'; + return h.response(error('VALIDATION_ERROR', msg)).code(400); + } + console.error('[concierge] create bot failed:', e); + return h.response(error('INTERNAL_ERROR', e.message || 'Failed to create chatbot')).code(500); + } + + console.log('[concierge] created bot', bot._id.toString(), 'event', eventId, 'org', botOrganizationId); + + const populated = await ConciergeBot.findById(bot._id) + .populate('event', 'name startDate endDate status') + .lean(); + return h + .response( + success( + { bot: populated, ...(wabaWarning ? { warning: wabaWarning } : {}) }, + 'Chatbot created', + 201, + ), + ) + .code(201); +}; + +const get = async (request, h) => { + const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user); + if (err) return h.response(error(err.code, err.message)).code(err.status); + const aggregates = await botAggregates(bot.event._id || bot.event); + return h.response(success({ bot, aggregates })); +}; + +const update = async (request, h) => { + const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user); + if (err) return h.response(error(err.code, err.message)).code(err.status); + + const p = request.payload; + const strictWaba = + String(process.env.CONCIERGE_STRICT_WABA_VALIDATION || '').trim().toLowerCase() === '1' || + String(process.env.CONCIERGE_STRICT_WABA_VALIDATION || '').trim().toLowerCase() === 'true'; + + if (p.name != null) bot.name = p.name; + + if (p.wabaPhoneNumberId != null) { + const nextPhone = String(p.wabaPhoneNumberId).trim(); + const phoneChanged = nextPhone !== String(bot.wabaPhoneNumberId || '').trim(); + if (phoneChanged) { + try { + const creds = await getOrgWhatsAppCredentials(String(bot.organization)); + if (creds?.accessToken && nextPhone) { + const chk = await assertPhoneNumberOnWaba(creds.wabaId, nextPhone, creds.accessToken); + if (!chk.ok) { + if (strictWaba) { + return h.response(error('VALIDATION_ERROR', chk.message)).code(400); + } + console.warn('[concierge] update bot: phone WABA check skipped:', chk.message); + } + } + } catch (metaErr) { + if (strictWaba) { + return h + .response(error('VALIDATION_ERROR', metaErr.message || 'WhatsApp validation failed')) + .code(400); + } + console.warn('[concierge] update bot: Meta check error (continuing):', metaErr.message); + } + bot.wabaPhoneNumberId = nextPhone; + } + } + + if (p.greeting) bot.greeting = { ...bot.greeting.toObject?.() || bot.greeting, ...p.greeting }; + if (p.registration) { + bot.registration = { ...bot.registration.toObject?.() || bot.registration, ...p.registration }; + } + if (p.knowledge) bot.knowledge = { ...bot.knowledge.toObject?.() || bot.knowledge, ...p.knowledge }; + if (p.enabled != null) { + bot.enabled = Boolean(p.enabled); + if (bot.enabled && bot.status !== 'ARCHIVED') bot.status = 'ACTIVE'; + if (!bot.enabled && bot.status === 'ACTIVE') bot.status = 'DRAFT'; + } + await bot.save(); + const populated = await ConciergeBot.findById(bot._id).populate('event', 'name startDate endDate status').lean(); + return h.response(success({ bot: populated })); +}; + +const remove = async (request, h) => { + const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user); + if (err) return h.response(error(err.code, err.message)).code(err.status); + await ConciergeBot.deleteOne({ _id: bot._id }); + return h.response(success({ deleted: true })); +}; + +const getByEvent = async (request, h) => { + const user = request.auth.credentials.user; + const { eventId } = request.params; + const event = await Event.findById(eventId).lean(); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + if (!userCanAccessOrganization(user, event.organization)) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const bot = await ConciergeBot.findOne({ event: eventId }).populate('event', 'name').lean(); + if (!bot) return h.response(error('NOT_FOUND', 'No chatbot for this event')).code(404); + const aggregates = await botAggregates(eventId); + return h.response(success({ bot, aggregates })); +}; + +const listSources = async (request, h) => { + const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user); + if (err) return h.response(error(err.code, err.message)).code(err.status); + const eventId = bot.event._id || bot.event; + const sources = await KnowledgeSource.find({ eventId }).sort({ createdAt: -1 }).lean(); + return h.response(success({ sources })); +}; + +const createSource = async (request, h) => { + const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user); + if (err) return h.response(error(err.code, err.message)).code(err.status); + const eventId = bot.event._id || bot.event; + const { type, sourcePath, displayName, crawlScope: crawlScopeInput } = request.payload; + + const doc = { + eventId, + type, + sourcePath, + displayName, + status: 'PENDING', + }; + + if (type === 'url') { + const { resolveCrawlScope } = require('../utils/conciergeUrlCrawl'); + const requested = + crawlScopeInput === 'domain' || crawlScopeInput === 'path' ? crawlScopeInput : 'auto'; + const scope = resolveCrawlScope(sourcePath, requested); + doc.crawlScope = scope.mode; + doc.crawlPathPrefix = scope.pathPrefix || null; + } + + const source = await KnowledgeSource.create(doc); + return h.response(success(source, 'Source created', 201)).code(201); +}; + +const deleteSource = async (request, h) => { + const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user); + if (err) return h.response(error(err.code, err.message)).code(err.status); + const eventId = bot.event._id || bot.event; + const source = await KnowledgeSource.findOne({ + _id: request.params.sourceId, + eventId, + }); + if (!source) return h.response(error('NOT_FOUND', 'Source not found')).code(404); + await ContextChunk.deleteMany({ sourceId: source._id }); + await source.deleteOne(); + return h.response(success({ deleted: true })); +}; + +const syncSource = async (request, h) => { + const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user); + if (err) return h.response(error(err.code, err.message)).code(err.status); + const eventId = bot.event._id || bot.event; + const source = await KnowledgeSource.findOne({ + _id: request.params.sourceId, + eventId, + }); + if (!source) return h.response(error('NOT_FOUND', 'Source not found')).code(404); + source.status = 'PENDING'; + source.errorMessage = null; + await source.save(); + await enqueueConciergeIngest({ + sourceId: String(source._id), + botId: String(bot._id), + eventId: String(eventId), + type: source.type, + }); + return h.response(success(source, 'Sync queued')); +}; + +const listChunks = async (request, h) => { + const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user); + if (err) return h.response(error(err.code, err.message)).code(err.status); + const eventId = bot.event._id || bot.event; + const source = await KnowledgeSource.findOne({ + _id: request.params.sourceId, + eventId, + }); + if (!source) return h.response(error('NOT_FOUND', 'Source not found')).code(404); + const q = { sourceId: source._id }; + if (request.query.staging === 'true') q.isActive = false; + const chunks = await ContextChunk.find(q).sort({ createdAt: 1 }).lean(); + return h.response(success({ chunks })); +}; + +const patchChunk = async (request, h) => { + const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user); + if (err) return h.response(error(err.code, err.message)).code(err.status); + const eventId = bot.event._id || bot.event; + const chunk = await ContextChunk.findOne({ + _id: request.params.chunkId, + eventId, + }); + if (!chunk) return h.response(error('NOT_FOUND', 'Chunk not found')).code(404); + if (request.payload.rawText != null) chunk.rawText = request.payload.rawText; + await chunk.save(); + return h.response(success(chunk)); +}; + +const deleteChunk = async (request, h) => { + const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user); + if (err) return h.response(error(err.code, err.message)).code(err.status); + const eventId = bot.event._id || bot.event; + const chunk = await ContextChunk.findOne({ + _id: request.params.chunkId, + eventId, + }); + if (!chunk) return h.response(error('NOT_FOUND', 'Chunk not found')).code(404); + await chunk.deleteOne(); + return h.response(success({ deleted: true })); +}; + +const approveSource = async (request, h) => { + const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user); + if (err) return h.response(error(err.code, err.message)).code(err.status); + const eventId = bot.event._id || bot.event; + const source = await KnowledgeSource.findOne({ + _id: request.params.sourceId, + eventId, + }); + if (!source) return h.response(error('NOT_FOUND', 'Source not found')).code(404); + const result = await ContextChunk.updateMany( + { sourceId: source._id }, + { $set: { isActive: true } }, + ); + return h.response(success({ modified: result.modifiedCount })); +}; + +const clearKnowledge = async (request, h) => { + const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user); + if (err) return h.response(error(err.code, err.message)).code(err.status); + const eventId = bot.event._id || bot.event; + await ContextChunk.deleteMany({ eventId }); + await KnowledgeSource.deleteMany({ eventId }); + bot.lastKnowledgeClearedAt = new Date(); + await bot.save(); + return h.response(success({ cleared: true })); +}; + +const syncEventKnowledge = async (request, h) => { + const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user); + if (err) return h.response(error(err.code, err.message)).code(err.status); + if (bot.knowledge?.includeEventDetails === false) { + return h + .response( + error( + 'VALIDATION_ERROR', + 'Event programme indexing is disabled for this bot. Enable "Include event programme in knowledge base" in settings first.', + ), + ) + .code(400); + } + const eventId = bot.event._id || bot.event; + const programmeDisplayName = 'Event programme & links'; + let source = await KnowledgeSource.findOne({ eventId, type: 'event' }); + if (!source) { + source = await KnowledgeSource.create({ + eventId, + type: 'event', + sourcePath: String(eventId), + displayName: programmeDisplayName, + status: 'PENDING', + }); + } else { + source.status = 'PENDING'; + source.errorMessage = null; + source.displayName = programmeDisplayName; + await source.save(); + } + bot.knowledge = bot.knowledge || {}; + bot.knowledge.lastEventSnapshotAt = new Date(); + await bot.save(); + await enqueueConciergeIngest({ + sourceId: String(source._id), + botId: String(bot._id), + eventId: String(eventId), + type: 'event', + }); + return h.response(success(source, 'Event programme sync queued')); +}; + +module.exports = { + list, + create, + get, + update, + remove, + getByEvent, + listSources, + createSource, + deleteSource, + syncSource, + listChunks, + patchChunk, + deleteChunk, + approveSource, + clearKnowledge, + syncEventKnowledge, +}; diff --git a/src/controllers/event.controller.js b/src/controllers/event.controller.js index 2e0355b..2dc1527 100644 --- a/src/controllers/event.controller.js +++ b/src/controllers/event.controller.js @@ -1,16 +1,21 @@ +const mongoose = require('mongoose'); const Event = require('../models/Event'); +const Form = require('../models/Form'); +const EventMicrosite = require('../models/EventMicrosite'); const User = require('../models/User'); +const Track = require('../models/Track'); +const Session = require('../models/Session'); const OrganizationUserMap = require('../models/OrganizationUserMap'); const { success, error } = require('../utils/response'); const { resolveScopedOrganizationId } = require('../utils/tenantListScope'); const { uploadBufferToS3AndGetCloudFrontUrl } = require('../utils/s3Media'); const { registrationUrlForForm } = require('../utils/micrositeBuild'); +const { extractEventFieldsFromDescription } = require('../utils/bedrockExtractEvent'); const { - extractEventFieldsFromDescription, - EVENT_TYPES: AI_EVENT_TYPES, - EVENT_STATUS: AI_EVENT_STATUS, - LOCATION_TYPES: AI_LOCATION_TYPES, -} = require('../utils/bedrockExtractEvent'); + mergeAiExtractedPayload, + computeQuickCreateGapsFromRawAi, + serializeMergedPreviewForApi, +} = require('../utils/aiEventMergeAndGaps'); function parseDataUrl(dataUrl) { if (typeof dataUrl !== 'string') return null; @@ -98,61 +103,180 @@ async function assignCreatorToEventStaff(creatorUserId, orgId, eventId) { await map.save(); } -function defaultAiDateRange() { - const start = new Date(); - start.setUTCDate(start.getUTCDate() + 7); - start.setUTCHours(10, 0, 0, 0); - const end = new Date(start.getTime()); - end.setUTCHours(18, 0, 0, 0); - return { start, end }; +/** + * One default track + opening session (agenda-ready). + * @param {object} eventDoc Mongoose event doc + * @param {string|object} titlePrefixOrOpts If string: legacy title prefix (e.g. "Opening session"). If object: { titlePrefix, trackName, sessionTitle, sessionUseFullEventWindow } + */ +async function createDefaultTrackAndOpeningSession(eventDoc, titlePrefixOrOpts) { + const opts = + typeof titlePrefixOrOpts === 'string' + ? { titlePrefix: titlePrefixOrOpts } + : titlePrefixOrOpts && typeof titlePrefixOrOpts === 'object' + ? titlePrefixOrOpts + : {}; + const { + titlePrefix = 'Session', + trackName = 'Main programme', + sessionTitle: explicitSessionTitle, + sessionUseFullEventWindow = false, + } = opts; + + const eid = eventDoc._id; + const track = await Track.create({ + event: eid, + name: trackName, + sortOrder: 0, + }); + + const start = new Date(eventDoc.startDate); + let end; + if (sessionUseFullEventWindow) { + end = new Date(eventDoc.endDate); + if (end <= start) end = new Date(start.getTime() + 60 * 60 * 1000); + } else { + end = new Date(start.getTime() + 2 * 60 * 60 * 1000); + const evEnd = new Date(eventDoc.endDate); + if (end > evEnd) end = new Date(evEnd.getTime()); + if (end <= start) end = new Date(start.getTime() + 60 * 60 * 1000); + } + + const titleBase = typeof eventDoc.name === 'string' ? eventDoc.name : 'Event'; + const trimmedExplicit = + explicitSessionTitle != null && String(explicitSessionTitle).trim() + ? String(explicitSessionTitle).trim().slice(0, 200) + : ''; + const sessionTitle = trimmedExplicit || `${titlePrefix} — ${titleBase}`.slice(0, 200); + + const sessionPayload = { + event: eid, + track: track._id, + title: sessionTitle, + type: 'keynote', + startTime: start, + endTime: end, + status: 'scheduled', + }; + if (eventDoc.description) { + sessionPayload.description = String(eventDoc.description).slice(0, 500); + } + const lt = eventDoc.locationType; + if ((lt === 'physical' || lt === 'hybrid') && eventDoc.location) { + sessionPayload.location = eventDoc.location; + } + + if (eventDoc.recordKind === 'activity') { + const sub = eventDoc.activitySubtype || 'qna'; + if (sub === 'qna') { + sessionPayload.enableQnA = true; + sessionPayload.enableQnP = false; + } else { + sessionPayload.enableQnA = false; + sessionPayload.enableQnP = true; + } + } + + try { + await Session.create(sessionPayload); + } catch (err) { + await Track.deleteOne({ _id: track._id }); + throw err; + } } -function mergeAiExtractedPayload(ai) { - const { start: ds, end: de } = defaultAiDateRange(); - const norm = (v) => (typeof v === 'string' ? v.toLowerCase().trim() : ''); - const type = AI_EVENT_TYPES.includes(norm(ai.type)) ? norm(ai.type) : 'conference'; - const status = AI_EVENT_STATUS.includes(norm(ai.status)) ? norm(ai.status) : 'draft'; - const locType = AI_LOCATION_TYPES.includes(norm(ai.locationType)) ? norm(ai.locationType) : 'physical'; +async function createDefaultTrackAndSessionForAiEvent(eventDoc) { + return createDefaultTrackAndOpeningSession(eventDoc, 'Opening session'); +} - let name = typeof ai.name === 'string' ? ai.name.trim() : ''; - if (name.length < 3) { - const fromDesc = typeof ai.description === 'string' ? ai.description.trim().slice(0, 80) : ''; - name = (fromDesc || 'New event').slice(0, 100); - if (name.length < 3) name = 'New event'; +async function createDefaultTrackAndSessionForActivity(eventDoc) { + const name = typeof eventDoc.name === 'string' ? eventDoc.name.trim() : ''; + return createDefaultTrackAndOpeningSession(eventDoc, { + trackName: 'Default', + sessionTitle: name ? name.slice(0, 200) : undefined, + titlePrefix: 'Activity session', + sessionUseFullEventWindow: true, + }); +} + +function toOrgObjectId(organizationId) { + if (!organizationId) return organizationId; + if (organizationId instanceof mongoose.Types.ObjectId) return organizationId; + const s = String(organizationId); + if (mongoose.Types.ObjectId.isValid(s) && String(new mongoose.Types.ObjectId(s)) === s) { + return new mongoose.Types.ObjectId(s); } + return organizationId; +} - let startDate = ai.startDate ? new Date(ai.startDate) : ds; - let endDate = ai.endDate ? new Date(ai.endDate) : de; - if (Number.isNaN(startDate.getTime())) startDate = ds; - if (Number.isNaN(endDate.getTime())) endDate = de; - if (endDate <= startDate) { - endDate = new Date(startDate.getTime() + 8 * 60 * 60 * 1000); - } +async function attachActivityStats(organizationId, eventsLean) { + if (!eventsLean.length) return eventsLean; + const Question = require('../models/Question'); + const QuizPoll = require('../models/QuizPoll'); + const QuizPollResponse = require('../models/QuizPollResponse'); + const Participant = require('../models/Participant'); - const subtitle = typeof ai.subtitle === 'string' ? ai.subtitle : ''; - const description = typeof ai.description === 'string' ? ai.description : ''; - const location = typeof ai.location === 'string' ? ai.location : ''; - const virtualLink = typeof ai.virtualLink === 'string' ? ai.virtualLink : ''; - const tags = Array.isArray(ai.tags) ? ai.tags.filter((t) => typeof t === 'string') : []; - - return { - name, - subtitle, - description, - type, - status, - startDate, - endDate, - location, - locationType: locType, - virtualLink, - tags, + const orgOid = toOrgObjectId(organizationId); + const eventIds = eventsLean.map((e) => e._id); + const notDeletedPoll = { + $or: [{ deletedAt: null }, { deletedAt: { $exists: false } }], }; + + const [qAgg, pAgg, qpAgg, respAgg] = await Promise.all([ + Question.aggregate([ + { $match: { event: { $in: eventIds } } }, + { $group: { _id: '$event', count: { $sum: 1 } } }, + ]), + Participant.aggregate([ + { $match: { tenantOrganization: orgOid } }, + { $unwind: '$eventMemberships' }, + { $match: { 'eventMemberships.event': { $in: eventIds } } }, + { $group: { _id: '$eventMemberships.event', count: { $sum: 1 } } }, + ]), + QuizPoll.aggregate([ + { $match: { event: { $in: eventIds }, ...notDeletedPoll } }, + { $group: { _id: '$event', count: { $sum: 1 } } }, + ]), + QuizPollResponse.aggregate([ + { $lookup: { from: 'quizpolls', localField: 'quizPoll', foreignField: '_id', as: 'qp' } }, + { $unwind: '$qp' }, + { + $match: { + 'qp.event': { $in: eventIds }, + $or: [{ 'qp.deletedAt': null }, { 'qp.deletedAt': { $exists: false } }], + }, + }, + { $group: { _id: '$qp.event', count: { $sum: 1 } } }, + ]), + ]); + + const toMap = (agg) => + agg.reduce((m, r) => { + m[String(r._id)] = r.count; + return m; + }, {}); + const qm = toMap(qAgg); + const pm = toMap(pAgg); + const qpm = toMap(qpAgg); + const rm = toMap(respAgg); + + return eventsLean.map((ev) => ({ + ...ev, + activityStats: { + questionCount: qm[String(ev._id)] || 0, + participantCount: pm[String(ev._id)] || 0, + quizPollCount: qpm[String(ev._id)] || 0, + responseCount: rm[String(ev._id)] || 0, + }, + })); } async function persistEventWithSideEffects(user, orgId, rest, metaIn) { + const payload = { ...rest }; + if (payload.recordKind !== 'activity') { + delete payload.activitySubtype; + } const event = await Event.create({ - ...rest, + ...payload, organization: orgId, metadata: { totalRevenue: 0, @@ -203,7 +327,23 @@ async function persistEventWithSideEffects(user, orgId, rest, metaIn) { } const list = async (request, h) => { - const { organization: organizationQuery, status, type, page = 1, limit = 20 } = request.query; + const { + organization: organizationQuery, + status, + type, + page: pageRaw = 1, + limit: limitRaw = 20, + recordKind: recordKindQuery, + activitySubtype, + enrich: enrichRaw, + } = request.query; + const page = parseInt(pageRaw, 10) || 1; + const limit = Math.min(parseInt(limitRaw, 10) || 20, 200); + const enrich = + enrichRaw === true || + enrichRaw === '1' || + enrichRaw === 'true'; + const user = request.auth.credentials.user; const scoped = resolveScopedOrganizationId(user, organizationQuery); if (scoped.error) { @@ -213,13 +353,25 @@ const list = async (request, h) => { if (status) filter.status = status; if (type) filter.type = type; - const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10); + if (recordKindQuery === 'activity') { + filter.recordKind = 'activity'; + if (activitySubtype) filter.activitySubtype = activitySubtype; + } else { + filter.recordKind = { $ne: 'activity' }; + } + + const skip = (page - 1) * limit; const [events, total] = await Promise.all([ - Event.find(filter).populate('organization').sort({ startDate: -1 }).skip(skip).limit(parseInt(limit, 10)).lean(), + Event.find(filter).populate('organization').sort({ startDate: -1 }).skip(skip).limit(limit).lean(), Event.countDocuments(filter), ]); - return h.response(success({ events, total, page: parseInt(page, 10), limit: parseInt(limit, 10) })) - .header('X-Total-Count', total); + + let outEvents = events; + if (enrich && recordKindQuery === 'activity' && events.length > 0) { + outEvents = await attachActivityStats(scoped.organizationId, events); + } + + return h.response(success({ events: outEvents, total, page, limit })).header('X-Total-Count', total); }; const create = async (request, h) => { @@ -229,11 +381,83 @@ const create = async (request, h) => { if (user.role !== 'super_admin' && (user.organization?._id?.toString() !== orgId?.toString())) { return h.response(error('FORBIDDEN', 'Cannot create event for this organization')).code(403); } - const { metadata: metaIn, ...rest } = request.payload; + const { metadata: metaIn, bootstrapAgenda, ...rest } = request.payload; + if (rest.recordKind === 'activity') { + if (!rest.type) rest.type = 'webinar'; + if (!rest.locationType) rest.locationType = 'virtual'; + } const event = await persistEventWithSideEffects(user, orgId, rest, metaIn); + if (rest.recordKind === 'activity') { + try { + await createDefaultTrackAndSessionForActivity(event); + } catch (err) { + console.error('createDefaultTrackAndSessionForActivity', err); + try { + await Event.deleteOne({ _id: event._id }); + } catch (delErr) { + console.error('rollback activity event', delErr); + } + return h + .response( + error( + 'SERVER_ERROR', + 'Could not finish creating the activity (default track or session failed). Please try again.', + ), + ) + .code(500); + } + } else if (bootstrapAgenda === true) { + try { + await createDefaultTrackAndSessionForAiEvent(event); + } catch (err) { + console.error('createDefaultTrackAndSessionForAiEvent (bootstrapAgenda)', err); + try { + await Event.deleteOne({ _id: event._id }); + } catch (delErr) { + console.error('rollback event after bootstrapAgenda failure', delErr); + } + return h + .response( + error( + 'SERVER_ERROR', + 'Could not finish creating the event (default track or session failed). Please try again.', + ), + ) + .code(500); + } + } return h.response(success(event, 'Event created', 201)).code(201); }; +/** Bedrock extract + gap list for quick-create flow; does not persist. */ +const aiExtractPreview = async (request, h) => { + const user = request.auth.credentials.user; + const orgId = request.payload.organization || (user.organization?._id || user.organization); + if (!orgId) return h.response(error('VALIDATION_ERROR', 'Organization required')).code(400); + if (user.role !== 'super_admin' && (user.organization?._id?.toString() !== orgId?.toString())) { + return h.response(error('FORBIDDEN', 'Cannot preview for this organization')).code(403); + } + + const { description } = request.payload; + let extracted; + try { + extracted = await extractEventFieldsFromDescription(description); + } catch (err) { + console.error('extractEventFieldsFromDescription (aiExtractPreview)', err); + const msg = err.message || 'AI extraction failed'; + return h.response(error('BEDROCK_ERROR', msg)).code(502); + } + + const mergedPreview = mergeAiExtractedPayload(extracted); + const gaps = computeQuickCreateGapsFromRawAi(extracted); + return h.response( + success({ + gaps, + mergedPreview: serializeMergedPreviewForApi(mergedPreview), + }), + ); +}; + const generateFromAi = async (request, h) => { const user = request.auth.credentials.user; const orgId = request.payload.organization || (user.organization?._id || user.organization); @@ -254,6 +478,24 @@ const generateFromAi = async (request, h) => { const merged = mergeAiExtractedPayload(extracted); const event = await persistEventWithSideEffects(user, orgId, merged, undefined); + try { + await createDefaultTrackAndSessionForAiEvent(event); + } catch (err) { + console.error('createDefaultTrackAndSessionForAiEvent', err); + try { + await Event.deleteOne({ _id: event._id }); + } catch (delErr) { + console.error('rollback AI event after track/session failure', delErr); + } + return h + .response( + error( + 'SERVER_ERROR', + 'Could not finish creating the event (default track or session failed). Please try again.', + ), + ) + .code(500); + } return h.response(success(event, 'Event created', 201)).code(201); }; @@ -274,7 +516,7 @@ const update = async (request, h) => { if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { return h.response(error('FORBIDDEN', 'Access denied')).code(403); } - const { metadata: metaIn, ...rest } = request.payload; + const { metadata: metaIn, bootstrapAgenda: _bootstrapIgnored, ...rest } = request.payload; const orgId = event.organization?._id || event.organization; const eventId = event._id?.toString?.() || event._id; @@ -298,6 +540,9 @@ const update = async (request, h) => { } Object.assign(event, rest); + if (event.recordKind !== 'activity') { + event.set('activitySubtype', undefined); + } if (metaIn && typeof metaIn === 'object') { const prev = event.metadata?.toObject?.() || event.metadata || {}; event.metadata = { ...prev, ...metaIn }; @@ -440,9 +685,39 @@ const registrationLink = async (request, h) => { return h.response(success({ url })); }; +/** Authenticated: forms + microsites with resolved public hrefs for emailer CTA pickers. */ +const emailerLinkTargets = async (request, h) => { + const event = await Event.findById(request.params.id).lean(); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const base = (process.env.MICROSITE_PUBLIC_BASE_URL || 'https://web.eventbuzz.live').replace(/\/$/, ''); + const formsRaw = await Form.find({ event: event._id }).select('_id name').sort({ name: 1 }).lean(); + const forms = formsRaw.map((f) => ({ + id: String(f._id), + name: f.name || 'Form', + href: registrationUrlForForm(event, event.website, f._id) || '', + })); + const mss = await EventMicrosite.find({ event: event._id }).select('name publishFolder').sort({ name: 1 }).lean(); + const microsites = mss + .filter((m) => m.publishFolder && String(m.publishFolder).trim()) + .map((m) => { + const folder = String(m.publishFolder).trim().toLowerCase(); + return { + id: String(m._id), + name: m.name || folder || 'Microsite', + href: `${base}/${folder}/`, + }; + }); + return h.response(success({ forms, microsites })); +}; + module.exports = { list, create, + aiExtractPreview, generateFromAi, get, update, @@ -450,4 +725,5 @@ module.exports = { analytics, duplicate, registrationLink, + emailerLinkTargets, }; diff --git a/src/controllers/eventTicket.controller.js b/src/controllers/eventTicket.controller.js new file mode 100644 index 0000000..5a6ff17 --- /dev/null +++ b/src/controllers/eventTicket.controller.js @@ -0,0 +1,147 @@ +const mongoose = require('mongoose'); +const EventTicket = require('../models/EventTicket'); +const Event = require('../models/Event'); +const Form = require('../models/Form'); +const ParticipantList = require('../models/ParticipantList'); +const Organization = require('../models/Organization'); +const { success, error } = require('../utils/response'); + +async function assertEventAccess(eventId, user) { + const event = await Event.findById(eventId).select('organization').lean(); + if (!event) return { err: (h) => h.response(error('NOT_FOUND', 'Event not found')).code(404) }; + if ( + user.role !== 'super_admin' && + String(event.organization) !== String(user.organization?._id || user.organization || '') + ) { + return { err: (h) => h.response(error('FORBIDDEN', 'Access denied')).code(403) }; + } + return { event }; +} + +async function validateTicketRefs(organizationId, eventId, payload) { + const list = await ParticipantList.findById(payload.participantList).lean(); + if (!list) return { err: 'Participant list not found' }; + if (String(list.tenantOrganization) !== String(organizationId)) return { err: 'List belongs to another organisation' }; + if (list.type !== 'fixed') return { err: 'Participant list must be a fixed list' }; + + if (payload.registrationForm) { + const form = await Form.findById(payload.registrationForm).lean(); + if (!form) return { err: 'Registration form not found' }; + if (String(form.tenantOrganization) !== String(organizationId)) return { err: 'Form belongs to another organisation' }; + if (form.event && String(form.event) !== String(eventId)) return { err: 'Form is scoped to a different event' }; + } + + return { ok: true }; +} + +const list = async (request, h) => { + const { eventId } = request.params; + const user = request.auth.credentials.user; + const r = await assertEventAccess(eventId, user); + if (r.err) return r.err(h); + const tickets = await EventTicket.find({ event: eventId }).sort({ sortOrder: 1, name: 1 }).lean(); + return h.response(success({ tickets })); +}; + +const create = async (request, h) => { + const { eventId } = request.params; + const user = request.auth.credentials.user; + const r = await assertEventAccess(eventId, user); + if (r.err) return r.err(h); + + const event = await Event.findById(eventId).lean(); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const orgId = event.organization; + + const orgDoc = await Organization.findById(orgId).lean(); + if (!orgDoc) return h.response(error('NOT_FOUND', 'Organization not found')).code(404); + + const p = request.payload; + const vr = await validateTicketRefs(orgId, eventId, { + participantList: p.participantList, + registrationForm: p.registrationForm || null, + }); + if (vr.err) return h.response(error('VALIDATION_ERROR', vr.err)).code(400); + + const ticket = await EventTicket.create({ + event: eventId, + organization: orgId, + name: p.name, + description: p.description || '', + keyPoints: p.keyPoints || [], + basePriceMinor: p.basePriceMinor, + currency: p.currency || 'INR', + isActive: p.isActive !== false, + sortOrder: p.sortOrder ?? 0, + maxPerOrder: p.maxPerOrder ?? null, + inventoryCap: p.inventoryCap ?? null, + taxItems: p.taxItems || [], + registrationForm: p.registrationForm || null, + participantList: p.participantList, + }); + + return h.response(success(ticket, 'Ticket created', 201)).code(201); +}; + +const update = async (request, h) => { + const { eventId, id } = request.params; + const user = request.auth.credentials.user; + const r = await assertEventAccess(eventId, user); + if (r.err) return r.err(h); + + const ticket = await EventTicket.findOne({ _id: id, event: eventId }); + if (!ticket) return h.response(error('NOT_FOUND', 'Ticket not found')).code(404); + + const event = await Event.findById(eventId).lean(); + const orgId = event.organization; + + const orgDoc = await Organization.findById(orgId).lean(); + if (!assertFeature(orgDoc, 'payment_gateway_integration')) { + return h.response(error('FORBIDDEN', 'Ticketing requires payment gateway integration on your plan.')).code(403); + } + + const p = request.payload; + if (p.participantList != null || p.registrationForm !== undefined) { + const vr = await validateTicketRefs(orgId, eventId, { + participantList: p.participantList ?? ticket.participantList, + registrationForm: p.registrationForm !== undefined ? p.registrationForm : ticket.registrationForm, + }); + if (vr.err) return h.response(error('VALIDATION_ERROR', vr.err)).code(400); + } + + const fields = [ + 'name', + 'description', + 'keyPoints', + 'basePriceMinor', + 'currency', + 'isActive', + 'sortOrder', + 'maxPerOrder', + 'inventoryCap', + 'taxItems', + 'registrationForm', + 'participantList', + ]; + for (const f of fields) { + if (p[f] !== undefined) ticket[f] = p[f]; + } + await ticket.save(); + return h.response(success(ticket)); +}; + +const remove = async (request, h) => { + const { eventId, id } = request.params; + const user = request.auth.credentials.user; + const r = await assertEventAccess(eventId, user); + if (r.err) return r.err(h); + + const ticket = await EventTicket.findOne({ _id: id, event: eventId }); + if (!ticket) return h.response(error('NOT_FOUND', 'Ticket not found')).code(404); + + ticket.isActive = false; + await ticket.save(); + return h.response(success(null, 'Ticket deactivated')); +}; + +module.exports = { list, create, update, remove }; diff --git a/src/controllers/eventTicketingManual.controller.js b/src/controllers/eventTicketingManual.controller.js new file mode 100644 index 0000000..f51c086 --- /dev/null +++ b/src/controllers/eventTicketingManual.controller.js @@ -0,0 +1,173 @@ +const Event = require('../models/Event'); +const { success, error } = require('../utils/response'); +const { defaultManualEnablementFeeInrFromEnv } = require('../utils/manualTicketingFee'); +const { manualQrFieldsSatisfied } = require('../utils/ticketingManualPayment'); +const { createManualEnablementCheckoutOrder } = require('../services/ticketingManualEnablement.service'); + +async function assertEventAccess(eventId, user) { + const event = await Event.findById(eventId).select('organization').lean(); + if (!event) return { err: (h) => h.response(error('NOT_FOUND', 'Event not found')).code(404) }; + if ( + user.role !== 'super_admin' && + String(event.organization) !== String(user.organization?._id || user.organization || '') + ) { + return { err: (h) => h.response(error('FORBIDDEN', 'Access denied')).code(403) }; + } + return { event }; +} + +function shapeTicketingManualResponse(mp) { + const raw = mp && typeof mp === 'object' ? mp : {}; + /** Legacy field; buyers are no longer charged a per-order Eventbuzz fee (enablement fee covers the organiser). */ + const fee = + raw.platformFeeInr != null && Number.isFinite(Number(raw.platformFeeInr)) ? Number(raw.platformFeeInr) : 0; + const mode = raw.manualQrMode === 'dynamic' ? 'dynamic' : 'upload'; + return { + enabled: Boolean(raw.enabled), + manualQrMode: mode, + upiQrImageUrl: raw.upiQrImageUrl || '', + upiId: raw.upiId || '', + payeeName: raw.payeeName || '', + instructions: raw.instructions || '', + platformFeeInr: fee, + platformFeeInrIsDefault: raw.platformFeeInr == null, + enablementFeeInr: defaultManualEnablementFeeInrFromEnv(), + enablementPaid: Boolean(raw.enablementFeePaidAt), + enablementPaidAt: raw.enablementFeePaidAt || null, + }; +} + +const patch = async (request, h) => { + const { eventId } = request.params; + const user = request.auth.credentials.user; + const r = await assertEventAccess(eventId, user); + if (r.err) return r.err(h); + + const p = request.payload; + const doc = await Event.findById(eventId); + if (!doc) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + + const prev = doc.ticketingManualPayment && typeof doc.ticketingManualPayment === 'object' ? doc.ticketingManualPayment : {}; + + const next = { ...prev }; + if (p.enabled !== undefined) next.enabled = Boolean(p.enabled); + if (p.manualQrMode !== undefined) { + next.manualQrMode = p.manualQrMode === 'dynamic' ? 'dynamic' : 'upload'; + } + if (p.upiQrImageUrl !== undefined) next.upiQrImageUrl = String(p.upiQrImageUrl || '').trim(); + if (p.upiId !== undefined) next.upiId = String(p.upiId || '').trim(); + if (p.payeeName !== undefined) next.payeeName = String(p.payeeName || '').trim(); + if (p.instructions !== undefined) next.instructions = String(p.instructions || ''); + if (p.platformFeeInr !== undefined) { + if (p.platformFeeInr === null) next.platformFeeInr = null; + else { + const n = Number(p.platformFeeInr); + if (!Number.isFinite(n) || n < 0) { + return h.response(error('VALIDATION_ERROR', 'platformFeeInr must be a non-negative number or null')).code(400); + } + next.platformFeeInr = n; + } + } + + const enablementOk = Boolean(next.enablementFeePaidAt || prev.enablementFeePaidAt); + if (next.enabled && !enablementOk) { + return h + .response( + error( + 'PAYMENT_REQUIRED', + 'Pay the manual UPI enablement fee for this event before enabling manual checkout. The paid transaction appears under Settings → Billing.', + ), + ) + .code(402); + } + + if (next.enabled) { + const mode = next.manualQrMode === 'dynamic' ? 'dynamic' : 'upload'; + if (mode === 'upload' && !String(next.upiQrImageUrl || '').trim()) { + return h.response(error('VALIDATION_ERROR', 'Add a UPI QR image or URL before enabling upload mode')).code(400); + } + if (mode === 'dynamic' && !String(next.upiId || '').trim()) { + return h.response(error('VALIDATION_ERROR', 'Enter a UPI ID (VPA) before enabling dynamic QR mode')).code(400); + } + } + + if (next.enabled && !manualQrFieldsSatisfied(next)) { + return h.response(error('VALIDATION_ERROR', 'Complete QR settings before enabling')).code(400); + } + + doc.ticketingManualPayment = next; + await doc.save(); + + return h.response( + success({ + ticketingManualPayment: shapeTicketingManualResponse(doc.ticketingManualPayment), + }), + ); +}; + +const get = async (request, h) => { + const { eventId } = request.params; + const user = request.auth.credentials.user; + const r = await assertEventAccess(eventId, user); + if (r.err) return r.err(h); + + const doc = await Event.findById(eventId).select('ticketingManualPayment').lean(); + if (!doc) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + + return h.response( + success({ + ticketingManualPayment: shapeTicketingManualResponse(doc.ticketingManualPayment || {}), + }), + ); +}; + +const checkoutEnablementOrder = async (request, h) => { + const { eventId } = request.params; + const user = request.auth.credentials.user; + const r = await assertEventAccess(eventId, user); + if (r.err) return r.err(h); + + const orgId = user.organization?._id || user.organization; + if (!orgId) return h.response(error('VALIDATION_ERROR', 'No organisation')).code(400); + + try { + const result = await createManualEnablementCheckoutOrder({ + organizationId: orgId, + userId: user._id, + eventId, + }); + return h + .response( + success({ + keyId: result.keyId, + orderId: result.orderId, + amount: result.amount, + currency: result.currency, + transactionId: result.transaction._id.toString(), + checkoutSource: result.checkoutSource, + enablementFeeInr: defaultManualEnablementFeeInrFromEnv(), + }), + ) + .code(201); + } catch (e) { + if (e.code === 'ALREADY_PAID') { + return h.response(error('VALIDATION_ERROR', e.message)).code(409); + } + if (e.code === 'INVALID_CONFIG') { + return h.response(error('VALIDATION_ERROR', e.message)).code(400); + } + if ( + e.code === 'NOT_FOUND' || + e.code === 'FORBIDDEN' || + e.code === 'NO_GATEWAY' || + e.code === 'PLATFORM_RAZORPAY_MISSING' || + e.code === 'INVALID_AMOUNT' || + e.code === 'RAZORPAY_SDK_MISSING' + ) { + return h.response(error('VALIDATION_ERROR', e.message)).code(400); + } + throw e; + } +}; + +module.exports = { patch, get, checkoutEnablementOrder }; diff --git a/src/controllers/form.controller.js b/src/controllers/form.controller.js index e6810a1..df038cf 100644 --- a/src/controllers/form.controller.js +++ b/src/controllers/form.controller.js @@ -2,9 +2,11 @@ const mongoose = require('mongoose'); const Form = require('../models/Form'); const FormSubmission = require('../models/FormSubmission'); const Event = require('../models/Event'); +const EventMicrosite = require('../models/EventMicrosite'); const Participant = require('../models/Participant'); const ParticipantList = require('../models/ParticipantList'); const { success, error } = require('../utils/response'); +const { computeEventRatingsSummary } = require('../utils/eventRatingsSummary'); function normalizeSubmitData(raw) { if (!raw) return {}; @@ -12,6 +14,77 @@ function normalizeSubmitData(raw) { return { ...raw }; } +/** Normalize form.event from lean() (ObjectId, string, or rare populated { _id }). */ +function extractObjectIdString(ref) { + if (ref == null) return null; + if (ref instanceof mongoose.Types.ObjectId) return ref.toString(); + if (Buffer.isBuffer(ref)) { + try { + return new mongoose.Types.ObjectId(ref).toString(); + } catch { + return null; + } + } + if (typeof ref === 'object' && ref._id != null) { + return extractObjectIdString(ref._id); + } + if (typeof ref === 'string') { + const t = ref.trim(); + if (t.length === 24 && mongoose.Types.ObjectId.isValid(t)) return t; + } + return null; +} + +const EVENT_PUBLIC_SELECT = + 'name subtitle type startDate endDate location locationType image bannerImage website.logo'; + +function buildPublicEventPayload(ev, micrositeLogo) { + const toIso = (d) => (d && !Number.isNaN(new Date(d).getTime()) ? new Date(d).toISOString() : null); + const siteLogo = ev.website?.logo && String(ev.website.logo).trim() ? String(ev.website.logo).trim() : null; + const msLogo = micrositeLogo && String(micrositeLogo).trim() ? String(micrositeLogo).trim() : null; + return { + name: ev.name, + subtitle: ev.subtitle || '', + eventType: ev.type, + startDate: toIso(ev.startDate), + endDate: toIso(ev.endDate), + location: ev.location ? String(ev.location) : '', + locationType: ev.locationType || 'physical', + logoUrl: siteLogo || msLogo || null, + imageUrl: (() => { + const a = ev.image && String(ev.image).trim(); + const b = ev.bannerImage && String(ev.bannerImage).trim(); + return a || b || null; + })(), + }; +} + +/** Resolve Event for a public form: direct form.event, then Event.website.registrationFormId, then EventMicrosite.registrationFormId. */ +async function resolveEventForPublicForm(form) { + const directId = extractObjectIdString(form.event); + if (directId) { + const ev = await Event.findById(directId).select(EVENT_PUBLIC_SELECT).lean(); + if (ev) return { ev, micrositeLogo: null }; + } + + const formIdStr = String(form._id); + let ev = await Event.findOne({ + $or: [{ 'website.registrationFormId': formIdStr }, { 'website.registrationFormId': form._id }], + }) + .select(EVENT_PUBLIC_SELECT) + .lean(); + if (ev) return { ev, micrositeLogo: null }; + + const ms = await EventMicrosite.findOne({ + $or: [{ registrationFormId: formIdStr }, { registrationFormId: form._id }], + }) + .select('event logo') + .lean(); + if (!ms || !ms.event) return { ev: null, micrositeLogo: null }; + ev = await Event.findById(ms.event).select(EVENT_PUBLIC_SELECT).lean(); + return { ev, micrositeLogo: ev ? ms.logo : null }; +} + async function upsertParticipantFromRegistrationForm(form, tenantOrganization, dataObj) { const fields = form.fields || []; const orgOid = @@ -165,6 +238,7 @@ const listByOrganization = async (request, h) => { const oid = new mongoose.Types.ObjectId(String(tenantOidRaw)); const forms = await Form.find({ tenantOrganization: oid }) .sort({ createdAt: -1 }) + .populate('event', 'name') .lean(); const formIds = forms.map((f) => f._id); const counts = await FormSubmission.aggregate([ @@ -208,24 +282,20 @@ const createByOrganization = async (request, h) => { } const toid = new mongoose.Types.ObjectId(String(tenantOidRaw)); - let eventRef; - if (body.event) { - if (!mongoose.Types.ObjectId.isValid(String(body.event))) { - return h.response(error('VALIDATION_ERROR', 'Invalid event')).code(400); - } - const ev = await Event.findById(body.event).lean(); - if (!ev) return h.response(error('NOT_FOUND', 'Event not found')).code(404); - if (user.role !== 'super_admin' && ev.organization?.toString() !== toid.toString()) { - return h.response(error('FORBIDDEN', 'Event does not belong to your organization')).code(403); - } - eventRef = body.event; + if (!body.event || !mongoose.Types.ObjectId.isValid(String(body.event))) { + return h.response(error('VALIDATION_ERROR', 'Event is required')).code(400); + } + const ev = await Event.findById(body.event).lean(); + if (!ev) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + if (user.role !== 'super_admin' && ev.organization?.toString() !== toid.toString()) { + return h.response(error('FORBIDDEN', 'Event does not belong to your organization')).code(403); } const { event: _e, tenantOrganization: _t, ...rest } = body; const form = await Form.create({ ...rest, tenantOrganization: toid, - ...(eventRef && { event: eventRef }), + event: body.event, }); return h.response(success(form, 'Form created', 201)).code(201); }; @@ -241,13 +311,27 @@ const get = async (request, h) => { const update = async (request, h) => { const form = await Form.findById(request.params.id); if (!form) return h.response(error('NOT_FOUND', 'Form not found')).code(404); - const allowed = await formBelongsToUserOrg(form, request.auth.credentials.user); + const user = request.auth.credentials.user; + const allowed = await formBelongsToUserOrg(form, user); if (!allowed) return h.response(error('FORBIDDEN', 'Access denied')).code(403); const payload = { ...request.payload }; delete payload.tenantOrganization; - Object.assign(form, payload); + const { event: eventId, ...rest } = payload; + Object.assign(form, rest); + if (eventId !== undefined && eventId !== null) { + if (!mongoose.Types.ObjectId.isValid(String(eventId))) { + return h.response(error('VALIDATION_ERROR', 'Invalid event')).code(400); + } + const ev = await Event.findById(eventId).lean(); + if (!ev) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + if (user.role !== 'super_admin' && ev.organization?.toString() !== form.tenantOrganization?.toString()) { + return h.response(error('FORBIDDEN', 'Event does not belong to this form organization')).code(403); + } + form.event = eventId; + } await form.save(); - return h.response(success(form)); + const updated = await Form.findById(form._id).populate('event', 'name'); + return h.response(success(updated)); }; const remove = async (request, h) => { @@ -298,16 +382,147 @@ const submit = async (request, h) => { participant: participantId, data: dataObj, }); + + if (eventRef) { + const summary = await computeEventRatingsSummary(eventRef.toString()); + await Event.findByIdAndUpdate(eventRef, { + $set: { + 'metadata.rating': summary.average, + 'metadata.ratingCount': summary.count, + }, + }); + } + return h.response(success(submission, 'Submission received', 201)).code(201); }; +function dataMapToObject(data) { + if (!data) return {}; + if (data instanceof Map) return Object.fromEntries(data); + return typeof data === 'object' ? { ...data } : {}; +} + +function submissionSearchBlob(sub) { + const parts = []; + const o = dataMapToObject(sub.data); + Object.values(o).forEach((v) => { + if (v != null && v !== '') parts.push(String(v)); + }); + const p = sub.participant; + if (p && typeof p === 'object') { + parts.push(p.email || '', p.firstName || '', p.lastName || ''); + } + return parts.join(' ').toLowerCase(); +} + +/** Wildcard * splits segments; every segment must appear as substring (case-insensitive). */ +function matchesSubmissionQuery(sub, qRaw) { + if (!qRaw || !String(qRaw).trim()) return true; + const q = String(qRaw).trim(); + const segments = q.split('*').map((s) => s.trim().toLowerCase()).filter(Boolean); + if (segments.length === 0) return true; + const blob = submissionSearchBlob(sub); + return segments.every((seg) => blob.includes(seg)); +} + +function escapeCsvCell(val) { + const s = val == null ? '' : String(val); + if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`; + return s; +} + +function submissionsToCsv(form, rows) { + const labels = (form.fields || []).map((f) => f.label).filter(Boolean); + const header = ['Submitted At', 'Participant Email', ...labels]; + const lines = [header.map(escapeCsvCell).join(',')]; + for (const s of rows) { + const o = dataMapToObject(s.data); + const email = s.participant && typeof s.participant === 'object' ? s.participant.email || '' : ''; + const vals = labels.map((lab) => o[lab] ?? ''); + const row = [new Date(s.submittedAt).toISOString(), email, ...vals]; + lines.push(row.map(escapeCsvCell).join(',')); + } + return lines.join('\n'); +} + const listSubmissions = async (request, h) => { const form = await Form.findById(request.params.formId); if (!form) return h.response(error('NOT_FOUND', 'Form not found')).code(404); const allowed = await formBelongsToUserOrg(form, request.auth.credentials.user); if (!allowed) return h.response(error('FORBIDDEN', 'Access denied')).code(403); - const submissions = await FormSubmission.find({ form: form._id }).populate('participant user').sort({ submittedAt: -1 }).lean(); - return h.response(success({ submissions })); + + const { q, dateFrom, dateTo, page: pageStr, limit: limitStr, export: exportStr } = request.query; + const exportCsv = exportStr === 'csv' || exportStr === '1'; + + const match = { form: form._id }; + if (dateFrom) { + const d = new Date(dateFrom); + if (!Number.isNaN(d.getTime())) match.submittedAt = { $gte: d }; + } + if (dateTo) { + const d = new Date(dateTo); + if (!Number.isNaN(d.getTime())) { + d.setHours(23, 59, 59, 999); + match.submittedAt = { ...match.submittedAt, $lte: d }; + } + } + + let all = await FormSubmission.find(match).populate('participant', 'firstName lastName email').sort({ submittedAt: -1 }).lean(); + if (q && String(q).trim()) { + all = all.filter((s) => matchesSubmissionQuery(s, q)); + } + + if (exportCsv) { + const csv = submissionsToCsv(form, all); + return h + .response(csv) + .type('text/csv; charset=utf-8') + .header('Content-Disposition', `attachment; filename="form-${form._id}-submissions.csv"`) + .code(200); + } + + const page = Math.max(1, parseInt(String(pageStr || '1'), 10) || 1); + const limit = Math.min(100, Math.max(1, parseInt(String(limitStr || '20'), 10) || 20)); + const total = all.length; + const skip = (page - 1) * limit; + const submissions = all.slice(skip, skip + limit); + return h.response(success({ submissions, total, page, limit })); +}; + +/** Public: form definition for rendering (no auth). Only when form accepts responses. */ +const getPublicForm = async (request, h) => { + const form = await Form.findById(request.params.formId).lean(); + if (!form) return h.response(error('NOT_FOUND', 'Form not found')).code(404); + if (!form.settings?.isActive) { + return h.response(error('VALIDATION_ERROR', 'This form is not accepting responses.')).code(400); + } + + const { ev: resolvedEvent, micrositeLogo } = await resolveEventForPublicForm(form); + const eventPublic = resolvedEvent ? buildPublicEventPayload(resolvedEvent, micrositeLogo) : null; + + const safe = { + _id: form._id, + name: form.name, + description: form.description || '', + type: form.type, + event: eventPublic, + fields: (form.fields || []).map((f) => ({ + label: f.label, + type: f.type, + required: f.required, + options: f.options, + placeholder: f.placeholder, + mapsTo: f.mapsTo, + isFixed: f.isFixed, + })), + settings: { + thankYouMessage: form.settings?.thankYouMessage, + showThankYou: form.settings?.showThankYou !== false, + collectEmail: !!form.settings?.collectEmail, + allowAnonymous: !!form.settings?.allowAnonymous, + }, + }; + return h.response(success(safe)); }; module.exports = { @@ -320,4 +535,5 @@ module.exports = { remove, submit, listSubmissions, + getPublicForm, }; diff --git a/src/controllers/organization.controller.js b/src/controllers/organization.controller.js index abc5c46..c726902 100644 --- a/src/controllers/organization.controller.js +++ b/src/controllers/organization.controller.js @@ -3,6 +3,8 @@ const User = require('../models/User'); const OrganizationUserMap = require('../models/OrganizationUserMap'); const { success, error } = require('../utils/response'); const { ROLES } = require('../config/constants'); +const { encrypt } = require('../utils/smtpCrypto'); +const { clearOrgTransportCache, sendOrgMail } = require('../utils/mail'); const getOrganization = async (request, h) => { const user = request.auth.credentials.user; @@ -36,9 +38,8 @@ const updateOrganization = async (request, h) => { if (p.subscription != null && typeof p.subscription === 'object') { const sub = org.subscription || {}; const incoming = p.subscription; - if (incoming.plan != null) sub.plan = incoming.plan; + /** Plan and billing period are set via Razorpay subscription checkout or platform admin APIs only. */ if (incoming.status != null) sub.status = incoming.status; - if (incoming.expiresAt !== undefined) sub.expiresAt = incoming.expiresAt; if (incoming.maxEvents !== undefined) sub.maxEvents = incoming.maxEvents; if (incoming.maxParticipants !== undefined) sub.maxParticipants = incoming.maxParticipants; if (Array.isArray(incoming.featureOverrides)) sub.featureOverrides = incoming.featureOverrides.map(String); @@ -115,6 +116,160 @@ const updateUser = async (request, h) => { return h.response(success(out)); }; +function assertOrgAdmin(user) { + return user.role === 'super_admin' || user.role === 'admin'; +} + +const getEmailSettings = async (request, h) => { + const user = request.auth.credentials.user; + if (!assertOrgAdmin(user)) return h.response(error('FORBIDDEN', 'Insufficient permissions')).code(403); + const orgId = user.organization?._id || user.organization; + if (!orgId) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400); + const org = await Organization.findById(orgId).lean(); + if (!org) return h.response(error('NOT_FOUND', 'Organization not found')).code(404); + const s = org.smtp || {}; + const pwd = s.passwordEnc ? String(s.passwordEnc) : ''; + const userLast4 = s.user && String(s.user).length > 4 ? String(s.user).slice(-4) : s.user || ''; + return h.response( + success({ + enabled: Boolean(s.enabled), + host: s.host || '', + port: s.port || 587, + secure: Boolean(s.secure), + user: s.user || '', + userLast4, + hasPassword: pwd.length > 0, + fromName: s.fromName || '', + fromAddress: s.fromAddress || '', + }) + ); +}; + +const updateEmailSettings = async (request, h) => { + const user = request.auth.credentials.user; + if (!assertOrgAdmin(user)) return h.response(error('FORBIDDEN', 'Insufficient permissions')).code(403); + const orgId = user.organization?._id || user.organization; + if (!orgId) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400); + const org = await Organization.findById(orgId); + if (!org) return h.response(error('NOT_FOUND', 'Organization not found')).code(404); + + const p = request.payload; + org.smtp = org.smtp || {}; + if (p.enabled !== undefined) org.smtp.enabled = Boolean(p.enabled); + if (p.host !== undefined) org.smtp.host = p.host; + if (p.port !== undefined) org.smtp.port = p.port; + if (p.secure !== undefined) org.smtp.secure = Boolean(p.secure); + if (p.user !== undefined) org.smtp.user = p.user; + if (p.fromName !== undefined) org.smtp.fromName = p.fromName; + if (p.fromAddress !== undefined) org.smtp.fromAddress = p.fromAddress; + if (p.password !== undefined && String(p.password).length > 0) { + org.smtp.passwordEnc = encrypt(String(p.password)); + } + + if (org.smtp.enabled) { + if (!org.smtp.host || !org.smtp.fromAddress) { + return h.response(error('VALIDATION_ERROR', 'When SMTP is enabled, host and fromAddress are required')).code(400); + } + } + + await org.save(); + clearOrgTransportCache(orgId); + const s = org.smtp; + return h.response( + success({ + enabled: Boolean(s.enabled), + host: s.host || '', + port: s.port || 587, + secure: Boolean(s.secure), + user: s.user || '', + userLast4: s.user && String(s.user).length > 4 ? String(s.user).slice(-4) : s.user || '', + hasPassword: Boolean(s.passwordEnc), + fromName: s.fromName || '', + fromAddress: s.fromAddress || '', + }) + ); +}; + +const getWhatsAppSettings = async (request, h) => { + const { getOrgWhatsAppCredentials } = require('../utils/metaWhatsApp'); + const user = request.auth.credentials.user; + if (!assertOrgAdmin(user)) return h.response(error('FORBIDDEN', 'Insufficient permissions')).code(403); + const orgId = user.organization?._id || user.organization; + if (!orgId) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400); + const org = await Organization.findById(orgId).lean(); + if (!org) return h.response(error('NOT_FOUND', 'Organization not found')).code(404); + const wc = org.whatsappCloud || {}; + const orgHasOwnToken = Boolean(wc.enabled && wc.wabaId && wc.accessTokenEnc); + const creds = await getOrgWhatsAppCredentials(String(orgId)); + const envWaba = (process.env.META_WHATSAPP_DEFAULT_WABA_ID || '').trim(); + const envToken = (process.env.META_WHATSAPP_ACCESS_TOKEN || '').trim(); + const usingServerDefaults = Boolean(creds) && !orgHasOwnToken && Boolean(envWaba && envToken); + const graphAccess = Boolean(creds && creds.wabaId && creds.accessToken); + const canSendMessages = Boolean( + creds && creds.accessToken && String(creds.phoneNumberId || '').trim() !== '', + ); + return h.response( + success({ + enabled: Boolean(wc.enabled), + wabaId: wc.wabaId || '', + phoneNumberId: wc.phoneNumberId || '', + hasAccessToken: Boolean(wc.accessTokenEnc), + usingServerDefaults, + graphAccess, + canSendMessages, + }) + ); +}; + +const updateWhatsAppSettings = async (request, h) => { + const user = request.auth.credentials.user; + if (!assertOrgAdmin(user)) return h.response(error('FORBIDDEN', 'Insufficient permissions')).code(403); + const orgId = user.organization?._id || user.organization; + if (!orgId) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400); + const org = await Organization.findById(orgId); + if (!org) return h.response(error('NOT_FOUND', 'Organization not found')).code(404); + const p = request.payload; + org.whatsappCloud = org.whatsappCloud || {}; + if (p.enabled !== undefined) org.whatsappCloud.enabled = Boolean(p.enabled); + if (p.wabaId !== undefined) org.whatsappCloud.wabaId = String(p.wabaId || '').trim(); + if (p.phoneNumberId !== undefined) org.whatsappCloud.phoneNumberId = String(p.phoneNumberId || '').trim(); + if (p.accessToken !== undefined && String(p.accessToken).length > 0) { + org.whatsappCloud.accessTokenEnc = encrypt(String(p.accessToken)); + } + if (org.whatsappCloud.enabled) { + const wabaOk = org.whatsappCloud.wabaId?.trim() || (process.env.META_WHATSAPP_DEFAULT_WABA_ID || '').trim(); + if (!wabaOk) { + return h.response(error('VALIDATION_ERROR', 'WABA ID is required when WhatsApp Cloud is enabled')).code(400); + } + const tokenOk = org.whatsappCloud.accessTokenEnc || (process.env.META_WHATSAPP_ACCESS_TOKEN || '').trim(); + if (!tokenOk) { + return h.response(error('VALIDATION_ERROR', 'Access token is required when WhatsApp Cloud is enabled')).code(400); + } + } + await org.save(); + return getWhatsAppSettings(request, h); +}; + +const testEmailSettings = async (request, h) => { + const user = request.auth.credentials.user; + if (!assertOrgAdmin(user)) return h.response(error('FORBIDDEN', 'Insufficient permissions')).code(403); + const orgId = user.organization?._id || user.organization; + if (!orgId) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400); + const to = String(request.payload.to || '').trim(); + if (!to) return h.response(error('VALIDATION_ERROR', 'to is required')).code(400); + const r = await sendOrgMail({ + organizationId: orgId, + to, + subject: 'EventBuzz — SMTP test', + html: '

This is a test email from your EventBuzz organisation SMTP settings.

', + }); + if (!r.ok && r.skipped) { + return h.response(error('VALIDATION_ERROR', 'No SMTP available — enable organisation SMTP or set MAIL_HOST on the server')).code(400); + } + if (!r.ok) return h.response(error('SEND_FAILED', r.error || 'Send failed')).code(502); + return h.response(success({ ok: true })); +}; + const removeUser = async (request, h) => { const target = await User.findById(request.params.id); if (!target) return h.response(error('NOT_FOUND', 'User not found')).code(404); @@ -132,6 +287,11 @@ const removeUser = async (request, h) => { module.exports = { getOrganization, updateOrganization, + getEmailSettings, + updateEmailSettings, + getWhatsAppSettings, + updateWhatsAppSettings, + testEmailSettings, listUsers, addUser, updateUser, diff --git a/src/controllers/participantList.controller.js b/src/controllers/participantList.controller.js index ef28526..0d9c025 100644 --- a/src/controllers/participantList.controller.js +++ b/src/controllers/participantList.controller.js @@ -5,6 +5,7 @@ const Participant = require('../models/Participant'); const { success, error } = require('../utils/response'); const { flattenParticipantForEvent } = require('../utils/participantMembership'); const { resolveScopedOrganizationId } = require('../utils/tenantListScope'); +const { rulesToQuery } = require('../utils/participantListQuery'); function assertListOrg(list, user) { if (user.role === 'super_admin') return true; @@ -19,103 +20,6 @@ async function resolveAllowedEventIds(user, queryOrganization) { return { allowedIds: ids }; } -function mapUiStatusToApi(val) { - const s = String(val || '').toLowerCase().replace(/ /g, '_'); - if (s === 'checked_in' || s === 'checkedin') return 'checked_in'; - if (s === 'confirmed') return 'confirmed'; - if (s === 'cancelled' || s === 'canceled') return 'cancelled'; - if (s === 'pending') return 'pending'; - return String(val || '').toLowerCase(); -} - -function rulesToQuery(rules, conjunction, allowedEventIds, listTenantOrg) { - if (!allowedEventIds.length) return { _id: { $in: [] } }; - const oidList = allowedEventIds.map((id) => - id instanceof mongoose.Types.ObjectId ? id : new mongoose.Types.ObjectId(String(id)) - ); - - const clauses = (Array.isArray(rules) ? rules : []) - .map((rule) => { - const field = String(rule.field || ''); - const op = String(rule.operator || 'is'); - const val = String(rule.value || '').trim(); - - if (field === 'Status') { - const st = mapUiStatusToApi(val); - return { - eventMemberships: { - $elemMatch: { - event: { $in: oidList }, - status: op === 'is not' ? { $ne: st } : st, - }, - }, - }; - } - if (field === 'Tag') { - const inAllowedEvents = { - eventMemberships: { $elemMatch: { event: { $in: oidList } } }, - }; - const legacyMembershipTag = { - eventMemberships: { - $elemMatch: { event: { $in: oidList }, tags: val }, - }, - }; - if (op === 'is not') { - return { - $and: [ - inAllowedEvents, - { tags: { $nin: [val] } }, - { - eventMemberships: { - $not: { - $elemMatch: { event: { $in: oidList }, tags: val }, - }, - }, - }, - ], - }; - } - return { - $and: [inAllowedEvents, { $or: [{ tags: val }, legacyMembershipTag] }], - }; - } - if (field === 'Event') { - if (!mongoose.Types.ObjectId.isValid(val)) return null; - const eid = new mongoose.Types.ObjectId(val); - if (!oidList.some((x) => String(x) === String(eid))) return null; - return { - eventMemberships: { - $elemMatch: { - event: op === 'is not' ? { $nin: [eid] } : eid, - }, - }, - }; - } - if (field === 'Role' || field === 'Registration Date') { - return null; - } - return null; - }) - .filter(Boolean); - - const base = { - tenantOrganization: - listTenantOrg instanceof mongoose.Types.ObjectId - ? listTenantOrg - : new mongoose.Types.ObjectId(String(listTenantOrg)), - }; - - if (!clauses.length) { - return { - ...base, - eventMemberships: { $elemMatch: { event: { $in: oidList } } }, - }; - } - - const combined = conjunction === 'OR' ? { $or: clauses } : { $and: clauses }; - return { $and: [base, combined] }; -} - const list = async (request, h) => { const user = request.auth.credentials.user; const scoped = resolveScopedOrganizationId(user, request.query.organization); diff --git a/src/controllers/platformAdmin.controller.js b/src/controllers/platformAdmin.controller.js new file mode 100644 index 0000000..8a65a64 --- /dev/null +++ b/src/controllers/platformAdmin.controller.js @@ -0,0 +1,371 @@ +const Organization = require('../models/Organization'); +const User = require('../models/User'); +const OrganizationUserMap = require('../models/OrganizationUserMap'); +const SubscriptionPlan = require('../models/SubscriptionPlan'); +const { success, error } = require('../utils/response'); +const { defaultFeatureValuesByPlanId } = require('../config/planFeatureMatrix'); +const { + FEATURES, + DEFAULT_PLANS, + listFeatures, + listPlans, + invalidatePlanCache, + warmPlanCache, +} = require('../config/subscriptionCatalog'); + +function defaultPricingForPlan(planId) { + return ( + DEFAULT_PLANS[planId]?.pricing || { + amount: null, + currency: 'INR', + interval: 'month', + contactSales: false, + } + ); +} + +/** Merge optional `pricing` payload with catalog defaults or existing document. */ +function applyFeatureValuesPayload(incoming, existingRow, planId) { + const defaults = defaultFeatureValuesByPlanId(planId); + const base = + existingRow && existingRow.featureValues && typeof existingRow.featureValues === 'object' + ? { + event_website: existingRow.featureValues.event_website ?? defaults.event_website, + email_outreach_campaigns: { + maxEmails: + existingRow.featureValues.email_outreach_campaigns && + existingRow.featureValues.email_outreach_campaigns.maxEmails !== undefined + ? existingRow.featureValues.email_outreach_campaigns.maxEmails + : defaults.email_outreach_campaigns.maxEmails, + }, + } + : defaults; + if (!incoming || typeof incoming !== 'object') return base; + const maxFromIn = + incoming.email_outreach_campaigns && + typeof incoming.email_outreach_campaigns === 'object' && + incoming.email_outreach_campaigns.maxEmails !== undefined + ? incoming.email_outreach_campaigns.maxEmails === null + ? null + : Number(incoming.email_outreach_campaigns.maxEmails) + : undefined; + return { + event_website: + incoming.event_website !== undefined ? String(incoming.event_website) : base.event_website, + email_outreach_campaigns: { + maxEmails: maxFromIn !== undefined ? maxFromIn : base.email_outreach_campaigns.maxEmails, + }, + }; +} + +function applyPricingPayload(incoming, existingRow, planId) { + const base = + existingRow && existingRow.pricing && typeof existingRow.pricing === 'object' + ? { + amount: existingRow.pricing.amount, + currency: existingRow.pricing.currency, + interval: existingRow.pricing.interval, + contactSales: existingRow.pricing.contactSales, + } + : defaultPricingForPlan(planId); + if (!incoming || typeof incoming !== 'object') return base; + return { + amount: + incoming.amount !== undefined + ? incoming.amount === null + ? null + : Number(incoming.amount) + : base.amount, + currency: + incoming.currency !== undefined + ? String(incoming.currency || '').trim() || 'INR' + : base.currency, + interval: + incoming.interval !== undefined + ? String(incoming.interval || '').trim() || 'month' + : base.interval, + contactSales: + incoming.contactSales !== undefined ? Boolean(incoming.contactSales) : base.contactSales, + }; +} + +function escapeRegex(s) { + return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function serializeOrgDoc(org, userCount) { + const o = typeof org.toObject === 'function' ? org.toObject() : { ...org }; + o._id = o._id != null ? String(o._id) : o._id; + o.userCount = userCount; + return o; +} + +async function listOrganizations(request, h) { + const page = Math.max(1, Number(request.query.page) || 1); + const limit = Math.min(100, Math.max(1, Number(request.query.limit) || 20)); + const search = request.query.search ? String(request.query.search).trim() : ''; + const q = {}; + if (search) { + const rx = new RegExp(escapeRegex(search), 'i'); + q.$or = [{ name: rx }, { email: rx }]; + } + const skip = (page - 1) * limit; + const [orgs, total] = await Promise.all([ + Organization.find(q).sort({ createdAt: -1 }).skip(skip).limit(limit).lean(), + Organization.countDocuments(q), + ]); + const organizations = await Promise.all( + orgs.map(async (org) => { + const userCount = await User.countDocuments({ organization: org._id }); + return serializeOrgDoc(org, userCount); + }), + ); + return h.response(success({ organizations, total, page, limit })).code(200); +} + +/** + * Create a tenant organization and its first user (org role super_admin). + */ +async function createOrganization(request, h) { + const { organization: orgIn, admin } = request.payload; + const adminEmail = String(admin.email).trim(); + const existingUser = await User.findOne({ email: adminEmail }); + if (existingUser) { + return h.response(error('VALIDATION_ERROR', 'A user with this email already exists')).code(400); + } + + const sub = {}; + if (orgIn.subscription && typeof orgIn.subscription === 'object') { + if (orgIn.subscription.plan != null) sub.plan = orgIn.subscription.plan; + if (orgIn.subscription.status != null) sub.status = orgIn.subscription.status; + } + + const str = (v) => (v == null ? undefined : String(v).trim()); + const opt = (v) => { + const s = str(v); + return s === '' ? undefined : s; + }; + + const org = await Organization.create({ + name: str(orgIn.name), + description: opt(orgIn.description), + email: opt(orgIn.email), + phone: opt(orgIn.phone), + website: opt(orgIn.website), + timezone: opt(orgIn.timezone), + ...(Object.keys(sub).length ? { subscription: sub } : {}), + }); + + const user = await User.create({ + email: adminEmail, + password: admin.password, + firstName: admin.firstName != null ? String(admin.firstName).trim() : undefined, + lastName: admin.lastName != null ? String(admin.lastName).trim() : undefined, + organization: org._id, + role: 'super_admin', + }); + + await OrganizationUserMap.create({ + user: user._id, + organization: org._id, + role: 'super_admin', + eventAccess: [], + }); + + const userCount = await User.countDocuments({ organization: org._id }); + const organization = serializeOrgDoc(await Organization.findById(org._id).lean(), userCount); + const adminOut = user.toObject(); + delete adminOut.password; + + return h + .response( + success({ + organization, + adminUser: adminOut, + }), + ) + .code(201); +} + +async function getOrganization(request, h) { + const org = await Organization.findById(request.params.id).lean(); + if (!org) return h.response(error('NOT_FOUND', 'Organization not found')).code(404); + const userCount = await User.countDocuments({ organization: org._id }); + return h.response(success({ organization: serializeOrgDoc(org, userCount) })).code(200); +} + +async function patchOrganizationSubscription(request, h) { + const org = await Organization.findById(request.params.id); + if (!org) return h.response(error('NOT_FOUND', 'Organization not found')).code(404); + const p = request.payload; + if (!org.subscription) org.subscription = {}; + const sub = org.subscription; + if (p.plan != null) sub.plan = p.plan; + if (p.status != null) sub.status = p.status; + if (p.expiresAt !== undefined) sub.expiresAt = p.expiresAt ? new Date(p.expiresAt) : undefined; + if (p.maxEvents !== undefined) sub.maxEvents = p.maxEvents; + if (p.maxParticipants !== undefined) sub.maxParticipants = p.maxParticipants; + if (Array.isArray(p.featureOverrides)) sub.featureOverrides = p.featureOverrides.map(String); + org.subscription = sub; + org.markModified('subscription'); + await org.save(); + const userCount = await User.countDocuments({ organization: org._id }); + return h.response(success({ organization: serializeOrgDoc(org, userCount) })).code(200); +} + +async function listPermissions(request, h) { + return h.response(success({ features: listFeatures() })).code(200); +} + +async function listPlansAdmin(request, h) { + const plans = await listPlans(); + const withCounts = await Promise.all( + plans.map(async (p) => ({ + ...p, + organizationCount: await Organization.countDocuments({ 'subscription.plan': p.id }), + })), + ); + return h.response(success({ plans: withCounts })).code(200); +} + +async function createPlan(request, h) { + const { PLAN_ID_REGEX } = SubscriptionPlan; + const b = request.payload; + const planId = String(b.planId || '').trim(); + if (!PLAN_ID_REGEX.test(planId)) { + return h + .response( + error( + 'VALIDATION_ERROR', + 'planId must be a lowercase slug: start with a letter, then letters, digits, or underscores (max 64 chars).', + ), + ) + .code(400); + } + const dup = await SubscriptionPlan.findOne({ planId }); + if (dup) { + return h.response(error('VALIDATION_ERROR', 'A plan with this id already exists')).code(400); + } + const featureKeys = Array.isArray(b.featureKeys) + ? [...new Set(b.featureKeys.filter((k) => typeof k === 'string' && FEATURES[k]))] + : []; + await SubscriptionPlan.create({ + planId, + name: String(b.name).trim(), + tagline: b.tagline != null ? String(b.tagline) : '', + featureKeys, + defaultLimits: + b.defaultLimits && typeof b.defaultLimits === 'object' + ? { + maxEvents: b.defaultLimits.maxEvents, + maxParticipants: b.defaultLimits.maxParticipants, + } + : { maxEvents: null, maxParticipants: null }, + pricing: applyPricingPayload(b.pricing, null, planId), + featureValues: applyFeatureValuesPayload(b.featureValues, null, planId), + }); + invalidatePlanCache(); + await warmPlanCache(); + const plans = await listPlans(); + const withCounts = await Promise.all( + plans.map(async (p) => ({ + ...p, + organizationCount: await Organization.countDocuments({ 'subscription.plan': p.id }), + })), + ); + return h.response(success({ plans: withCounts })).code(201); +} + +async function putPlan(request, h) { + const { PLAN_ID_REGEX } = SubscriptionPlan; + const { planId } = request.params; + if (!PLAN_ID_REGEX.test(planId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid plan id')).code(400); + } + const b = request.payload; + const existing = await SubscriptionPlan.findOne({ planId }).lean(); + if (!existing) { + return h.response(error('NOT_FOUND', 'Plan not found')).code(404); + } + const featureKeys = Array.isArray(b.featureKeys) + ? [...new Set(b.featureKeys.filter((k) => typeof k === 'string' && FEATURES[k]))] + : undefined; + const set = {}; + if (b.name != null) set.name = String(b.name).trim(); + if (b.tagline !== undefined) set.tagline = String(b.tagline); + if (featureKeys !== undefined) set.featureKeys = featureKeys; + if (b.defaultLimits != null && typeof b.defaultLimits === 'object') { + set.defaultLimits = { + maxEvents: b.defaultLimits.maxEvents, + maxParticipants: b.defaultLimits.maxParticipants, + }; + } + if (b.pricing !== undefined) { + set.pricing = applyPricingPayload(b.pricing, existing, planId); + } + if (b.featureValues !== undefined) { + set.featureValues = applyFeatureValuesPayload(b.featureValues, existing, planId); + } + if (Object.keys(set).length === 0) { + return h.response(error('VALIDATION_ERROR', 'No valid fields to update')).code(400); + } + const updated = await SubscriptionPlan.findOneAndUpdate({ planId }, { $set: set }, { new: true }); + if (!updated) { + return h.response(error('NOT_FOUND', 'Plan not found')).code(404); + } + invalidatePlanCache(); + await warmPlanCache(); + const plans = await listPlans(); + const withCounts = await Promise.all( + plans.map(async (p) => ({ + ...p, + organizationCount: await Organization.countDocuments({ 'subscription.plan': p.id }), + })), + ); + return h.response(success({ plans: withCounts })).code(200); +} + +async function deletePlan(request, h) { + const { PLAN_ID_REGEX } = SubscriptionPlan; + const { planId } = request.params; + if (!PLAN_ID_REGEX.test(planId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid plan id')).code(400); + } + const inUse = await Organization.countDocuments({ 'subscription.plan': planId }); + if (inUse > 0) { + return h + .response( + error( + 'CONFLICT', + `This plan is assigned to ${inUse} organization(s). Move them to another plan before deleting.`, + ), + ) + .code(409); + } + const removed = await SubscriptionPlan.findOneAndDelete({ planId }); + if (!removed) { + return h.response(error('NOT_FOUND', 'Plan not found')).code(404); + } + invalidatePlanCache(); + await warmPlanCache(); + const plans = await listPlans(); + const withCounts = await Promise.all( + plans.map(async (p) => ({ + ...p, + organizationCount: await Organization.countDocuments({ 'subscription.plan': p.id }), + })), + ); + return h.response(success({ plans: withCounts })).code(200); +} + +module.exports = { + listOrganizations, + createOrganization, + getOrganization, + patchOrganizationSubscription, + listPermissions, + listPlansAdmin, + createPlan, + putPlan, + deletePlan, +}; diff --git a/src/controllers/publicAttendance.controller.js b/src/controllers/publicAttendance.controller.js new file mode 100644 index 0000000..08de487 --- /dev/null +++ b/src/controllers/publicAttendance.controller.js @@ -0,0 +1,88 @@ +const Event = require('../models/Event'); +const { success, error } = require('../utils/response'); +const { + performCheckIn, + loadAttendanceByToken, + searchEventParticipantsForAttendance, +} = require('../services/attendanceCheckIn.service'); + +const getSession = async (request, h) => { + const { token } = request.params; + const r = await loadAttendanceByToken(token); + if (r.err) return h.response(r.err).code(404); + + const attendance = r.attendance; + if (attendance.status !== 'active') { + return h.response(error('BAD_REQUEST', 'This attendance session is closed')).code(400); + } + + const event = await Event.findById(attendance.event) + .select('name subtitle startDate endDate location locationType image') + .lean(); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + + return h.response( + success({ + name: attendance.name, + description: attendance.description, + settings: { + allowDuplicateCheckIn: Boolean(attendance.settings?.allowDuplicateCheckIn), + publicLinkEnabled: Boolean(attendance.settings?.publicLinkEnabled), + }, + status: attendance.status, + event: { + name: event.name, + subtitle: event.subtitle, + startDate: event.startDate, + endDate: event.endDate, + location: event.location, + locationType: event.locationType, + image: event.image, + }, + }) + ); +}; + +const search = async (request, h) => { + const { token } = request.params; + const r = await loadAttendanceByToken(token); + if (r.err) return h.response(r.err).code(404); + + const attendance = r.attendance; + if (attendance.status !== 'active') { + return h.response(error('BAD_REQUEST', 'This attendance session is closed')).code(400); + } + if (!attendance.settings?.publicLinkEnabled) { + return h.response(error('FORBIDDEN', 'Public check-in link is disabled')).code(403); + } + + const q = String(request.query.q || '').trim(); + + const data = await searchEventParticipantsForAttendance({ + eventId: attendance.event, + organizationId: attendance.organization, + q: q || undefined, + attendanceId: attendance._id, + limit: q ? 500 : 5000, + maxCap: 5000, + }); + return h.response(success(data)); +}; + +const checkIn = async (request, h) => { + const { token } = request.params; + const { participantId } = request.payload; + const outcome = await performCheckIn({ + publicToken: token, + participantId, + source: 'public', + }); + if (outcome.err) return h.response(outcome.err).code(outcome.statusCode || 400); + return h.response(success(outcome.result, 'Checked in')); +}; + +module.exports = { + getSession, + search, + checkIn, +}; diff --git a/src/controllers/publicConcierge.controller.js b/src/controllers/publicConcierge.controller.js new file mode 100644 index 0000000..06cdb3c --- /dev/null +++ b/src/controllers/publicConcierge.controller.js @@ -0,0 +1,134 @@ +const crypto = require('crypto'); +const mongoose = require('mongoose'); +const ConciergeBot = require('../models/ConciergeBot'); +const Event = require('../models/Event'); +const WebChatSession = require('../models/WebChatSession'); +const { success, error } = require('../utils/response'); +const { executeConciergeRag } = require('../services/conciergeRag.service'); + +const MAX_MESSAGE_LENGTH = 800; +const MAX_HISTORY_MESSAGES = 40; + +// Basic in-memory rate limit (best-effort). +const ipBuckets = new Map(); +const WINDOW_MS = 60_000; +const MAX_REQ_PER_WINDOW = 20; + +function takeIpToken(ip) { + const now = Date.now(); + const key = String(ip || 'unknown'); + const row = ipBuckets.get(key) || { resetAt: now + WINDOW_MS, count: 0 }; + if (now > row.resetAt) { + row.resetAt = now + WINDOW_MS; + row.count = 0; + } + row.count += 1; + ipBuckets.set(key, row); + return row.count <= MAX_REQ_PER_WINDOW; +} + +async function loadBotOr404(botId) { + if (!mongoose.Types.ObjectId.isValid(String(botId))) return { err: error('VALIDATION_ERROR', 'Invalid bot') }; + const bot = await ConciergeBot.findById(botId).lean(); + if (!bot) return { err: error('NOT_FOUND', 'Chatbot not found') }; + if (!bot.enabled || bot.status !== 'ACTIVE') return { err: error('FORBIDDEN', 'Chatbot is not enabled') }; + const event = await Event.findById(bot.event).select('name').lean(); + return { bot, event }; +} + +const config = async (request, h) => { + const { botId } = request.params; + const { bot, event, err } = await loadBotOr404(botId); + if (err) return h.response(err).code(err.error?.code === 'NOT_FOUND' ? 404 : 400); + return h.response( + success({ + botId: String(bot._id), + eventId: String(bot.event), + eventName: event?.name || 'Event', + greeting: bot.greeting?.message || '', + maxMessageLength: MAX_MESSAGE_LENGTH, + }), + ); +}; + +const chat = async (request, h) => { + const ip = request.headers['x-forwarded-for'] || request.info.remoteAddress; + if (!takeIpToken(ip)) return h.response(error('RATE_LIMIT', 'Too many requests. Try again in a minute.')).code(429); + + const { botId } = request.params; + const { bot, event, err } = await loadBotOr404(botId); + if (err) return h.response(err).code(err.error?.code === 'NOT_FOUND' ? 404 : 400); + + const msg = String(request.payload?.message || '').trim(); + if (!msg) return h.response(error('VALIDATION_ERROR', 'Message is required')).code(400); + if (msg.length > MAX_MESSAGE_LENGTH) { + return h + .response(error('VALIDATION_ERROR', `Message too long (max ${MAX_MESSAGE_LENGTH} chars)`)) + .code(400); + } + + let sessionId = String(request.payload?.sessionId || '').trim(); + if (!sessionId) sessionId = crypto.randomBytes(16).toString('hex'); + + let session = await WebChatSession.findOne({ botId: bot._id, sessionId }); + if (!session) { + session = await WebChatSession.create({ + botId: bot._id, + eventId: bot.event, + organization: bot.organization, + sessionId, + history: [], + lastSeenAt: new Date(), + }); + } + + session.history.push({ + role: 'user', + text: msg, + ts: new Date(), + modelId: null, + inputTokens: 0, + outputTokens: 0, + }); + if (session.history.length > MAX_HISTORY_MESSAGES) { + session.history = session.history.slice(-MAX_HISTORY_MESSAGES); + } + session.lastSeenAt = new Date(); + + const historyForPrompt = session.history.slice(-12).map((m) => ({ role: m.role, text: m.text })); + const includeEventDetails = bot.knowledge?.includeEventDetails !== false; + + const ragResult = await executeConciergeRag(String(bot.event), msg, event?.name || 'Event', { + includeEventDetails, + history: historyForPrompt, + }); + const answerText = + ragResult?.text || + "I don't have that in my knowledge base yet. Please check with event staff or try another question."; + + const reply = String(answerText || '').trim().slice(0, 1200); + const inTok = ragResult?.usage?.inputTokens || 0; + const outTok = ragResult?.usage?.outputTokens || 0; + session.inputTokensTotal = Number(session.inputTokensTotal || 0) + Number(inTok || 0); + session.outputTokensTotal = Number(session.outputTokensTotal || 0) + Number(outTok || 0); + session.history.push({ + role: 'assistant', + text: reply, + ts: new Date(), + modelId: ragResult?.modelId || null, + inputTokens: inTok, + outputTokens: outTok, + }); + if (session.history.length > MAX_HISTORY_MESSAGES) { + session.history = session.history.slice(-MAX_HISTORY_MESSAGES); + } + await session.save(); + + return h.response(success({ sessionId, reply, usage: ragResult?.usage || null })); +}; + +module.exports = { + config, + chat, +}; + diff --git a/src/controllers/publicEventTicket.controller.js b/src/controllers/publicEventTicket.controller.js new file mode 100644 index 0000000..79f332c --- /dev/null +++ b/src/controllers/publicEventTicket.controller.js @@ -0,0 +1,378 @@ +const mongoose = require('mongoose'); +const Event = require('../models/Event'); +const EventTicket = require('../models/EventTicket'); +const Form = require('../models/Form'); +const TicketManualOrder = require('../models/TicketManualOrder'); +const { success, error } = require('../utils/response'); +const { computeTicketQuote, ticketRequiresBillingState } = require('../utils/eventTicketPricing'); +const { createPaymentOrderCore } = require('../services/payments/createOrderCore'); +const { validateAndBuildTicketPurchaseContext } = require('../utils/publicTicketCheckoutShared'); +const { organizationHasOnlineTicketCheckout } = require('../utils/eventTicketingGateway'); +const { uploadBufferToS3AndGetCloudFrontUrl } = require('../utils/s3Media'); +const { notifyTicketStakeholders } = require('../services/ticketSaleNotify'); +const { isManualPaymentConfigured } = require('../utils/ticketingManualPayment'); + +function publicTicketShape(ticket, registrationFormFields) { + return { + _id: ticket._id, + name: ticket.name, + description: ticket.description, + keyPoints: ticket.keyPoints || [], + basePriceMinor: ticket.basePriceMinor, + currency: ticket.currency || 'INR', + maxPerOrder: ticket.maxPerOrder, + requiresBillingState: ticketRequiresBillingState(ticket), + registrationFormId: ticket.registrationForm || null, + registrationFormFields: registrationFormFields || null, + }; +} + +async function loadFormFieldsForPublic(formId) { + if (!formId || !mongoose.Types.ObjectId.isValid(String(formId))) return null; + const form = await Form.findById(formId).lean(); + if (!form || !form.settings?.isActive) return null; + const fields = (form.fields || []).map((f) => ({ + label: f.label, + type: f.type, + required: Boolean(f.required), + options: f.options, + placeholder: f.placeholder, + mapsTo: f.mapsTo, + })); + return fields; +} + +const eventSummary = async (request, h) => { + const { eventId } = request.params; + if (!mongoose.Types.ObjectId.isValid(eventId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid event')).code(400); + } + const event = await Event.findById(eventId) + .select('name subtitle startDate endDate location locationType image') + .lean(); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + return h.response( + success({ + name: event.name, + subtitle: event.subtitle, + startDate: event.startDate, + endDate: event.endDate, + location: event.location, + locationType: event.locationType, + image: event.image, + }), + ); +}; + +const listCatalog = async (request, h) => { + const { eventId } = request.params; + if (!mongoose.Types.ObjectId.isValid(eventId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid event')).code(400); + } + const event = await Event.findById(eventId).lean(); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + + const tickets = await EventTicket.find({ event: eventId, isActive: true }).sort({ sortOrder: 1, name: 1 }).lean(); + const out = []; + for (const t of tickets) { + const regFields = t.registrationForm ? await loadFormFieldsForPublic(t.registrationForm) : null; + out.push(publicTicketShape(t, regFields)); + } + return h.response(success({ tickets: out })); +}; + +const quote = async (request, h) => { + const { eventId } = request.params; + const { ticketId, quantity, billingStateCode } = request.payload; + if (!mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(ticketId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid id')).code(400); + } + const ticket = await EventTicket.findOne({ _id: ticketId, event: eventId, isActive: true }).lean(); + if (!ticket) return h.response(error('NOT_FOUND', 'Ticket not found')).code(404); + + const state = billingStateCode ? String(billingStateCode).trim().toUpperCase() : ''; + if (ticketRequiresBillingState(ticket) && !state) { + return h.response(error('VALIDATION_ERROR', 'Billing state is required for this ticket')).code(400); + } + + const qty = Math.min(100, Math.max(1, parseInt(quantity, 10) || 1)); + if (ticket.maxPerOrder && qty > ticket.maxPerOrder) { + return h.response(error('VALIDATION_ERROR', `Maximum ${ticket.maxPerOrder} per order`)).code(400); + } + + const q = computeTicketQuote(ticket, qty, state); + return h.response( + success({ + ticketId: String(ticket._id), + quantity: qty, + billingStateCode: state || null, + baseMinor: q.baseMinor, + taxMinor: q.taxMinor, + totalMinor: q.totalMinor, + taxLines: q.taxLines, + currency: ticket.currency || 'INR', + }), + ); +}; + +const checkoutOptions = async (request, h) => { + const { eventId } = request.params; + if (!mongoose.Types.ObjectId.isValid(eventId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid event')).code(400); + } + const event = await Event.findById(eventId).select('name organization ticketingManualPayment').lean(); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + + const onlineAvailable = await organizationHasOnlineTicketCheckout(event.organization); + const mp = event.ticketingManualPayment || {}; + const manualEligible = Boolean(mp.enabled && isManualPaymentConfigured(mp)); + const manualQrMode = mp.manualQrMode === 'dynamic' ? 'dynamic' : 'upload'; + + return h.response( + success({ + onlineAvailable, + manualUpi: manualEligible + ? { + manualQrMode, + eventName: event.name || '', + upiQrImageUrl: String(mp.upiQrImageUrl || '').trim(), + upiId: mp.upiId || '', + payeeName: mp.payeeName || '', + instructions: mp.instructions || '', + /** Always 0 — organiser covers Eventbuzz via manual UPI enablement fee; buyers pay ticket + tax only. */ + platformFeeInr: 0, + } + : null, + }), + ); +}; + +const manualQuote = async (request, h) => { + const { eventId } = request.params; + const { ticketId, quantity, billingStateCode } = request.payload; + if (!mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(ticketId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid id')).code(400); + } + const event = await Event.findById(eventId).lean(); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + + const mp = event.ticketingManualPayment || {}; + if (!mp.enabled || !isManualPaymentConfigured(mp)) { + return h.response(error('VALIDATION_ERROR', 'Manual UPI is not enabled for this event')).code(400); + } + + const ticket = await EventTicket.findOne({ _id: ticketId, event: eventId, isActive: true }).lean(); + if (!ticket) return h.response(error('NOT_FOUND', 'Ticket not found')).code(404); + + const state = billingStateCode ? String(billingStateCode).trim().toUpperCase() : ''; + if (ticketRequiresBillingState(ticket) && !state) { + return h.response(error('VALIDATION_ERROR', 'Billing state is required for this ticket')).code(400); + } + + const qty = Math.min(100, Math.max(1, parseInt(quantity, 10) || 1)); + if (ticket.maxPerOrder && qty > ticket.maxPerOrder) { + return h.response(error('VALIDATION_ERROR', `Maximum ${ticket.maxPerOrder} per order`)).code(400); + } + + const q = computeTicketQuote(ticket, qty, state); + + return h.response( + success({ + ticketId: String(ticket._id), + ticketName: ticket.name || '', + eventName: event.name || '', + quantity: qty, + billingStateCode: state || null, + baseMinor: q.baseMinor, + taxMinor: q.taxMinor, + platformFeeMinor: 0, + totalMinor: q.totalMinor, + taxLines: q.taxLines, + currency: ticket.currency || 'INR', + }), + ); +}; + +function parseDataUrlImage(dataUrl) { + const s = String(dataUrl || ''); + const m = s.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/); + if (!m) return null; + const contentType = m[1]; + const buf = Buffer.from(m[2], 'base64'); + if (!buf.length || buf.length > 5 * 1024 * 1024) return null; + return { buffer: buf, contentType }; +} + +const manualSubmit = async (request, h) => { + const { eventId } = request.params; + const p = request.payload; + + const event = await Event.findById(eventId).lean(); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + + const mp = event.ticketingManualPayment || {}; + if (!mp.enabled || !isManualPaymentConfigured(mp)) { + return h.response(error('VALIDATION_ERROR', 'Manual UPI is not enabled for this event')).code(400); + } + + const v = await validateAndBuildTicketPurchaseContext(eventId, p); + if (!v.ok) { + const code = v.code === 'NOT_FOUND' ? 404 : 400; + return h.response(error(v.code, v.message)).code(code); + } + + const q = v.quoteResult; + if (q.totalMinor !== Math.round(Number(p.expectedTotalMinor))) { + return h.response(error('VALIDATION_ERROR', 'Price changed — refresh and try again')).code(409); + } + + const img = parseDataUrlImage(p.screenshotDataUrl); + if (!img) { + return h.response(error('VALIDATION_ERROR', 'Invalid or too large payment screenshot (use a base64 data URL image, max 5MB)')).code(400); + } + + const orgId = String(event.organization); + const filename = `ticket-manual-${Date.now()}.${img.contentType.includes('png') ? 'png' : 'jpg'}`; + let screenshotUrl; + try { + const up = await uploadBufferToS3AndGetCloudFrontUrl({ + buffer: img.buffer, + contentType: img.contentType, + orgId, + eventId: String(eventId), + module: 'ticket-manual-payments', + filename, + }); + screenshotUrl = up.url; + } catch (e) { + console.error('[manualSubmit] S3 upload failed', e); + return h.response(error('SERVER_ERROR', 'Could not store screenshot')).code(500); + } + + const registrationContext = { + ...v.registrationContext, + quote: { + baseMinor: q.baseMinor, + taxMinor: q.taxMinor, + totalMinor: q.totalMinor, + taxLines: q.taxLines, + platformFeeMinor: 0, + quantity: v.qty, + }, + }; + + const order = await TicketManualOrder.create({ + event: eventId, + organization: event.organization, + ticket: p.ticketId, + purchaserEmail: registrationContext.purchaserEmail, + attendees: registrationContext.attendees, + quote: registrationContext.quote, + currency: v.currency, + billingStateCode: v.state || '', + screenshotUrl, + buyerNote: p.buyerNote ? String(p.buyerNote).trim().slice(0, 2000) : '', + status: 'pending_review', + }); + + void notifyTicketStakeholders({ + kind: 'manual_submitted', + eventId: String(eventId), + organizationId: orgId, + ticketName: v.ticket.name, + purchaserEmail: registrationContext.purchaserEmail, + amountMinor: q.totalMinor, + currency: v.currency, + orderId: String(order._id), + }); + + return h + .response( + success({ + orderId: String(order._id), + message: 'Submitted for review. You will be notified when it is approved.', + }), + ) + .code(201); +}; + +const checkout = async (request, h) => { + const { eventId } = request.params; + const p = request.payload; + + const event = await Event.findById(eventId).lean(); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + + const onlineAvailable = await organizationHasOnlineTicketCheckout(event.organization); + if (!onlineAvailable) { + return h.response( + error( + 'VALIDATION_ERROR', + 'Online ticket checkout requires the organiser’s connected Razorpay account. Use UPI / manual checkout for this event if it is enabled, or ask the organiser to add their payment keys.', + ), + ).code(400); + } + + const v = await validateAndBuildTicketPurchaseContext(eventId, p); + if (!v.ok) { + const code = v.code === 'NOT_FOUND' ? 404 : 400; + return h.response(error(v.code, v.message)).code(code); + } + + if (v.quoteResult.totalMinor !== Math.round(Number(p.expectedTotalMinor))) { + return h.response(error('VALIDATION_ERROR', 'Price changed — refresh and try again')).code(409); + } + + try { + const result = await createPaymentOrderCore({ + organizationId: event.organization, + eventId, + participantId: null, + amountSubtotalMinor: v.quoteResult.totalMinor, + currency: v.currency, + idempotencyKey: p.idempotencyKey || null, + registrationContext: v.registrationContext, + purpose: 'ticket_purchase', + }); + return h + .response( + success({ + keyId: result.keyId, + orderId: result.orderId, + amount: result.amount, + currency: result.currency, + transactionId: result.transaction._id.toString(), + checkoutSource: result.checkoutSource, + }), + ) + .code(201); + } catch (e) { + if (e.code === 'IDEMPOTENCY_COMPLETE') { + return h.response(error('VALIDATION_ERROR', e.message)).code(409); + } + if ( + e.code === 'GATEWAY_NOT_IMPLEMENTED' || + e.code === 'PLATFORM_CHECKOUT_DISABLED' || + e.code === 'PLATFORM_RAZORPAY_MISSING' || + e.code === 'NO_GATEWAY' || + e.code === 'TICKET_ORG_GATEWAY_REQUIRED' || + e.code === 'BAD_GATEWAY_CREDENTIALS' || + e.code === 'IDEMPOTENCY_MISMATCH' || + e.code === 'INVALID_AMOUNT' || + e.code === 'RAZORPAY_SDK_MISSING' + ) { + return h.response(error('VALIDATION_ERROR', e.message)).code(400); + } + throw e; + } +}; + +module.exports = { + eventSummary, + listCatalog, + quote, + checkoutOptions, + manualQuote, + manualSubmit, + checkout, +}; diff --git a/src/controllers/question.controller.js b/src/controllers/question.controller.js index cb062b6..68680e1 100644 --- a/src/controllers/question.controller.js +++ b/src/controllers/question.controller.js @@ -1,5 +1,6 @@ const Question = require('../models/Question'); const Event = require('../models/Event'); +const Session = require('../models/Session'); const { success, error } = require('../utils/response'); const { syncSessionQuestionToFirebase, @@ -147,7 +148,16 @@ const answer = async (request, h) => { }; const sessionQuestions = async (request, h) => { - const questions = await Question.find({ session: request.params.sessionId }).populate('participant user').lean(); + const { sessionId } = request.params; + const session = await Session.findById(sessionId).lean(); + if (!session) return h.response(error('NOT_FOUND', 'Session not found')).code(404); + const event = await Event.findById(session.event); + if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const user = request.auth.credentials.user; + if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + const questions = await Question.find({ session: sessionId }).populate('participant user').sort({ upvotes: -1, createdAt: -1 }).lean(); return h.response(success({ questions })); }; diff --git a/src/controllers/subscription.controller.js b/src/controllers/subscription.controller.js index 17215bf..5be9df5 100644 --- a/src/controllers/subscription.controller.js +++ b/src/controllers/subscription.controller.js @@ -1,12 +1,183 @@ -const { success } = require('../utils/response'); -const { listFeatures, listPlans } = require('../config/subscriptionCatalog'); +const PaymentTransaction = require('../models/PaymentTransaction'); +const Organization = require('../models/Organization'); +const Event = require('../models/Event'); +const User = require('../models/User'); +const { success, error } = require('../utils/response'); +const { sendContactSalesLeadEmail } = require('../utils/mail'); +const { verifyPaymentCore, minorToMajor } = require('../services/payments/verifyPaymentCore'); +const { + createSubscriptionCheckoutOrder, + buildSubscriptionInvoiceHtml, + buildManualEnablementInvoiceHtml, +} = require('../services/subscriptionPayment.service'); +const { listFeatures, listPlans, getPlanForAccess } = require('../config/subscriptionCatalog'); const catalog = async (request, h) => h.response( success({ features: listFeatures(), - plans: listPlans(), + plans: await listPlans(), }), ); -module.exports = { catalog }; +const checkoutOrder = async (request, h) => { + const user = request.auth.credentials.user; + const orgId = user.organization?._id || user.organization; + const { targetPlanId } = request.payload; + try { + const result = await createSubscriptionCheckoutOrder({ + organizationId: orgId, + userId: user._id, + targetPlanId: String(targetPlanId).trim(), + }); + return h + .response( + success({ + keyId: result.keyId, + orderId: result.orderId, + amount: result.amount, + currency: result.currency, + transactionId: result.transaction._id.toString(), + checkoutSource: result.checkoutSource, + }), + ) + .code(201); + } catch (e) { + const code = e.code || 'ERROR'; + const msg = e.message || 'Failed to create order'; + if (['INVALID_PLAN', 'CONTACT_SALES', 'NO_CHARGE', 'PLAN_MISSING'].includes(code)) { + return h.response(error(code, msg)).code(400); + } + if (code === 'INVALID_AMOUNT') return h.response(error('VALIDATION_ERROR', msg)).code(400); + if (code === 'PLATFORM_RAZORPAY_MISSING' || code === 'NO_GATEWAY') { + return h.response(error('VALIDATION_ERROR', msg)).code(400); + } + throw e; + } +}; + +const checkoutVerify = async (request, h) => { + const user = request.auth.credentials.user; + const orgId = user.organization?._id || user.organization; + const { orderId, paymentId, signature } = request.payload; + const out = await verifyPaymentCore({ + orderId, + paymentId, + signature, + organizationId: orgId, + }); + if (out.error) { + const status = out.error === 'NOT_FOUND' ? 404 : 400; + return h.response(error(out.error, out.message)).code(status); + } + return h.response( + success({ + verified: true, + transactionId: out.transaction._id.toString(), + }), + ); +}; + +const listInvoices = async (request, h) => { + const user = request.auth.credentials.user; + const orgId = user.organization?._id || user.organization; + const rows = await PaymentTransaction.find({ + organization: orgId, + purpose: { $in: ['saas_subscription', 'ticketing_manual_enablement'] }, + status: 'paid', + }) + .sort({ createdAt: -1 }) + .limit(100) + .lean(); + + const invoices = rows.map((r) => { + const isEnablement = r.purpose === 'ticketing_manual_enablement'; + return { + id: r._id.toString(), + createdAt: r.createdAt, + planId: isEnablement ? 'manual_upi_enablement' : r.subscriptionPlanId, + invoiceLabel: isEnablement ? 'Manual UPI ticketing — enablement fee' : r.subscriptionPlanId, + amountMajor: minorToMajor(r.amountGross, r.currency), + currency: r.currency, + status: r.status, + razorpayOrderId: r.externalOrderId, + razorpayPaymentId: r.externalPaymentId, + }; + }); + return h.response(success({ invoices })); +}; + +const contactSalesRequest = async (request, h) => { + const user = request.auth.credentials.user; + const orgId = user.organization?._id || user.organization; + if (!orgId) return h.response(error('NO_ORG', 'No organisation')).code(400); + + const { requirements, targetPlanId } = request.payload; + const org = await Organization.findById(orgId).lean(); + const planKey = targetPlanId ? String(targetPlanId).trim() : 'enterprise'; + const planRow = getPlanForAccess(planKey); + + const admins = await User.find({ platform_admin: 'yes', isActive: { $ne: false } }) + .select('email') + .lean(); + const emails = admins.map((a) => a.email).filter(Boolean); + if (emails.length === 0) { + return h + .response(error('NO_PLATFORM_ADMIN', 'No platform administrators are configured to receive sales enquiries.')) + .code(503); + } + + const requesterName = [user.firstName, user.lastName].filter(Boolean).join(' ').trim(); + const ok = await sendContactSalesLeadEmail({ + to: emails, + requesterEmail: user.email, + requesterName: requesterName || undefined, + organizationName: org?.name, + planId: planRow.id || planKey, + planName: planRow.name, + requirements, + }); + if (!ok) { + return h.response(error('MAIL_FAILED', 'Could not send message. Check server email configuration.')).code(502); + } + return h.response(success({ sent: true })).code(200); +}; + +const downloadInvoice = async (request, h) => { + const user = request.auth.credentials.user; + const orgId = user.organization?._id || user.organization; + const { transactionId } = request.params; + const tx = await PaymentTransaction.findOne({ + _id: transactionId, + organization: orgId, + purpose: { $in: ['saas_subscription', 'ticketing_manual_enablement'] }, + status: 'paid', + }).lean(); + if (!tx) return h.response(error('NOT_FOUND', 'Invoice not found')).code(404); + const org = await Organization.findById(orgId).lean(); + let html; + if (tx.purpose === 'ticketing_manual_enablement') { + const ctx = tx.registrationContext && typeof tx.registrationContext === 'object' ? tx.registrationContext : {}; + let eventName = ''; + if (ctx.eventId) { + const ev = await Event.findById(ctx.eventId).select('name').lean(); + eventName = ev?.name || ''; + } + html = buildManualEnablementInvoiceHtml(org, tx, eventName); + } else { + html = buildSubscriptionInvoiceHtml(org, tx); + } + return h + .response(html) + .type('text/html; charset=utf-8') + .header('Content-Disposition', `attachment; filename="eventbuzz-invoice-${tx._id}.html"`); +}; + +module.exports = { + catalog, + checkoutOrder, + checkoutVerify, + contactSalesRequest, + listInvoices, + downloadInvoice, +}; diff --git a/src/controllers/ticketManual.controller.js b/src/controllers/ticketManual.controller.js new file mode 100644 index 0000000..cdf056d --- /dev/null +++ b/src/controllers/ticketManual.controller.js @@ -0,0 +1,149 @@ +const mongoose = require('mongoose'); +const Event = require('../models/Event'); +const TicketManualOrder = require('../models/TicketManualOrder'); +const EventTicket = require('../models/EventTicket'); +const { success, error } = require('../utils/response'); +const { fulfillTicketPurchaseFromContext } = require('../services/ticketPurchaseFulfillment'); +const { notifyTicketStakeholders } = require('../services/ticketSaleNotify'); + +async function assertEventAccess(eventId, user) { + const event = await Event.findById(eventId).select('organization').lean(); + if (!event) return { err: (h) => h.response(error('NOT_FOUND', 'Event not found')).code(404) }; + if ( + user.role !== 'super_admin' && + String(event.organization) !== String(user.organization?._id || user.organization || '') + ) { + return { err: (h) => h.response(error('FORBIDDEN', 'Access denied')).code(403) }; + } + return { event }; +} + +const list = async (request, h) => { + const { eventId } = request.params; + const status = request.query.status ? String(request.query.status) : 'pending_review'; + const user = request.auth.credentials.user; + const r = await assertEventAccess(eventId, user); + if (r.err) return r.err(h); + + const filter = { event: eventId }; + if (status !== 'all' && ['pending_review', 'approved', 'rejected'].includes(status)) filter.status = status; + + const orders = await TicketManualOrder.find(filter) + .sort({ createdAt: -1 }) + .limit(200) + .populate('ticket', 'name') + .lean(); + + return h.response( + success({ + orders: orders.map((o) => ({ + _id: o._id, + status: o.status, + createdAt: o.createdAt, + purchaserEmail: o.purchaserEmail, + screenshotUrl: o.screenshotUrl, + buyerNote: o.buyerNote, + quote: o.quote, + currency: o.currency, + ticket: o.ticket ? { _id: o.ticket._id, name: o.ticket.name } : null, + reviewedAt: o.reviewedAt, + rejectReason: o.rejectReason, + fulfillmentParticipantIds: o.fulfillmentParticipantIds || [], + })), + }), + ); +}; + +const approve = async (request, h) => { + const { eventId, orderId } = request.params; + const user = request.auth.credentials.user; + const r = await assertEventAccess(eventId, user); + if (r.err) return r.err(h); + + if (!mongoose.Types.ObjectId.isValid(orderId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid order')).code(400); + } + + const order = await TicketManualOrder.findOne({ _id: orderId, event: eventId }); + if (!order) return h.response(error('NOT_FOUND', 'Order not found')).code(404); + if (order.status !== 'pending_review') { + return h.response(error('VALIDATION_ERROR', 'Order is not pending review')).code(400); + } + + const ctx = { + kind: 'ticket_purchase', + ticketId: String(order.ticket), + eventId: String(order.event), + quantity: order.quote?.quantity || order.attendees?.length || 1, + billingStateCode: order.billingStateCode || null, + purchaserEmail: order.purchaserEmail, + attendees: order.attendees, + quote: order.quote, + }; + + const ids = await fulfillTicketPurchaseFromContext(ctx, { + paymentLine: { + amountGrossMinor: order.quote?.totalMinor ?? 0, + currency: (order.currency || 'INR').toUpperCase(), + externalId: `manual-upi:${order._id}`, + }, + }); + + if (!ids.length) { + return h.response(error('SERVER_ERROR', 'Fulfillment failed — check ticket and list configuration')).code(500); + } + + order.status = 'approved'; + order.reviewedAt = new Date(); + order.reviewedBy = user._id || user.id; + order.fulfillmentParticipantIds = ids.map((id) => String(id)); + await order.save(); + + const ticket = await EventTicket.findById(order.ticket).select('name').lean(); + void notifyTicketStakeholders({ + kind: 'manual_approved', + eventId: String(eventId), + organizationId: String(order.organization), + ticketName: ticket?.name, + purchaserEmail: order.purchaserEmail, + amountMinor: order.quote?.totalMinor, + currency: order.currency, + }); + + return h.response(success({ ok: true, participantIds: order.fulfillmentParticipantIds })); +}; + +const reject = async (request, h) => { + const { eventId, orderId } = request.params; + const { reason } = request.payload || {}; + const user = request.auth.credentials.user; + const r = await assertEventAccess(eventId, user); + if (r.err) return r.err(h); + + if (!mongoose.Types.ObjectId.isValid(orderId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid order')).code(400); + } + + const order = await TicketManualOrder.findOne({ _id: orderId, event: eventId }); + if (!order) return h.response(error('NOT_FOUND', 'Order not found')).code(404); + if (order.status !== 'pending_review') { + return h.response(error('VALIDATION_ERROR', 'Order is not pending review')).code(400); + } + + order.status = 'rejected'; + order.reviewedAt = new Date(); + order.reviewedBy = user._id || user.id; + order.rejectReason = reason ? String(reason).trim().slice(0, 2000) : ''; + await order.save(); + + void notifyTicketStakeholders({ + kind: 'manual_rejected', + eventId: String(eventId), + organizationId: String(order.organization), + rejectReason: order.rejectReason, + }); + + return h.response(success({ ok: true })); +}; + +module.exports = { list, approve, reject }; diff --git a/src/controllers/visitor.controller.js b/src/controllers/visitor.controller.js index 84c6bbd..aba25bd 100644 --- a/src/controllers/visitor.controller.js +++ b/src/controllers/visitor.controller.js @@ -16,6 +16,7 @@ const { participantInEvent } = require('../utils/participantMembership'); const { mergeRules } = require('../utils/rewardRulesResolve'); const { isObjectiveQuestionCorrect } = require('../utils/quizLoyaltyScore'); const { awardIfNew } = require('../services/rewardAward'); +const { computeEventRatingsSummary } = require('../utils/eventRatingsSummary'); /** All event fields for JSON except website, timestamps; adds logoUrl from website.logo || image. Never expose inviteCode. */ function publicEventLoginPayload(doc) { @@ -148,7 +149,13 @@ async function loginPreview(request, h) { if (!event) { return h.response(error('NOT_FOUND', 'Event not found')).code(404); } - return h.response(success(publicEventLoginPayload(event))); + const ratings = await computeEventRatingsSummary(eventId); + return h.response( + success({ + ...publicEventLoginPayload(event), + ratings, + }), + ); } /** GET …/visitor/events/{eventId}/tracks — public; tracks for agenda links */ diff --git a/src/controllers/whatsappTemplate.controller.js b/src/controllers/whatsappTemplate.controller.js new file mode 100644 index 0000000..607c328 --- /dev/null +++ b/src/controllers/whatsappTemplate.controller.js @@ -0,0 +1,191 @@ +const WhatsAppTemplate = require('../models/WhatsAppTemplate'); +const { success, error } = require('../utils/response'); +const { + createMessageTemplate, + getOrgWhatsAppCredentials, + validateTemplateComponentsForMeta, + resolveTemplateImageHeaderHandles, +} = require('../utils/metaWhatsApp'); +const { syncOrganizationTemplates } = require('../services/whatsappTemplateMetaSync'); + +function organizationId(user) { + return user.organization?._id || user.organization; +} + +function assertRead(user) { + if (!['super_admin', 'admin', 'moderator', 'viewer'].includes(user.role)) { + return { code: 'FORBIDDEN', message: 'Insufficient permissions', status: 403 }; + } + return null; +} + +function assertWrite(user) { + if (!['super_admin', 'admin'].includes(user.role)) { + return { code: 'FORBIDDEN', message: 'Only administrators can manage WhatsApp templates', status: 403 }; + } + return null; +} + +function normalizeLanguage(lang) { + return String(lang || '') + .trim() + .replace(/-/g, '_'); +} + +const list = async (request, h) => { + const user = request.auth.credentials.user; + const denied = assertRead(user); + if (denied) return h.response(error(denied.code, denied.message)).code(denied.status); + const oid = organizationId(user); + if (!oid) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400); + + const doSync = request.query.sync === '1' || request.query.sync === 'true'; + if (doSync) { + try { + const r = await syncOrganizationTemplates(oid); + if (!r.ok) return h.response(error('VALIDATION_ERROR', r.error || 'Sync failed')).code(400); + } catch (err) { + return h.response(error('META_ERROR', err.message || 'Meta sync failed')).code(502); + } + } + + const q = { organization: oid }; + if (request.query.status) q.status = String(request.query.status).toUpperCase(); + const templates = await WhatsAppTemplate.find(q).sort({ updatedAt: -1 }).lean(); + return h.response(success({ templates })); +}; + +const get = async (request, h) => { + const user = request.auth.credentials.user; + const denied = assertRead(user); + if (denied) return h.response(error(denied.code, denied.message)).code(denied.status); + const oid = organizationId(user); + if (!oid) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400); + const doc = await WhatsAppTemplate.findOne({ _id: request.params.id, organization: oid }).lean(); + if (!doc) return h.response(error('NOT_FOUND', 'Template not found')).code(404); + + const doSync = request.query.sync === '1' || request.query.sync === 'true'; + if (doSync) { + try { + await syncOrganizationTemplates(oid); + } catch { + /* ignore */ + } + const fresh = await WhatsAppTemplate.findOne({ _id: request.params.id, organization: oid }).lean(); + return h.response(success(fresh || doc)); + } + return h.response(success(doc)); +}; + +const create = async (request, h) => { + const user = request.auth.credentials.user; + const denied = assertWrite(user); + if (denied) return h.response(error(denied.code, denied.message)).code(denied.status); + const oid = organizationId(user); + if (!oid) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400); + + const creds = await getOrgWhatsAppCredentials(oid); + if (!creds) { + return h + .response( + error( + 'VALIDATION_ERROR', + 'WhatsApp Cloud is not configured. Add WABA ID and access token under Settings, or set META_WHATSAPP_DEFAULT_* on the server.', + ), + ) + .code(400); + } + + const { name, language, category, components, variableBindings } = request.payload; + const lang = normalizeLanguage(language); + const metaBody = { + name, + language: lang, + category, + components: JSON.parse(JSON.stringify(components || [])), + }; + + try { + validateTemplateComponentsForMeta(metaBody.components); + } catch (err) { + return h.response(error('VALIDATION_ERROR', err.message || 'Invalid template')).code(400); + } + + try { + await resolveTemplateImageHeaderHandles(metaBody.components, creds.accessToken); + } catch (err) { + if (err.code === 'WA_TEMPLATE_RULE') { + return h.response(error('VALIDATION_ERROR', err.message)).code(400); + } + const status = err.status && err.status < 500 ? 400 : 502; + const code = err.status && err.status < 500 ? 'META_VALIDATION' : 'META_ERROR'; + return h.response(error(code, err.message || 'Meta media upload failed')).code(status); + } + + let metaRes; + try { + metaRes = await createMessageTemplate(creds.wabaId, creds.accessToken, metaBody); + } catch (err) { + const code = err.status && err.status < 500 ? 'META_VALIDATION' : 'META_ERROR'; + return h.response(error(code, err.message || 'Meta API error')).code(err.status && err.status < 500 ? 400 : 502); + } + + try { + const vb = variableBindings && typeof variableBindings === 'object' ? variableBindings : {}; + const doc = await WhatsAppTemplate.create({ + organization: oid, + name, + language: lang, + category, + components, + variableBindings: { + header: Array.isArray(vb.header) ? vb.header.map((x) => String(x || '').trim()) : [], + body: Array.isArray(vb.body) ? vb.body.map((x) => String(x || '').trim()) : [], + url: Array.isArray(vb.url) ? vb.url.map((x) => String(x || '').trim()) : [], + }, + metaTemplateId: metaRes.id != null ? String(metaRes.id) : '', + status: metaRes.status != null ? String(metaRes.status).toUpperCase() : 'PENDING', + rejectionReason: '', + lastSyncedAt: new Date(), + lastError: '', + createdBy: user._id, + }); + return h.response(success(doc, 'Template submitted to Meta', 201)).code(201); + } catch (e) { + if (e.code === 11000) { + return h.response(error('VALIDATION_ERROR', 'A template with this name and language already exists')).code(400); + } + throw e; + } +}; + +const syncOne = async (request, h) => { + const user = request.auth.credentials.user; + const denied = assertWrite(user); + if (denied) return h.response(error(denied.code, denied.message)).code(denied.status); + const oid = organizationId(user); + if (!oid) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400); + const exists = await WhatsAppTemplate.findOne({ _id: request.params.id, organization: oid }).lean(); + if (!exists) return h.response(error('NOT_FOUND', 'Template not found')).code(404); + try { + const r = await syncOrganizationTemplates(oid); + if (!r.ok) return h.response(error('VALIDATION_ERROR', r.error || 'Sync failed')).code(400); + } catch (err) { + return h.response(error('META_ERROR', err.message || 'Meta sync failed')).code(502); + } + const doc = await WhatsAppTemplate.findOne({ _id: request.params.id, organization: oid }).lean(); + return h.response(success(doc)); +}; + +const remove = async (request, h) => { + const user = request.auth.credentials.user; + const denied = assertWrite(user); + if (denied) return h.response(error(denied.code, denied.message)).code(denied.status); + const oid = organizationId(user); + if (!oid) return h.response(error('VALIDATION_ERROR', 'No organization')).code(400); + const r = await WhatsAppTemplate.deleteOne({ _id: request.params.id, organization: oid }); + if (r.deletedCount === 0) return h.response(error('NOT_FOUND', 'Template not found')).code(404); + return h.response(success(null, 'Template removed from dashboard')); +}; + +module.exports = { list, get, create, syncOne, remove }; diff --git a/src/jobs/subscriptionExpiry.job.js b/src/jobs/subscriptionExpiry.job.js new file mode 100644 index 0000000..57a57f4 --- /dev/null +++ b/src/jobs/subscriptionExpiry.job.js @@ -0,0 +1,27 @@ +const Organization = require('../models/Organization'); + +/** + * Downgrade organisations whose paid subscription period has ended. + * @returns {Promise} modified count + */ +async function runSubscriptionExpiry() { + const now = new Date(); + const result = await Organization.updateMany( + { + 'subscription.plan': { $nin: [null, '', 'free'] }, + 'subscription.expiresAt': { $lte: now }, + }, + { + $set: { + 'subscription.plan': 'free', + 'subscription.status': 'active', + 'subscription.expiresAt': null, + 'subscription.maxEvents': null, + 'subscription.maxParticipants': null, + }, + }, + ); + return result.modifiedCount ?? 0; +} + +module.exports = { runSubscriptionExpiry }; diff --git a/src/jobs/whatsappTemplateSync.job.js b/src/jobs/whatsappTemplateSync.job.js new file mode 100644 index 0000000..ecf49d1 --- /dev/null +++ b/src/jobs/whatsappTemplateSync.job.js @@ -0,0 +1,26 @@ +const WhatsAppTemplate = require('../models/WhatsAppTemplate'); +const { syncOrganizationTemplates } = require('../services/whatsappTemplateMetaSync'); + +const PENDING_LIKE = ['PENDING', 'IN_APPEAL', 'PENDING_DELETION', 'PENDING_REVIEW']; + +/** + * Poll Meta for templates that are still awaiting approval (or similar). + * @returns {number} number of organisations synced + */ +async function runWhatsAppTemplateSync() { + const orgIds = await WhatsAppTemplate.distinct('organization', { + status: { $in: PENDING_LIKE }, + }); + let n = 0; + for (const oid of orgIds) { + try { + const r = await syncOrganizationTemplates(oid); + if (r.ok) n += 1; + } catch (err) { + console.error('[cron whatsappTemplateSync]', String(oid), err.message); + } + } + return n; +} + +module.exports = { runWhatsAppTemplateSync, PENDING_LIKE }; diff --git a/src/models/Attendance.js b/src/models/Attendance.js new file mode 100644 index 0000000..39fd3b6 --- /dev/null +++ b/src/models/Attendance.js @@ -0,0 +1,40 @@ +const mongoose = require('mongoose'); +const crypto = require('crypto'); + +const attendanceSchema = new mongoose.Schema( + { + event: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + required: true, + index: true, + }, + organization: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Organization', + required: true, + index: true, + }, + name: { type: String, required: true, trim: true }, + description: { type: String, default: '', trim: true }, + settings: { + allowDuplicateCheckIn: { type: Boolean, default: false }, + publicLinkEnabled: { type: Boolean, default: true }, + }, + publicToken: { type: String, required: true, unique: true, index: true }, + status: { + type: String, + enum: ['active', 'archived'], + default: 'active', + }, + }, + { timestamps: true } +); + +attendanceSchema.index({ event: 1, createdAt: -1 }); + +attendanceSchema.statics.generatePublicToken = function generatePublicToken() { + return crypto.randomBytes(32).toString('hex'); +}; + +module.exports = mongoose.model('Attendance', attendanceSchema, 'attendances'); diff --git a/src/models/AttendanceParticipant.js b/src/models/AttendanceParticipant.js new file mode 100644 index 0000000..93b1a38 --- /dev/null +++ b/src/models/AttendanceParticipant.js @@ -0,0 +1,48 @@ +const mongoose = require('mongoose'); + +const checkInEntrySchema = new mongoose.Schema( + { + checkedInAt: { type: Date, required: true, default: Date.now }, + source: { type: String, enum: ['dashboard', 'public'], required: true }, + checkedInBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, + }, + { _id: false } +); + +const attendanceParticipantSchema = new mongoose.Schema( + { + attendance: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Attendance', + required: true, + index: true, + }, + event: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + required: true, + index: true, + }, + participant: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Participant', + required: true, + index: true, + }, + firstName: { type: String, trim: true }, + lastName: { type: String, trim: true }, + email: { type: String, trim: true, lowercase: true }, + phone: { type: String, trim: true }, + checkIns: { type: [checkInEntrySchema], default: [] }, + }, + { timestamps: true } +); + +attendanceParticipantSchema.index({ attendance: 1, participant: 1 }, { unique: true }); +attendanceParticipantSchema.index({ attendance: 1, updatedAt: -1 }); + +module.exports = mongoose.model( + 'AttendanceParticipant', + attendanceParticipantSchema, + 'attendance_participants' +); diff --git a/src/models/BadgeBatch.js b/src/models/BadgeBatch.js new file mode 100644 index 0000000..c16db56 --- /dev/null +++ b/src/models/BadgeBatch.js @@ -0,0 +1,64 @@ +const mongoose = require('mongoose'); + +const badgeConfigSchema = new mongoose.Schema( + { + sizePreset: { type: String, default: 'a6' }, + widthMm: { type: Number }, + heightMm: { type: Number }, + orientation: { type: String, enum: ['portrait', 'landscape'], default: 'portrait' }, + includeFaculty: { type: Boolean, default: false }, + includeParticipants: { type: Boolean, default: false }, + participantScope: { type: String, enum: ['all', 'participant_list'], default: 'all' }, + participantListId: { type: mongoose.Schema.Types.ObjectId, ref: 'ParticipantList' }, + backgroundMode: { type: String, enum: ['image', 'color'], required: true }, + backgroundImageUrl: { type: String, default: '' }, + backgroundColor: { type: String, default: '#0B4DD1' }, + fields: [{ type: String }], + outputFormat: { type: String, enum: ['pdf', 'png', 'jpeg'], required: true }, + /** Display label for badge type tag when field badge_type is enabled */ + badgeTypeLabel: { type: String, default: 'Attendee' }, + }, + { _id: false }, +); + +const badgeBatchSchema = new mongoose.Schema( + { + organization: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Organization', + required: true, + index: true, + }, + event: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + required: true, + index: true, + }, + createdBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + }, + name: { type: String, required: true, trim: true }, + status: { + type: String, + enum: ['pending', 'processing', 'ready', 'failed'], + default: 'pending', + index: true, + }, + lastError: { type: String }, + startedAt: Date, + completedAt: Date, + zipUrl: { type: String, trim: true }, + zipS3Key: { type: String, trim: true }, + itemCount: { type: Number }, + config: { type: badgeConfigSchema, required: true }, + }, + { timestamps: true }, +); + +badgeBatchSchema.index({ organization: 1, createdAt: -1 }); +badgeBatchSchema.index({ status: 1, createdAt: 1 }); + +module.exports = mongoose.model('BadgeBatch', badgeBatchSchema); diff --git a/src/models/ChatSubscriber.js b/src/models/ChatSubscriber.js new file mode 100644 index 0000000..55cd808 --- /dev/null +++ b/src/models/ChatSubscriber.js @@ -0,0 +1,32 @@ +const mongoose = require('mongoose'); + +const chatSubscriberSchema = new mongoose.Schema( + { + eventId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + required: true, + index: true, + }, + attendeeId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Participant', + default: null, + }, + phoneNumber: { type: String, required: true, index: true }, + sessionState: { + type: String, + enum: ['WELCOME_FORK', 'AWAITING_TICKET', 'MAIN_MENU', 'LOCAL_GUIDE_MODE'], + default: 'WELCOME_FORK', + }, + metaContext: { + guestModeEnabled: { type: Boolean, default: false }, + lastMenuInteraction: { type: Date, default: Date.now }, + }, + }, + { timestamps: true }, +); + +chatSubscriberSchema.index({ eventId: 1, phoneNumber: 1 }, { unique: true }); + +module.exports = mongoose.model('ChatSubscriber', chatSubscriberSchema); diff --git a/src/models/ConciergeBot.js b/src/models/ConciergeBot.js new file mode 100644 index 0000000..e98d90c --- /dev/null +++ b/src/models/ConciergeBot.js @@ -0,0 +1,50 @@ +const mongoose = require('mongoose'); + +const conciergeBotSchema = new mongoose.Schema( + { + organization: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Organization', + required: true, + index: true, + }, + event: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + required: true, + }, + name: { type: String, trim: true }, + wabaPhoneNumberId: { type: String, required: true, trim: true }, + enabled: { type: Boolean, default: false }, + status: { + type: String, + enum: ['DRAFT', 'ACTIVE', 'ARCHIVED'], + default: 'DRAFT', + }, + greeting: { + enabled: { type: Boolean, default: true }, + message: { + type: String, + default: + 'Hi {profileName}! Welcome to {eventName}. How would you like to continue?', + }, + }, + registration: { + promptIfUnregistered: { type: Boolean, default: true }, + allowGuestSkip: { type: Boolean, default: true }, + }, + knowledge: { + includeEventDetails: { type: Boolean, default: true }, + lastEventSnapshotAt: Date, + }, + lastKnowledgeClearedAt: Date, + lastPurgedAt: Date, + }, + { timestamps: true }, +); + +conciergeBotSchema.index({ organization: 1, event: 1 }, { unique: true }); +conciergeBotSchema.index({ wabaPhoneNumberId: 1 }, { unique: true }); +conciergeBotSchema.index({ organization: 1, status: 1 }); + +module.exports = mongoose.model('ConciergeBot', conciergeBotSchema); diff --git a/src/models/ContextChunk.js b/src/models/ContextChunk.js new file mode 100644 index 0000000..04c45fc --- /dev/null +++ b/src/models/ContextChunk.js @@ -0,0 +1,27 @@ +const mongoose = require('mongoose'); + +const contextChunkSchema = new mongoose.Schema( + { + eventId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + required: true, + index: true, + }, + sourceId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'KnowledgeSource', + required: true, + index: true, + }, + rawText: { type: String, required: true }, + embedding: { type: [Number], required: true }, + embeddingModel: { type: String, trim: true }, + isActive: { type: Boolean, default: false, index: true }, + }, + { timestamps: true }, +); + +contextChunkSchema.index({ eventId: 1, isActive: 1 }); + +module.exports = mongoose.model('ContextChunk', contextChunkSchema); diff --git a/src/models/DesignTemplate.js b/src/models/DesignTemplate.js index 91b244b..7272883 100644 --- a/src/models/DesignTemplate.js +++ b/src/models/DesignTemplate.js @@ -30,6 +30,12 @@ const designTemplateSchema = new mongoose.Schema( canDuplicate: { type: Boolean, default: true }, }, usedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Event' }], + /** Optional event this emailer is associated with (CTA resolution, default campaign event). */ + associatedEvent: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + required: false, + }, createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', diff --git a/src/models/EmailQueue.js b/src/models/EmailQueue.js new file mode 100644 index 0000000..09c1b6a --- /dev/null +++ b/src/models/EmailQueue.js @@ -0,0 +1,46 @@ +const mongoose = require('mongoose'); + +const emailQueueSchema = new mongoose.Schema( + { + campaign: { + type: mongoose.Schema.Types.ObjectId, + ref: 'MessagingCampaign', + required: true, + index: true, + }, + event: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + required: true, + }, + organization: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Organization', + required: true, + }, + participant: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Participant', + }, + to: { type: String, required: true, trim: true, lowercase: true }, + subject: { type: String, required: true }, + body: { type: String, required: true }, + status: { + type: String, + enum: ['pending', 'processing', 'sent', 'failed', 'cancelled'], + default: 'pending', + index: true, + }, + processAfter: { type: Date, default: () => new Date(), index: true }, + attempts: { type: Number, default: 0 }, + lastError: String, + lockedAt: Date, + sentAt: Date, + }, + { timestamps: true } +); + +emailQueueSchema.index({ status: 1, processAfter: 1, createdAt: 1 }); +emailQueueSchema.index({ campaign: 1, status: 1 }); + +module.exports = mongoose.model('EmailQueue', emailQueueSchema); diff --git a/src/models/Event.js b/src/models/Event.js index 29e6421..d05a692 100644 --- a/src/models/Event.js +++ b/src/models/Event.js @@ -17,6 +17,17 @@ const eventSchema = new mongoose.Schema( enum: ['conference', 'workshop', 'summit', 'cme', 'webinar'], required: true, }, + /** `event` = full conference-style row; `activity` = standalone Q&A / quiz / poll shell (still Event→Track→Session in DB). */ + recordKind: { + type: String, + enum: ['event', 'activity'], + default: 'event', + }, + /** When recordKind is `activity`: qna | quiz | poll */ + activitySubtype: { + type: String, + enum: ['qna', 'quiz', 'poll'], + }, status: { type: String, enum: ['draft', 'upcoming', 'live', 'completed'], @@ -86,6 +97,24 @@ const eventSchema = new mongoose.Schema( /** GrapesJS / visual editor main markup (injected at publish when set; canonical store for custom HTML). */ micrositeHtml: String, }, + /** + * UPI / manual proof checkout (alongside or instead of org Razorpay). Scoped to this event. + * Buyer totals are ticket + tax only; Eventbuzz is covered by ticketingManualPayment enablement (billing). + */ + ticketingManualPayment: { + enabled: { type: Boolean, default: false }, + /** `upload` = static QR image URL; `dynamic` = build `upi://pay?...` at checkout from UPI ID + amount. */ + manualQrMode: { type: String, enum: ['upload', 'dynamic'], default: 'upload' }, + upiQrImageUrl: { type: String, trim: true, default: '' }, + upiId: { type: String, trim: true, default: '' }, + payeeName: { type: String, trim: true, default: '' }, + instructions: { type: String, default: '' }, + /** Legacy / unused — per-order buyer Eventbuzz fees are not applied; kept for older documents. */ + platformFeeInr: { type: Number, min: 0, default: null }, + /** Set when org pays EVENT_TICKETING_MANUAL_ENABLEMENT_FEE_INR (billing / invoices). Required before manual UPI can be enabled. */ + enablementFeePaidAt: { type: Date, default: null }, + enablementTransaction: { type: mongoose.Schema.Types.ObjectId, ref: 'PaymentTransaction', default: null }, + }, metadata: { totalRevenue: { type: Number, default: 0 }, rating: { type: Number, default: 0 }, @@ -104,6 +133,7 @@ const eventSchema = new mongoose.Schema( ); eventSchema.index({ organization: 1 }); +eventSchema.index({ organization: 1, recordKind: 1 }); eventSchema.index({ status: 1 }); eventSchema.index({ startDate: 1 }); eventSchema.index({ 'website.folder': 1 }); diff --git a/src/models/EventTicket.js b/src/models/EventTicket.js new file mode 100644 index 0000000..68a2817 --- /dev/null +++ b/src/models/EventTicket.js @@ -0,0 +1,58 @@ +const mongoose = require('mongoose'); + +/** + * Tax row: rateBps = basis points on pre-tax base (e.g. 1800 = 18%). + * appliesToStates: empty array = applies in all states; otherwise only listed state codes (e.g. KA, MH). + */ +const taxItemSchema = new mongoose.Schema( + { + label: { type: String, required: true, trim: true }, + rateBps: { type: Number, required: true, min: 0, max: 100000 }, + appliesToStates: { type: [String], default: [] }, + }, + { _id: false }, +); + +const eventTicketSchema = new mongoose.Schema( + { + event: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + required: true, + index: true, + }, + organization: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Organization', + required: true, + index: true, + }, + name: { type: String, required: true, trim: true, maxlength: 200 }, + description: { type: String, default: '' }, + keyPoints: { type: [String], default: [] }, + /** Pre-tax unit price, smallest currency unit (e.g. paise) */ + basePriceMinor: { type: Number, required: true, min: 0 }, + currency: { type: String, default: 'INR', uppercase: true, trim: true }, + isActive: { type: Boolean, default: true }, + sortOrder: { type: Number, default: 0 }, + maxPerOrder: { type: Number, default: null, min: 1 }, + inventoryCap: { type: Number, default: null, min: 1 }, + taxItems: { type: [taxItemSchema], default: [] }, + registrationForm: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Form', + default: null, + }, + /** Fixed list: ticket buyers are $addToSet to participantIds after payment */ + participantList: { + type: mongoose.Schema.Types.ObjectId, + ref: 'ParticipantList', + required: true, + }, + }, + { timestamps: true }, +); + +eventTicketSchema.index({ event: 1, isActive: 1, sortOrder: 1 }); + +module.exports = mongoose.model('EventTicket', eventTicketSchema); diff --git a/src/models/Form.js b/src/models/Form.js index fc03803..5544c2f 100644 --- a/src/models/Form.js +++ b/src/models/Form.js @@ -4,7 +4,7 @@ const formFieldSchema = new mongoose.Schema({ label: String, type: { type: String, - enum: ['text', 'email', 'phone', 'select', 'radio', 'checkbox', 'checkboxes', 'file'], + enum: ['text', 'email', 'phone', 'select', 'stars', 'radio', 'checkbox', 'checkboxes', 'file'], }, required: Boolean, options: [String], diff --git a/src/models/KnowledgeSource.js b/src/models/KnowledgeSource.js new file mode 100644 index 0000000..b3017ec --- /dev/null +++ b/src/models/KnowledgeSource.js @@ -0,0 +1,40 @@ +const mongoose = require('mongoose'); + +const knowledgeSourceSchema = new mongoose.Schema( + { + eventId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + required: true, + index: true, + }, + type: { + type: String, + enum: ['url', 'pdf', 'event'], + required: true, + }, + sourcePath: { type: String, required: true }, + displayName: { type: String, required: true }, + status: { + type: String, + enum: ['PENDING', 'PROCESSING', 'READY', 'FAILED'], + default: 'PENDING', + }, + errorMessage: { type: String, default: null }, + lastSyncedAt: { type: Date, default: Date.now }, + /** URL sources: domain-wide or path-prefix crawl */ + crawlScope: { type: String, enum: ['domain', 'path'], default: null }, + crawlPathPrefix: { type: String, default: null }, + lastCrawlStats: { + pages: { type: Number, default: 0 }, + assets: { type: Number, default: 0 }, + skipped: { type: Number, default: 0 }, + failed: { type: Number, default: 0 }, + }, + }, + { timestamps: true }, +); + +knowledgeSourceSchema.index({ eventId: 1, type: 1 }); + +module.exports = mongoose.model('KnowledgeSource', knowledgeSourceSchema); diff --git a/src/models/MessagingCampaign.js b/src/models/MessagingCampaign.js index bbd17d8..ab2a415 100644 --- a/src/models/MessagingCampaign.js +++ b/src/models/MessagingCampaign.js @@ -3,10 +3,11 @@ const mongoose = require('mongoose'); const recipientsSchema = new mongoose.Schema({ type: { type: String, - enum: ['all', 'filtered', 'custom'], + enum: ['all', 'filtered', 'custom', 'participant_list'], }, filters: { type: Map, of: mongoose.Schema.Types.Mixed }, customList: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Participant' }], + participantList: { type: mongoose.Schema.Types.ObjectId, ref: 'ParticipantList' }, }); const contentSchema = new mongoose.Schema({ @@ -14,6 +15,10 @@ const contentSchema = new mongoose.Schema({ body: String, template: String, variables: { type: Map, of: mongoose.Schema.Types.Mixed }, + /** WhatsApp: ObjectId string of WhatsAppTemplate */ + whatsappTemplateId: String, + waHeaderImageUrl: String, + waOverrides: { type: mongoose.Schema.Types.Mixed }, }); const campaignSchema = new mongoose.Schema( @@ -39,9 +44,10 @@ const campaignSchema = new mongoose.Schema( content: contentSchema, status: { type: String, - enum: ['draft', 'scheduled', 'sending', 'sent', 'failed'], + enum: ['draft', 'scheduled', 'sending', 'sent', 'failed', 'stopped'], default: 'draft', }, + stoppedAt: Date, scheduledFor: Date, sentAt: Date, stats: { @@ -62,4 +68,5 @@ const campaignSchema = new mongoose.Schema( ); campaignSchema.index({ event: 1 }); +campaignSchema.index({ status: 1, scheduledFor: 1 }); module.exports = mongoose.model('MessagingCampaign', campaignSchema); diff --git a/src/models/Organization.js b/src/models/Organization.js index f57f3ef..83a8cfb 100644 --- a/src/models/Organization.js +++ b/src/models/Organization.js @@ -13,7 +13,8 @@ const organizationSchema = new mongoose.Schema( dateFormat: { type: String, default: 'DD/MM/YYYY' }, language: { type: String, default: 'en-IN' }, subscription: { - plan: { type: String, enum: ['free', 'pro', 'enterprise'], default: 'free' }, + /** Matches `SubscriptionPlan.planId` (slug). */ + plan: { type: String, default: 'free', trim: true, maxlength: 64 }, status: { type: String, enum: ['active', 'trial', 'past_due', 'cancelled'], @@ -24,9 +25,48 @@ const organizationSchema = new mongoose.Schema( maxParticipants: Number, featureOverrides: [{ type: String }], }, + /** Bring-your-own SMTP (password stored encrypted — see smtpCrypto). */ + smtp: { + enabled: { type: Boolean, default: false }, + host: String, + port: Number, + secure: { type: Boolean, default: false }, + user: String, + passwordEnc: String, + fromName: String, + fromAddress: String, + }, + /** WhatsApp Cloud API (Meta) — token encrypted like SMTP. */ + whatsappCloud: { + enabled: { type: Boolean, default: false }, + wabaId: { type: String, trim: true }, + phoneNumberId: { type: String, trim: true }, + accessTokenEnc: { type: String }, + }, }, { timestamps: true } ); +organizationSchema.set('toJSON', { + transform(_doc, ret) { + if (ret.whatsappCloud && ret.whatsappCloud.accessTokenEnc) { + ret.whatsappCloud = { + enabled: Boolean(ret.whatsappCloud.enabled), + wabaId: ret.whatsappCloud.wabaId || '', + phoneNumberId: ret.whatsappCloud.phoneNumberId || '', + hasAccessToken: true, + }; + } else if (ret.whatsappCloud) { + ret.whatsappCloud = { + enabled: Boolean(ret.whatsappCloud.enabled), + wabaId: ret.whatsappCloud.wabaId || '', + phoneNumberId: ret.whatsappCloud.phoneNumberId || '', + hasAccessToken: false, + }; + } + return ret; + }, +}); + organizationSchema.index({ name: 1 }); module.exports = mongoose.model('Organization', organizationSchema); diff --git a/src/models/PaymentTransaction.js b/src/models/PaymentTransaction.js index 856cd51..6c50b0a 100644 --- a/src/models/PaymentTransaction.js +++ b/src/models/PaymentTransaction.js @@ -43,6 +43,14 @@ const paymentTransactionSchema = new mongoose.Schema( /** Razorpay key_id used when order was created — must match on verify after resolve */ ledgerKeyId: { type: String, default: '' }, registrationContext: { type: mongoose.Schema.Types.Mixed, default: null }, + /** `ticketing_manual_enablement` = one-time fee to unlock per-event manual UPI (billing). */ + purpose: { + type: String, + enum: ['registration', 'saas_subscription', 'ticket_purchase', 'ticketing_manual_enablement'], + default: 'registration', + }, + subscriptionPlanId: { type: String, default: '' }, + purchasedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', default: null }, }, { timestamps: true } ); @@ -53,5 +61,6 @@ paymentTransactionSchema.index({ event: 1 }); paymentTransactionSchema.index({ participant: 1 }); paymentTransactionSchema.index({ status: 1 }); paymentTransactionSchema.index({ externalPaymentId: 1 }); +paymentTransactionSchema.index({ organization: 1, purpose: 1, createdAt: -1 }); module.exports = mongoose.model('PaymentTransaction', paymentTransactionSchema); diff --git a/src/models/Session.js b/src/models/Session.js index 7d3bf0e..2e0f923 100644 --- a/src/models/Session.js +++ b/src/models/Session.js @@ -42,6 +42,10 @@ const sessionSchema = new mongoose.Schema( audienceFeedbackEnabled: { type: Boolean, default: true }, /** When true (default), session page can surface ratings if the event has ratings enabled */ audienceRatingsEnabled: { type: Boolean, default: true }, + /** Dashboard: show Questions moderation (activity sessions default from activitySubtype). */ + enableQnA: { type: Boolean, default: true }, + /** Dashboard: show Quiz & Poll management (activity sessions default from activitySubtype). */ + enableQnP: { type: Boolean, default: true }, /** Optional Form (type feedback) for custom feedback; null = default EventBuzz feedback UI */ audienceFeedbackForm: { type: mongoose.Schema.Types.ObjectId, ref: 'Form' }, /** Optional Form (type feedback) for custom ratings flow; null = default EventBuzz ratings UI */ diff --git a/src/models/SubscriptionPlan.js b/src/models/SubscriptionPlan.js new file mode 100644 index 0000000..0456238 --- /dev/null +++ b/src/models/SubscriptionPlan.js @@ -0,0 +1,38 @@ +const mongoose = require('mongoose'); + +/** Lowercase slug: a-z start, then a-z, 0-9, underscore. Max 64 chars. */ +const PLAN_ID_REGEX = /^[a-z][a-z0-9_]{0,62}$/; + +const subscriptionPlanSchema = new mongoose.Schema( + { + planId: { + type: String, + required: true, + unique: true, + trim: true, + maxlength: 64, + match: PLAN_ID_REGEX, + }, + name: { type: String, required: true }, + /** Pricing / positioning line(s) for catalog & billing UI (e.g. "₹0\\nforever · …"). */ + tagline: { type: String, default: '' }, + featureKeys: [{ type: String }], + defaultLimits: { + maxEvents: { type: mongoose.Schema.Types.Mixed, default: null }, + maxParticipants: { type: mongoose.Schema.Types.Mixed, default: null }, + }, + /** Structured price for future payment integration (amount = major units, e.g. INR rupees). */ + pricing: { + amount: { type: mongoose.Schema.Types.Mixed, default: null }, + currency: { type: String, default: 'INR' }, + interval: { type: String, default: 'month' }, + contactSales: { type: Boolean, default: false }, + }, + /** Non-boolean feature config (event website tier, email caps). Synced from CSV defaults in catalog. */ + featureValues: { type: mongoose.Schema.Types.Mixed, default: {} }, + }, + { timestamps: true } +); + +module.exports = mongoose.model('SubscriptionPlan', subscriptionPlanSchema); +module.exports.PLAN_ID_REGEX = PLAN_ID_REGEX; diff --git a/src/models/TicketManualOrder.js b/src/models/TicketManualOrder.js new file mode 100644 index 0000000..2b9bf99 --- /dev/null +++ b/src/models/TicketManualOrder.js @@ -0,0 +1,35 @@ +const mongoose = require('mongoose'); + +/** + * UPI / manual proof flow when org has no online gateway. Awaiting admin approval. + */ +const ticketManualOrderSchema = new mongoose.Schema( + { + event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event', required: true, index: true }, + organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization', required: true, index: true }, + ticket: { type: mongoose.Schema.Types.ObjectId, ref: 'EventTicket', required: true }, + status: { + type: String, + enum: ['pending_review', 'approved', 'rejected'], + default: 'pending_review', + index: true, + }, + purchaserEmail: { type: String, required: true, trim: true, lowercase: true }, + attendees: { type: [mongoose.Schema.Types.Mixed], default: [] }, + quote: { type: mongoose.Schema.Types.Mixed, default: null }, + currency: { type: String, default: 'INR', trim: true, uppercase: true }, + billingStateCode: { type: String, default: '' }, + screenshotUrl: { type: String, required: true, trim: true }, + buyerNote: { type: String, default: '', trim: true }, + reviewedAt: Date, + reviewedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, + rejectReason: { type: String, default: '' }, + /** Set after approval when fulfillment runs */ + fulfillmentParticipantIds: [{ type: String }], + }, + { timestamps: true }, +); + +ticketManualOrderSchema.index({ event: 1, status: 1, createdAt: -1 }); + +module.exports = mongoose.model('TicketManualOrder', ticketManualOrderSchema); diff --git a/src/models/User.js b/src/models/User.js index 047841c..366d13a 100644 --- a/src/models/User.js +++ b/src/models/User.js @@ -37,6 +37,12 @@ const userSchema = new mongoose.Schema( enum: ['super_admin', 'admin', 'moderator', 'viewer'], default: 'viewer', }, + /** EventBuzz platform staff only — not part of `role`. Access to `/api/platform-admin/*` and Super Admin UI when `"yes"`. */ + platform_admin: { + type: String, + enum: ['yes', 'no'], + default: 'no', + }, organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization' }, profileImage: String, phone: String, diff --git a/src/models/WebChatSession.js b/src/models/WebChatSession.js new file mode 100644 index 0000000..6c367a6 --- /dev/null +++ b/src/models/WebChatSession.js @@ -0,0 +1,32 @@ +const mongoose = require('mongoose'); + +const messageSchema = new mongoose.Schema( + { + role: { type: String, enum: ['user', 'assistant'], required: true }, + text: { type: String, required: true }, + ts: { type: Date, default: Date.now }, + modelId: { type: String, default: null }, + inputTokens: { type: Number, default: 0 }, + outputTokens: { type: Number, default: 0 }, + }, + { _id: false }, +); + +const webChatSessionSchema = new mongoose.Schema( + { + botId: { type: mongoose.Schema.Types.ObjectId, ref: 'ConciergeBot', required: true, index: true }, + eventId: { type: mongoose.Schema.Types.ObjectId, ref: 'Event', required: true, index: true }, + organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization', required: true, index: true }, + sessionId: { type: String, required: true, index: true }, + history: { type: [messageSchema], default: [] }, + lastSeenAt: { type: Date, default: Date.now, index: true }, + inputTokensTotal: { type: Number, default: 0 }, + outputTokensTotal: { type: Number, default: 0 }, + }, + { timestamps: true }, +); + +webChatSessionSchema.index({ botId: 1, sessionId: 1 }, { unique: true }); + +module.exports = mongoose.model('WebChatSession', webChatSessionSchema); + diff --git a/src/models/WhatsAppQueue.js b/src/models/WhatsAppQueue.js new file mode 100644 index 0000000..ece0ffd --- /dev/null +++ b/src/models/WhatsAppQueue.js @@ -0,0 +1,52 @@ +const mongoose = require('mongoose'); + +const whatsAppQueueSchema = new mongoose.Schema( + { + campaign: { + type: mongoose.Schema.Types.ObjectId, + ref: 'MessagingCampaign', + required: true, + index: true, + }, + event: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + required: true, + }, + organization: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Organization', + required: true, + }, + participant: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Participant', + }, + /** E.164-like digits, no + */ + to: { type: String, required: true, trim: true, index: true }, + /** + * WhatsApp Cloud API message id from send response (messages[0].id), e.g. wamid… + * Webhook status updates reference this id. + */ + whatsappMessageId: { type: String, trim: true, sparse: true, index: true }, + /** contacts[0].wa_id from send response (digits), optional helper for webhooks */ + recipientWaId: { type: String, trim: true }, + status: { + type: String, + enum: ['pending', 'processing', 'sent', 'failed', 'cancelled'], + default: 'pending', + index: true, + }, + processAfter: { type: Date, default: () => new Date(), index: true }, + attempts: { type: Number, default: 0 }, + lastError: String, + lockedAt: Date, + sentAt: Date, + }, + { timestamps: true } +); + +whatsAppQueueSchema.index({ status: 1, processAfter: 1, createdAt: 1 }); +whatsAppQueueSchema.index({ campaign: 1, status: 1 }); + +module.exports = mongoose.model('WhatsAppQueue', whatsAppQueueSchema); diff --git a/src/models/WhatsAppTemplate.js b/src/models/WhatsAppTemplate.js new file mode 100644 index 0000000..cd35ad7 --- /dev/null +++ b/src/models/WhatsAppTemplate.js @@ -0,0 +1,50 @@ +const mongoose = require('mongoose'); + +const whatsAppTemplateSchema = new mongoose.Schema( + { + organization: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Organization', + required: true, + index: true, + }, + /** Meta template name: lowercase letters, numbers, underscores */ + name: { type: String, required: true, trim: true }, + language: { type: String, required: true, trim: true }, + category: { + type: String, + required: true, + enum: ['MARKETING', 'UTILITY', 'AUTHENTICATION'], + }, + /** Full Meta `components` array as stored/submitted */ + components: { type: mongoose.Schema.Types.Mixed, required: true }, + metaTemplateId: { type: String, trim: true }, + /** Meta template status (PENDING, APPROVED, …) — stored as returned by Graph API */ + status: { type: String, default: 'PENDING', trim: true }, + rejectionReason: { type: String, default: '' }, + lastSyncedAt: Date, + lastError: { type: String, default: '' }, + /** + * Maps Meta positional vars {{1}}, {{2}}… to campaign merge keys (e.g. contact.firstName). + * Arrays align with parameter order per component: header (TEXT), body, url (button). + * Not sent to Meta; used when sending template messages later. + */ + variableBindings: { + header: { type: [String], default: [] }, + body: { type: [String], default: [] }, + url: { type: [String], default: [] }, + }, + createdBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + }, + }, + { timestamps: true } +); + +whatsAppTemplateSchema.index({ organization: 1, name: 1, language: 1 }, { unique: true }); +whatsAppTemplateSchema.index({ organization: 1, status: 1 }); +whatsAppTemplateSchema.index({ metaTemplateId: 1 }, { sparse: true }); + +module.exports = mongoose.model('WhatsAppTemplate', whatsAppTemplateSchema); diff --git a/src/routes/attendance.routes.js b/src/routes/attendance.routes.js new file mode 100644 index 0000000..cfd140f --- /dev/null +++ b/src/routes/attendance.routes.js @@ -0,0 +1,58 @@ +const Joi = require('joi'); +const controller = require('../controllers/attendance.controller'); + +const settingsSchema = Joi.object({ + allowDuplicateCheckIn: Joi.boolean(), + publicLinkEnabled: Joi.boolean(), +}); + +const createPayload = Joi.object({ + name: Joi.string().trim().required(), + description: Joi.string().allow('').optional(), + settings: settingsSchema.optional(), +}); + +const updatePayload = Joi.object({ + name: Joi.string().trim().optional(), + description: Joi.string().allow('').optional(), + status: Joi.string().valid('active', 'archived').optional(), + settings: settingsSchema.optional(), +}); + +const checkInPayload = Joi.object({ + participantId: Joi.string().required(), +}); + +module.exports = [ + { method: 'GET', path: '/api/events/{eventId}/attendances', options: { handler: controller.list } }, + { + method: 'POST', + path: '/api/events/{eventId}/attendances', + options: { validate: { payload: createPayload }, handler: controller.create }, + }, + { method: 'GET', path: '/api/events/{eventId}/attendances/{id}', options: { handler: controller.get } }, + { + method: 'PUT', + path: '/api/events/{eventId}/attendances/{id}', + options: { validate: { payload: updatePayload }, handler: controller.update }, + }, + { method: 'DELETE', path: '/api/events/{eventId}/attendances/{id}', options: { handler: controller.remove } }, + { + method: 'POST', + path: '/api/events/{eventId}/attendances/{id}/regenerate-token', + options: { handler: controller.regenerateToken }, + }, + { method: 'GET', path: '/api/events/{eventId}/attendances/{id}/check-ins', options: { handler: controller.listCheckIns } }, + { method: 'GET', path: '/api/events/{eventId}/attendances/{id}/search-participants', options: { handler: controller.searchParticipants } }, + { + method: 'GET', + path: '/api/events/{eventId}/attendances/{id}/participants/{participantId}', + options: { handler: controller.getParticipantPreview }, + }, + { + method: 'POST', + path: '/api/events/{eventId}/attendances/{id}/check-in', + options: { validate: { payload: checkInPayload }, handler: controller.checkIn }, + }, + { method: 'GET', path: '/api/events/{eventId}/attendances/{id}/export', options: { handler: controller.exportCsv } }, +]; diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js index 11608af..ac8cc95 100644 --- a/src/routes/auth.routes.js +++ b/src/routes/auth.routes.js @@ -107,7 +107,7 @@ module.exports = [ validate: { payload: Joi.object({ idToken: Joi.string().required(), - teamName: Joi.string().min(1).required(), + teamName: Joi.string().min(1).allow('', null).optional(), firstName: Joi.string().allow('', null), lastName: Joi.string().allow('', null), phone: Joi.string().allow('', null), diff --git a/src/routes/badgeBatch.routes.js b/src/routes/badgeBatch.routes.js new file mode 100644 index 0000000..46a067a --- /dev/null +++ b/src/routes/badgeBatch.routes.js @@ -0,0 +1,63 @@ +const Joi = require('joi'); +const controller = require('../controllers/badgeBatch.controller'); +const { requireFeaturePre } = require('../services/featureAccess'); + +const FIELD_IDS = ['name', 'title', 'org', 'badge_type', 'qr', 'photo']; + +const createPayload = Joi.object({ + name: Joi.string().required().trim(), + organization: Joi.string().hex().length(24), + event: Joi.string().hex().length(24).required(), + sizePreset: Joi.string().valid('a6', 'a7', '4x3in', '4x6in', 'custom').default('a6'), + widthMm: Joi.number().positive().when('sizePreset', { is: 'custom', then: Joi.required(), otherwise: Joi.optional() }), + heightMm: Joi.number().positive().when('sizePreset', { is: 'custom', then: Joi.required(), otherwise: Joi.optional() }), + orientation: Joi.string().valid('portrait', 'landscape').default('portrait'), + includeFaculty: Joi.boolean().default(false), + includeParticipants: Joi.boolean().default(false), + participantScope: Joi.string().valid('all', 'participant_list').default('all'), + participantListId: Joi.string().hex().length(24).allow(null, ''), + backgroundMode: Joi.string().valid('image', 'color').required(), + backgroundImageUrl: Joi.string().allow(''), + backgroundColor: Joi.string().pattern(/^#[0-9A-Fa-f]{6}$/).default('#0B4DD1'), + fields: Joi.array().items(Joi.string().valid(...FIELD_IDS)).min(1).required(), + outputFormat: Joi.string().valid('pdf', 'png', 'jpeg').required(), + badgeTypeLabel: Joi.string().allow('').max(80), +}); + +module.exports = [ + { + method: 'GET', + path: '/api/badge-batches', + options: { + validate: { + query: Joi.object({ + organization: Joi.string().hex().length(24), + event: Joi.string().hex().length(24), + page: Joi.number().integer().min(1), + limit: Joi.number().integer().min(1).max(100), + }), + }, + handler: controller.list, + }, + }, + { + method: 'GET', + path: '/api/badge-batches/{id}', + options: { + validate: { + params: Joi.object({ id: Joi.string().hex().length(24).required() }), + query: Joi.object({ organization: Joi.string().hex().length(24) }), + }, + handler: controller.get, + }, + }, + { + method: 'POST', + path: '/api/badge-batches', + options: { + pre: [{ method: requireFeaturePre('custom_badge_and_print_file_creation') }], + validate: { payload: createPayload }, + handler: controller.create, + }, + }, +]; diff --git a/src/routes/campaign.routes.js b/src/routes/campaign.routes.js index bf7da4b..9b4b606 100644 --- a/src/routes/campaign.routes.js +++ b/src/routes/campaign.routes.js @@ -2,31 +2,79 @@ const Joi = require('joi'); const controller = require('../controllers/campaign.controller'); const { CAMPAIGN_CHANNELS, CAMPAIGN_TYPES } = require('../config/constants'); +const recipientsSchema = Joi.object({ + type: Joi.string().valid('all', 'filtered', 'custom', 'participant_list'), + filters: Joi.object(), + customList: Joi.array().items(Joi.string()), + participantList: Joi.string(), +}); + +const waOverrideSlot = Joi.alternatives().try( + Joi.object({ kind: Joi.string().valid('merge').required(), key: Joi.string().allow('') }), + Joi.object({ kind: Joi.string().valid('fixed').required(), value: Joi.string().allow('') }), +); + +const contentSchema = Joi.object({ + subject: Joi.string().allow(''), + body: Joi.string().allow(''), + template: Joi.string().allow(''), + variables: Joi.object(), + /** WhatsApp campaigns: Meta template doc in DB */ + whatsappTemplateId: Joi.string().allow(''), + /** Public https URL for template IMAGE header (send time) */ + waHeaderImageUrl: Joi.string().allow(''), + /** Per-section {{n}} overrides: { header|body|url: { "1": { kind, key|value } } } */ + waOverrides: Joi.object({ + header: Joi.object().pattern(Joi.string(), waOverrideSlot), + body: Joi.object().pattern(Joi.string(), waOverrideSlot), + url: Joi.object().pattern(Joi.string(), waOverrideSlot), + }), +}); + const campaignPayload = Joi.object({ name: Joi.string().required(), channel: Joi.string().valid(...CAMPAIGN_CHANNELS).required(), type: Joi.string().valid(...CAMPAIGN_TYPES).required(), - triggerEvent: Joi.string(), - recipients: Joi.object({ - type: Joi.string().valid('all', 'filtered', 'custom'), - filters: Joi.object(), - customList: Joi.array().items(Joi.string()), - }), - content: Joi.object({ - subject: Joi.string(), - body: Joi.string(), - template: Joi.string(), - variables: Joi.object(), - }), + triggerEvent: Joi.string().allow(''), + recipients: recipientsSchema, + content: contentSchema, +}); + +const campaignUpdatePayload = Joi.object({ + name: Joi.string(), + channel: Joi.string().valid(...CAMPAIGN_CHANNELS), + type: Joi.string().valid(...CAMPAIGN_TYPES), + triggerEvent: Joi.string().allow(''), + recipients: recipientsSchema, + content: contentSchema, +}).min(1); + +/** Optional fields applied to a draft immediately before enqueue (avoids PUT+POST race). */ +const sendApplyDraftSchema = Joi.object({ + name: Joi.string(), + recipients: recipientsSchema, + content: contentSchema, }); module.exports = [ + { + method: 'GET', + path: '/api/campaigns', + options: { + validate: { query: Joi.object({ channel: Joi.string().valid(...CAMPAIGN_CHANNELS) }) }, + handler: controller.listByOrg, + }, + }, { method: 'GET', path: '/api/events/{eventId}/campaigns', options: { handler: controller.list } }, { method: 'POST', path: '/api/events/{eventId}/campaigns', options: { validate: { payload: campaignPayload }, handler: controller.create } }, { method: 'GET', path: '/api/campaigns/{id}', options: { handler: controller.get } }, - { method: 'PUT', path: '/api/campaigns/{id}', options: { validate: { payload: campaignPayload }, handler: controller.update } }, + { method: 'PUT', path: '/api/campaigns/{id}', options: { validate: { payload: campaignUpdatePayload }, handler: controller.update } }, { method: 'DELETE', path: '/api/campaigns/{id}', options: { handler: controller.remove } }, - { method: 'POST', path: '/api/campaigns/{id}/send', options: { handler: controller.send } }, + { + method: 'POST', + path: '/api/campaigns/{id}/send', + options: { validate: { payload: sendApplyDraftSchema }, handler: controller.send }, + }, { method: 'POST', path: '/api/campaigns/{id}/schedule', @@ -36,5 +84,7 @@ module.exports = [ }, }, { method: 'POST', path: '/api/campaigns/{id}/cancel', options: { handler: controller.cancel } }, + { method: 'POST', path: '/api/campaigns/{id}/stop', options: { handler: controller.stop } }, + { method: 'POST', path: '/api/campaigns/{id}/preview', options: { handler: controller.preview } }, { method: 'GET', path: '/api/campaigns/{id}/analytics', options: { handler: controller.analytics } }, ]; diff --git a/src/routes/checkins.routes.js b/src/routes/checkins.routes.js new file mode 100644 index 0000000..e2805e8 --- /dev/null +++ b/src/routes/checkins.routes.js @@ -0,0 +1,5 @@ +const controller = require('../controllers/checkins.controller'); + +module.exports = [ + { method: 'GET', path: '/api/checkins/events', options: { handler: controller.listEvents } }, +]; diff --git a/src/routes/conciergeBot.routes.js b/src/routes/conciergeBot.routes.js new file mode 100644 index 0000000..2e7484a --- /dev/null +++ b/src/routes/conciergeBot.routes.js @@ -0,0 +1,68 @@ +const Joi = require('joi'); +const controller = require('../controllers/conciergeBot.controller'); + +const greetingSchema = Joi.object({ + enabled: Joi.boolean(), + message: Joi.string().allow(''), +}); + +const registrationSchema = Joi.object({ + promptIfUnregistered: Joi.boolean(), + allowGuestSkip: Joi.boolean(), +}); + +const knowledgeSchema = Joi.object({ + includeEventDetails: Joi.boolean(), +}); + +const createPayload = Joi.object({ + eventId: Joi.string().required(), + name: Joi.string().allow(''), + wabaPhoneNumberId: Joi.string().required(), + greeting: greetingSchema, + registration: registrationSchema, + knowledge: knowledgeSchema, + enabled: Joi.boolean(), +}); + +const updatePayload = Joi.object({ + name: Joi.string().allow(''), + wabaPhoneNumberId: Joi.string(), + greeting: greetingSchema, + registration: registrationSchema, + knowledge: knowledgeSchema, + enabled: Joi.boolean(), +}).min(1); + +const sourcePayload = Joi.object({ + type: Joi.string().valid('url', 'pdf').required(), + sourcePath: Joi.string().required(), + displayName: Joi.string().required(), + crawlScope: Joi.string().valid('auto', 'domain', 'path'), +}); + +module.exports = [ + { + method: 'GET', + path: '/api/concierge-bots', + options: { + validate: { query: Joi.object({ organization: Joi.string() }) }, + handler: controller.list, + }, + }, + { method: 'POST', path: '/api/concierge-bots', options: { validate: { payload: createPayload }, handler: controller.create } }, + { method: 'GET', path: '/api/concierge-bots/{botId}', options: { handler: controller.get } }, + { method: 'PUT', path: '/api/concierge-bots/{botId}', options: { validate: { payload: updatePayload }, handler: controller.update } }, + { method: 'DELETE', path: '/api/concierge-bots/{botId}', options: { handler: controller.remove } }, + { method: 'GET', path: '/api/events/{eventId}/concierge-bot', options: { handler: controller.getByEvent } }, + { method: 'GET', path: '/api/concierge-bots/{botId}/sources', options: { handler: controller.listSources } }, + { method: 'POST', path: '/api/concierge-bots/{botId}/sources', options: { validate: { payload: sourcePayload }, handler: controller.createSource } }, + { method: 'DELETE', path: '/api/concierge-bots/{botId}/sources/{sourceId}', options: { handler: controller.deleteSource } }, + { method: 'POST', path: '/api/concierge-bots/{botId}/sources/{sourceId}/sync', options: { handler: controller.syncSource } }, + { method: 'GET', path: '/api/concierge-bots/{botId}/sources/{sourceId}/chunks', options: { validate: { query: Joi.object({ staging: Joi.string() }) }, handler: controller.listChunks } }, + { method: 'PATCH', path: '/api/concierge-bots/{botId}/chunks/{chunkId}', options: { validate: { payload: Joi.object({ rawText: Joi.string().required() }) }, handler: controller.patchChunk } }, + { method: 'DELETE', path: '/api/concierge-bots/{botId}/chunks/{chunkId}', options: { handler: controller.deleteChunk } }, + { method: 'POST', path: '/api/concierge-bots/{botId}/sources/{sourceId}/approve', options: { handler: controller.approveSource } }, + { method: 'POST', path: '/api/concierge-bots/{botId}/knowledge/clear', options: { handler: controller.clearKnowledge } }, + { method: 'POST', path: '/api/concierge-bots/{botId}/knowledge/sync-event', options: { handler: controller.syncEventKnowledge } }, +]; diff --git a/src/routes/designTemplate.routes.js b/src/routes/designTemplate.routes.js index 86e0c65..1c02063 100644 --- a/src/routes/designTemplate.routes.js +++ b/src/routes/designTemplate.routes.js @@ -14,6 +14,7 @@ const templatePayload = Joi.object({ type: Joi.string().valid(...DESIGN_TEMPLATE_TYPES).required(), category: Joi.string(), thumbnail: Joi.string(), + associatedEvent: Joi.string().hex().length(24).allow(null).optional(), content: Joi.any(), variables: Joi.array().items(variableSchema), organization: Joi.string(), diff --git a/src/routes/event.routes.js b/src/routes/event.routes.js index 624f5cb..7bf741b 100644 --- a/src/routes/event.routes.js +++ b/src/routes/event.routes.js @@ -40,6 +40,12 @@ const eventPayload = Joi.object({ subtitle: Joi.string().allow('', null), description: Joi.string().allow('', null), type: Joi.string().valid(...EVENT_TYPES).required(), + recordKind: Joi.string().valid('event', 'activity'), + activitySubtype: Joi.when('recordKind', { + is: 'activity', + then: Joi.string().valid('qna', 'quiz', 'poll').required(), + otherwise: Joi.string().valid('qna', 'quiz', 'poll').allow(null, ''), + }), status: Joi.string().valid(...EVENT_STATUS), category: Joi.string().allow('', null), startDate: Joi.date().iso().required(), @@ -72,6 +78,8 @@ const eventPayload = Joi.object({ registrationCustomFields: Joi.array().items(registrationFieldSchema), }).unknown(true), rewardRules: rewardRulesPayload, + /** When true on POST /events (non-activity), server creates default track + opening session (AI-style). */ + bootstrapAgenda: Joi.boolean().default(false), }); const generateFromAiPayload = Joi.object({ @@ -79,13 +87,35 @@ const generateFromAiPayload = Joi.object({ organization: Joi.string().allow('', null), }); +const aiExtractPreviewPayload = generateFromAiPayload; + +const listQuerySchema = Joi.object({ + organization: Joi.string().allow('', null), + status: Joi.string().allow('', null), + type: Joi.string().allow('', null), + page: Joi.alternatives().try(Joi.number().integer().min(1), Joi.string().pattern(/^\d+$/)), + limit: Joi.alternatives().try(Joi.number().integer().min(1).max(200), Joi.string().pattern(/^\d+$/)), + recordKind: Joi.string().valid('event', 'activity', ''), + activitySubtype: Joi.string().valid('qna', 'quiz', 'poll', ''), + enrich: Joi.alternatives().try(Joi.boolean(), Joi.string().valid('0', '1', 'true', 'false', '')), +}).unknown(true); + module.exports = [ - { method: 'GET', path: '/api/events', options: { handler: controller.list } }, + { + method: 'GET', + path: '/api/events', + options: { validate: { query: listQuerySchema }, handler: controller.list }, + }, { method: 'POST', path: '/api/events/generate-from-ai', options: { validate: { payload: generateFromAiPayload }, handler: controller.generateFromAi }, }, + { + method: 'POST', + path: '/api/events/ai-extract-preview', + options: { validate: { payload: aiExtractPreviewPayload }, handler: controller.aiExtractPreview }, + }, { method: 'POST', path: '/api/events', options: { validate: { payload: eventPayload }, handler: controller.create } }, { method: 'GET', path: '/api/events/{id}', options: { handler: controller.get } }, { method: 'PUT', path: '/api/events/{id}', options: { validate: { payload: eventPayload }, handler: controller.update } }, @@ -102,5 +132,13 @@ module.exports = [ handler: controller.registrationLink, }, }, + { + method: 'GET', + path: '/api/events/{id}/emailer-link-targets', + options: { + validate: { params: Joi.object({ id: Joi.string().hex().length(24).required() }) }, + handler: controller.emailerLinkTargets, + }, + }, { method: 'POST', path: '/api/events/{id}/duplicate', options: { handler: controller.duplicate } }, ]; diff --git a/src/routes/eventTicket.routes.js b/src/routes/eventTicket.routes.js new file mode 100644 index 0000000..0ec9af7 --- /dev/null +++ b/src/routes/eventTicket.routes.js @@ -0,0 +1,91 @@ +const Joi = require('joi'); +const controller = require('../controllers/eventTicket.controller'); +const { requireTicketingManagePre } = require('../services/featureAccess'); + +const taxItem = Joi.object({ + label: Joi.string().min(1).max(120).required(), + rateBps: Joi.number().integer().min(0).max(100000).required(), + appliesToStates: Joi.array().items(Joi.string().max(10)).default([]), +}); + +const createPayload = Joi.object({ + name: Joi.string().min(1).max(200).required(), + description: Joi.string().allow('', null), + keyPoints: Joi.array().items(Joi.string().max(500)).default([]), + basePriceMinor: Joi.number().integer().min(0).required(), + currency: Joi.string().length(3).uppercase(), + isActive: Joi.boolean(), + sortOrder: Joi.number().integer(), + maxPerOrder: Joi.number().integer().min(1).allow(null), + inventoryCap: Joi.number().integer().min(1).allow(null), + taxItems: Joi.array().items(taxItem).default([]), + registrationForm: Joi.string().hex().length(24).allow(null, ''), + participantList: Joi.string().hex().length(24).required(), +}); + +const patchPayload = Joi.object({ + name: Joi.string().min(1).max(200), + description: Joi.string().allow('', null), + keyPoints: Joi.array().items(Joi.string().max(500)), + basePriceMinor: Joi.number().integer().min(0), + currency: Joi.string().length(3).uppercase(), + isActive: Joi.boolean(), + sortOrder: Joi.number().integer(), + maxPerOrder: Joi.number().integer().min(1).allow(null), + inventoryCap: Joi.number().integer().min(1).allow(null), + taxItems: Joi.array().items(taxItem), + registrationForm: Joi.string().hex().length(24).allow(null, ''), + participantList: Joi.string().hex().length(24), +}).min(1); + +module.exports = [ + { + method: 'GET', + path: '/api/events/{eventId}/tickets', + options: { + validate: { params: Joi.object({ eventId: Joi.string().hex().length(24).required() }) }, + handler: controller.list, + }, + }, + { + method: 'POST', + path: '/api/events/{eventId}/tickets', + options: { + pre: [{ method: requireTicketingManagePre() }], + validate: { + params: Joi.object({ eventId: Joi.string().hex().length(24).required() }), + payload: createPayload, + }, + handler: controller.create, + }, + }, + { + method: 'PATCH', + path: '/api/events/{eventId}/tickets/{id}', + options: { + pre: [{ method: requireTicketingManagePre() }], + validate: { + params: Joi.object({ + eventId: Joi.string().hex().length(24).required(), + id: Joi.string().hex().length(24).required(), + }), + payload: patchPayload, + }, + handler: controller.update, + }, + }, + { + method: 'DELETE', + path: '/api/events/{eventId}/tickets/{id}', + options: { + pre: [{ method: requireTicketingManagePre() }], + validate: { + params: Joi.object({ + eventId: Joi.string().hex().length(24).required(), + id: Joi.string().hex().length(24).required(), + }), + }, + handler: controller.remove, + }, + }, +]; diff --git a/src/routes/eventTicketingManual.routes.js b/src/routes/eventTicketingManual.routes.js new file mode 100644 index 0000000..69a5117 --- /dev/null +++ b/src/routes/eventTicketingManual.routes.js @@ -0,0 +1,47 @@ +const Joi = require('joi'); +const controller = require('../controllers/eventTicketingManual.controller'); +const { requireTicketingManagePre } = require('../services/featureAccess'); +const { assertOrgBillingManager } = require('../utils/assertOrgBillingManager'); + +const patchPayload = Joi.object({ + enabled: Joi.boolean(), + manualQrMode: Joi.string().valid('upload', 'dynamic'), + upiQrImageUrl: Joi.string().allow('', null), + upiId: Joi.string().max(200).allow('', null), + payeeName: Joi.string().max(200).allow('', null), + instructions: Joi.string().max(5000).allow('', null), + platformFeeInr: Joi.number().min(0).allow(null), +}).min(1); + +module.exports = [ + { + method: 'GET', + path: '/api/events/{eventId}/ticketing-manual-payment', + options: { + pre: [{ method: requireTicketingManagePre() }], + validate: { params: Joi.object({ eventId: Joi.string().hex().length(24).required() }) }, + handler: controller.get, + }, + }, + { + method: 'PATCH', + path: '/api/events/{eventId}/ticketing-manual-payment', + options: { + pre: [{ method: requireTicketingManagePre() }], + validate: { + params: Joi.object({ eventId: Joi.string().hex().length(24).required() }), + payload: patchPayload, + }, + handler: controller.patch, + }, + }, + { + method: 'POST', + path: '/api/events/{eventId}/ticketing-manual-enablement/checkout-order', + options: { + pre: [{ method: requireTicketingManagePre() }, { method: assertOrgBillingManager }], + validate: { params: Joi.object({ eventId: Joi.string().hex().length(24).required() }) }, + handler: controller.checkoutEnablementOrder, + }, + }, +]; diff --git a/src/routes/form.routes.js b/src/routes/form.routes.js index 6ac8ae5..bf53102 100644 --- a/src/routes/form.routes.js +++ b/src/routes/form.routes.js @@ -2,6 +2,7 @@ const Joi = require('joi'); const controller = require('../controllers/form.controller'); const { FORM_TYPES, FIELD_TYPES, FORM_FIELD_MAPS_TO } = require('../config/constants'); +/** stripUnknown: Mongo subdocs include _id; duplicates must not fail validation. */ const fieldSchema = Joi.object({ label: Joi.string(), type: Joi.string().valid(...FIELD_TYPES), @@ -11,7 +12,7 @@ const fieldSchema = Joi.object({ mapsTo: Joi.string().valid(...FORM_FIELD_MAPS_TO).allow('', null), customMapsKey: Joi.string().allow('', null), isFixed: Joi.boolean(), -}); +}).options({ stripUnknown: true }); const settingsSchema = Joi.object({ isActive: Joi.boolean(), @@ -27,7 +28,7 @@ const settingsSchema = Joi.object({ sendConfirmationEmail: Joi.boolean(), onSubmitAddTags: Joi.array().items(Joi.string()), onSubmitParticipantListId: Joi.string().allow(null, ''), -}); +}).options({ stripUnknown: true }); const formPayload = Joi.object({ name: Joi.string().required(), @@ -35,6 +36,10 @@ const formPayload = Joi.object({ description: Joi.string().allow('', null), fields: Joi.array().items(fieldSchema), settings: settingsSchema, +}).options({ stripUnknown: true }); + +const formUpdatePayload = formPayload.keys({ + event: Joi.string().hex().length(24).required(), }); /** tenantOrganization: super_admin may set explicitly; others get it from JWT in the controller. */ @@ -44,13 +49,13 @@ const formOrgCreatePayload = Joi.object({ description: Joi.string().allow('', null), fields: Joi.array().items(fieldSchema), settings: settingsSchema, - event: Joi.string().allow(null, ''), + event: Joi.string().hex().length(24).required(), tenantOrganization: Joi.alternatives().try( Joi.string().hex().length(24), Joi.string().allow(null, ''), Joi.valid(null) ), -}); +}).options({ stripUnknown: true }); const submitPayload = Joi.object({ participantId: Joi.string(), @@ -67,12 +72,17 @@ module.exports = [ { method: 'GET', path: '/api/events/{eventId}/forms', options: { handler: controller.list } }, { method: 'POST', path: '/api/events/{eventId}/forms', options: { validate: { payload: formPayload }, handler: controller.create } }, { method: 'GET', path: '/api/forms/{id}', options: { handler: controller.get } }, - { method: 'PUT', path: '/api/forms/{id}', options: { validate: { payload: formPayload }, handler: controller.update } }, + { method: 'PUT', path: '/api/forms/{id}', options: { validate: { payload: formUpdatePayload }, handler: controller.update } }, { method: 'DELETE', path: '/api/forms/{id}', options: { handler: controller.remove } }, { method: 'POST', path: '/api/forms/{formId}/submit', options: { auth: false, validate: { payload: submitPayload }, handler: controller.submit }, }, + { + method: 'GET', + path: '/api/public/forms/{formId}', + options: { auth: false, handler: controller.getPublicForm }, + }, { method: 'GET', path: '/api/forms/{formId}/submissions', options: { handler: controller.listSubmissions } }, ]; diff --git a/src/routes/organization.routes.js b/src/routes/organization.routes.js index 9faec58..79cf94d 100644 --- a/src/routes/organization.routes.js +++ b/src/routes/organization.routes.js @@ -14,9 +14,7 @@ const orgPayload = Joi.object({ dateFormat: Joi.string(), language: Joi.string(), subscription: Joi.object({ - plan: Joi.string().valid('free', 'pro', 'enterprise'), status: Joi.string().valid('active', 'trial', 'past_due', 'cancelled'), - expiresAt: Joi.date().allow(null), maxEvents: Joi.number().allow(null), maxParticipants: Joi.number().allow(null), featureOverrides: Joi.array().items(Joi.string()), @@ -33,9 +31,44 @@ const userPayload = Joi.object({ assignedEvents: Joi.array().items(Joi.string()).optional(), }); +const emailSettingsPayload = Joi.object({ + enabled: Joi.boolean(), + host: Joi.string().allow(''), + port: Joi.number().integer().min(1).max(65535), + secure: Joi.boolean(), + user: Joi.string().allow(''), + password: Joi.string().allow(''), + fromName: Joi.string().allow(''), + fromAddress: Joi.string().allow(''), +}).min(1); + +const whatsAppSettingsPayload = Joi.object({ + enabled: Joi.boolean(), + wabaId: Joi.string().allow(''), + phoneNumberId: Joi.string().allow(''), + accessToken: Joi.string().allow(''), +}).min(1); + module.exports = [ { method: 'GET', path: '/api/organization', options: { handler: controller.getOrganization } }, { method: 'PUT', path: '/api/organization', options: { validate: { payload: orgPayload }, handler: controller.updateOrganization } }, + { method: 'GET', path: '/api/organization/email-settings', options: { handler: controller.getEmailSettings } }, + { + method: 'PUT', + path: '/api/organization/email-settings', + options: { validate: { payload: emailSettingsPayload }, handler: controller.updateEmailSettings }, + }, + { + method: 'POST', + path: '/api/organization/email-settings/test', + options: { validate: { payload: Joi.object({ to: Joi.string().email().required() }) }, handler: controller.testEmailSettings }, + }, + { method: 'GET', path: '/api/organization/whatsapp-settings', options: { handler: controller.getWhatsAppSettings } }, + { + method: 'PUT', + path: '/api/organization/whatsapp-settings', + options: { validate: { payload: whatsAppSettingsPayload }, handler: controller.updateWhatsAppSettings }, + }, { method: 'GET', path: '/api/users', options: { handler: controller.listUsers } }, { method: 'POST', path: '/api/users', options: { validate: { payload: userPayload }, handler: controller.addUser } }, { method: 'PUT', path: '/api/users/{id}', options: { validate: { payload: userPayload }, handler: controller.updateUser } }, diff --git a/src/routes/platformAdmin.routes.js b/src/routes/platformAdmin.routes.js new file mode 100644 index 0000000..76e87f3 --- /dev/null +++ b/src/routes/platformAdmin.routes.js @@ -0,0 +1,198 @@ +const Joi = require('joi'); +const controller = require('../controllers/platformAdmin.controller'); +const { assertPlatformAdmin } = require('../utils/assertPlatformAdmin'); + +const platformPre = [{ method: assertPlatformAdmin }]; + +const PLAN_ID_PARAM = Joi.string() + .pattern(/^[a-z][a-z0-9_]{0,62}$/) + .required(); + +const subscriptionPatch = Joi.object({ + plan: Joi.string().min(1).max(64), + status: Joi.string().valid('active', 'trial', 'past_due', 'cancelled'), + expiresAt: Joi.alternatives(Joi.date().iso(), Joi.valid(null)), + maxEvents: Joi.alternatives(Joi.number(), Joi.valid(null)), + maxParticipants: Joi.alternatives(Joi.number(), Joi.valid(null)), + featureOverrides: Joi.array().items(Joi.string()), +}); + +const planPricing = Joi.object({ + amount: Joi.alternatives(Joi.number().min(0), Joi.valid(null)), + currency: Joi.string().trim().max(10), + interval: Joi.string().trim().max(32), + contactSales: Joi.boolean(), +}); + +const planFeatureValues = Joi.object({ + event_website: Joi.string().valid( + 'event_page', + 'event_microsite', + 'custom_domain_microsite', + 'custom_website', + ), + email_outreach_campaigns: Joi.object({ + maxEmails: Joi.alternatives(Joi.number().integer().min(0), Joi.valid(null)), + }), +}).unknown(true); + +const planPut = Joi.object({ + name: Joi.string(), + tagline: Joi.string().allow('').max(800), + featureKeys: Joi.array().items(Joi.string()), + defaultLimits: Joi.object({ + maxEvents: Joi.alternatives(Joi.number(), Joi.valid(null)), + maxParticipants: Joi.alternatives(Joi.number(), Joi.valid(null)), + }), + pricing: planPricing, + featureValues: planFeatureValues, +}).min(1); + +const planPost = Joi.object({ + planId: Joi.string() + .pattern(/^[a-z][a-z0-9_]{0,62}$/) + .required(), + name: Joi.string().trim().min(1).max(200).required(), + tagline: Joi.string().allow('').max(800), + featureKeys: Joi.array().items(Joi.string()).default([]), + defaultLimits: Joi.object({ + maxEvents: Joi.alternatives(Joi.number(), Joi.valid(null)), + maxParticipants: Joi.alternatives(Joi.number(), Joi.valid(null)), + }), + pricing: planPricing, + featureValues: planFeatureValues, +}); + +const createOrganizationPayload = Joi.object({ + organization: Joi.object({ + name: Joi.string().trim().min(1).max(200).required(), + description: Joi.string().allow('', null), + email: Joi.alternatives(Joi.string().trim().email({ tlds: { allow: false } }), Joi.valid('', null)).optional(), + phone: Joi.string().allow('', null), + website: Joi.string().allow('', null), + timezone: Joi.string().allow('', null), + subscription: Joi.object({ + plan: Joi.string().min(1).max(64), + status: Joi.string().valid('active', 'trial', 'past_due', 'cancelled'), + }), + }).required(), + admin: Joi.object({ + email: Joi.string().email().required(), + password: Joi.string().min(8).max(128).required(), + firstName: Joi.string().trim().max(100).allow('', null), + lastName: Joi.string().trim().max(100).allow('', null), + }).required(), +}); + +module.exports = [ + { + method: 'POST', + path: '/api/platform-admin/organizations', + options: { + auth: 'jwt', + pre: platformPre, + validate: { + payload: createOrganizationPayload, + }, + handler: controller.createOrganization, + }, + }, + { + method: 'GET', + path: '/api/platform-admin/organizations', + options: { + auth: 'jwt', + pre: platformPre, + validate: { + query: Joi.object({ + page: Joi.number().integer().min(1), + limit: Joi.number().integer().min(1).max(100), + search: Joi.string().allow(''), + }).unknown(true), + }, + handler: controller.listOrganizations, + }, + }, + { + method: 'GET', + path: '/api/platform-admin/organizations/{id}', + options: { + auth: 'jwt', + pre: platformPre, + validate: { params: Joi.object({ id: Joi.string().required() }) }, + handler: controller.getOrganization, + }, + }, + { + method: 'PATCH', + path: '/api/platform-admin/organizations/{id}/subscription', + options: { + auth: 'jwt', + pre: platformPre, + validate: { + params: Joi.object({ id: Joi.string().required() }), + payload: subscriptionPatch, + }, + handler: controller.patchOrganizationSubscription, + }, + }, + { + method: 'GET', + path: '/api/platform-admin/permissions', + options: { + auth: 'jwt', + pre: platformPre, + handler: controller.listPermissions, + }, + }, + { + method: 'GET', + path: '/api/platform-admin/plans', + options: { + auth: 'jwt', + pre: platformPre, + handler: controller.listPlansAdmin, + }, + }, + { + method: 'POST', + path: '/api/platform-admin/plans', + options: { + auth: 'jwt', + pre: platformPre, + validate: { + payload: planPost, + }, + handler: controller.createPlan, + }, + }, + { + method: 'PUT', + path: '/api/platform-admin/plans/{planId}', + options: { + auth: 'jwt', + pre: platformPre, + validate: { + params: Joi.object({ + planId: PLAN_ID_PARAM, + }), + payload: planPut, + }, + handler: controller.putPlan, + }, + }, + { + method: 'DELETE', + path: '/api/platform-admin/plans/{planId}', + options: { + auth: 'jwt', + pre: platformPre, + validate: { + params: Joi.object({ + planId: PLAN_ID_PARAM, + }), + }, + handler: controller.deletePlan, + }, + }, +]; diff --git a/src/routes/publicAttendance.routes.js b/src/routes/publicAttendance.routes.js new file mode 100644 index 0000000..9830d88 --- /dev/null +++ b/src/routes/publicAttendance.routes.js @@ -0,0 +1,24 @@ +const Joi = require('joi'); +const controller = require('../controllers/publicAttendance.controller'); + +const checkInPayload = Joi.object({ + participantId: Joi.string().required(), +}); + +module.exports = [ + { + method: 'GET', + path: '/api/public/attendance/{token}', + options: { auth: false, handler: controller.getSession }, + }, + { + method: 'GET', + path: '/api/public/attendance/{token}/search', + options: { auth: false, handler: controller.search }, + }, + { + method: 'POST', + path: '/api/public/attendance/{token}/check-in', + options: { auth: false, validate: { payload: checkInPayload }, handler: controller.checkIn }, + }, +]; diff --git a/src/routes/publicConcierge.routes.js b/src/routes/publicConcierge.routes.js new file mode 100644 index 0000000..db51c0e --- /dev/null +++ b/src/routes/publicConcierge.routes.js @@ -0,0 +1,29 @@ +const Joi = require('joi'); +const controller = require('../controllers/publicConcierge.controller'); + +const chatPayload = Joi.object({ + sessionId: Joi.string().max(80).allow('', null), + message: Joi.string().min(1).max(2000).required(), +}); + +module.exports = [ + { + method: 'GET', + path: '/api/public/concierge/{botId}/config', + options: { + auth: false, + validate: { params: Joi.object({ botId: Joi.string().required() }) }, + handler: controller.config, + }, + }, + { + method: 'POST', + path: '/api/public/concierge/{botId}/chat', + options: { + auth: false, + validate: { params: Joi.object({ botId: Joi.string().required() }), payload: chatPayload }, + handler: controller.chat, + }, + }, +]; + diff --git a/src/routes/publicEventTicket.routes.js b/src/routes/publicEventTicket.routes.js new file mode 100644 index 0000000..8825742 --- /dev/null +++ b/src/routes/publicEventTicket.routes.js @@ -0,0 +1,112 @@ +const Joi = require('joi'); +const controller = require('../controllers/publicEventTicket.controller'); + +const attendeeSchema = Joi.object({ + firstName: Joi.string().min(1).max(100).required(), + lastName: Joi.string().min(1).max(100).required(), + email: Joi.string().email().required(), + phone: Joi.string().allow('', null), + organization: Joi.string().allow('', null), + designation: Joi.string().allow('', null), + displayName: Joi.string().allow('', null), + formData: Joi.object().unknown(true), +}); + +const quotePayload = Joi.object({ + ticketId: Joi.string().hex().length(24).required(), + quantity: Joi.number().integer().min(1).max(100).required(), + billingStateCode: Joi.string().max(10).allow('', null), +}); + +const checkoutPayload = Joi.object({ + ticketId: Joi.string().hex().length(24).required(), + quantity: Joi.number().integer().min(1).max(100).required(), + billingStateCode: Joi.string().max(10).allow('', null), + purchaserEmail: Joi.string().email().required(), + attendees: Joi.array().items(attendeeSchema).min(1).required(), + expectedTotalMinor: Joi.number().integer().min(1).required(), + idempotencyKey: Joi.string().max(200).allow('', null), +}); + +const manualSubmitPayload = checkoutPayload.keys({ + screenshotDataUrl: Joi.string().min(100).max(8_000_000).required(), + buyerNote: Joi.string().max(2000).allow('', null), +}); + +module.exports = [ + { + method: 'GET', + path: '/api/public/events/{eventId}/summary', + options: { + auth: false, + validate: { params: Joi.object({ eventId: Joi.string().hex().length(24).required() }) }, + handler: controller.eventSummary, + }, + }, + { + method: 'GET', + path: '/api/public/events/{eventId}/tickets', + options: { + auth: false, + validate: { params: Joi.object({ eventId: Joi.string().hex().length(24).required() }) }, + handler: controller.listCatalog, + }, + }, + { + method: 'POST', + path: '/api/public/events/{eventId}/tickets/quote', + options: { + auth: false, + validate: { + params: Joi.object({ eventId: Joi.string().hex().length(24).required() }), + payload: quotePayload, + }, + handler: controller.quote, + }, + }, + { + method: 'GET', + path: '/api/public/events/{eventId}/tickets/checkout-options', + options: { + auth: false, + validate: { params: Joi.object({ eventId: Joi.string().hex().length(24).required() }) }, + handler: controller.checkoutOptions, + }, + }, + { + method: 'POST', + path: '/api/public/events/{eventId}/tickets/manual-quote', + options: { + auth: false, + validate: { + params: Joi.object({ eventId: Joi.string().hex().length(24).required() }), + payload: quotePayload, + }, + handler: controller.manualQuote, + }, + }, + { + method: 'POST', + path: '/api/public/events/{eventId}/tickets/manual-submit', + options: { + auth: false, + validate: { + params: Joi.object({ eventId: Joi.string().hex().length(24).required() }), + payload: manualSubmitPayload, + }, + handler: controller.manualSubmit, + }, + }, + { + method: 'POST', + path: '/api/public/events/{eventId}/tickets/checkout', + options: { + auth: false, + validate: { + params: Joi.object({ eventId: Joi.string().hex().length(24).required() }), + payload: checkoutPayload, + }, + handler: controller.checkout, + }, + }, +]; diff --git a/src/routes/session.routes.js b/src/routes/session.routes.js index da1f845..a493ec7 100644 --- a/src/routes/session.routes.js +++ b/src/routes/session.routes.js @@ -32,6 +32,8 @@ const sessionPayload = Joi.object({ audienceRatingsEnabled: Joi.boolean(), audienceFeedbackForm: Joi.string().hex().length(24).allow(null, ''), audienceRatingsForm: Joi.string().hex().length(24).allow(null, ''), + enableQnA: Joi.boolean(), + enableQnP: Joi.boolean(), }); module.exports = [ diff --git a/src/routes/subscription.routes.js b/src/routes/subscription.routes.js index c505677..46c8317 100644 --- a/src/routes/subscription.routes.js +++ b/src/routes/subscription.routes.js @@ -1,3 +1,81 @@ +const Joi = require('joi'); const controller = require('../controllers/subscription.controller'); +const { assertOrgBillingManager } = require('../utils/assertOrgBillingManager'); -module.exports = [{ method: 'GET', path: '/api/subscription/catalog', options: { handler: controller.catalog } }]; +const billingPre = [{ method: assertOrgBillingManager }]; + +const checkoutOrderPayload = Joi.object({ + targetPlanId: Joi.string().trim().min(1).max(64).required(), +}); + +const checkoutVerifyPayload = Joi.object({ + orderId: Joi.string().required(), + paymentId: Joi.string().required(), + signature: Joi.string().required(), +}); + +const contactSalesPayload = Joi.object({ + targetPlanId: Joi.string().trim().max(64).allow('', null), + requirements: Joi.string().trim().min(10).max(10000).required(), +}); + +module.exports = [ + { + method: 'GET', + path: '/api/subscription/catalog', + options: { auth: 'jwt', handler: controller.catalog }, + }, + { + method: 'POST', + path: '/api/subscription/checkout/order', + options: { + auth: 'jwt', + pre: billingPre, + validate: { payload: checkoutOrderPayload }, + handler: controller.checkoutOrder, + }, + }, + { + method: 'POST', + path: '/api/subscription/checkout/verify', + options: { + auth: 'jwt', + pre: billingPre, + validate: { payload: checkoutVerifyPayload }, + handler: controller.checkoutVerify, + }, + }, + { + method: 'POST', + path: '/api/subscription/contact-sales', + options: { + auth: 'jwt', + pre: billingPre, + validate: { payload: contactSalesPayload }, + handler: controller.contactSalesRequest, + }, + }, + { + method: 'GET', + path: '/api/subscription/invoices', + options: { + auth: 'jwt', + pre: billingPre, + handler: controller.listInvoices, + }, + }, + { + method: 'GET', + path: '/api/subscription/invoices/{transactionId}/invoice', + options: { + auth: 'jwt', + pre: billingPre, + validate: { + params: Joi.object({ + transactionId: Joi.string().hex().length(24).required(), + }), + }, + handler: controller.downloadInvoice, + }, + }, +]; diff --git a/src/routes/ticketManual.routes.js b/src/routes/ticketManual.routes.js new file mode 100644 index 0000000..d1f6604 --- /dev/null +++ b/src/routes/ticketManual.routes.js @@ -0,0 +1,51 @@ +const Joi = require('joi'); +const controller = require('../controllers/ticketManual.controller'); +const { requireTicketingManagePre } = require('../services/featureAccess'); + +module.exports = [ + { + method: 'GET', + path: '/api/events/{eventId}/ticket-manual-orders', + options: { + pre: [{ method: requireTicketingManagePre() }], + validate: { + params: Joi.object({ eventId: Joi.string().hex().length(24).required() }), + query: Joi.object({ + status: Joi.string().valid('pending_review', 'approved', 'rejected', 'all').default('pending_review'), + }), + }, + handler: controller.list, + }, + }, + { + method: 'POST', + path: '/api/events/{eventId}/ticket-manual-orders/{orderId}/approve', + options: { + pre: [{ method: requireTicketingManagePre() }], + validate: { + params: Joi.object({ + eventId: Joi.string().hex().length(24).required(), + orderId: Joi.string().hex().length(24).required(), + }), + }, + handler: controller.approve, + }, + }, + { + method: 'POST', + path: '/api/events/{eventId}/ticket-manual-orders/{orderId}/reject', + options: { + pre: [{ method: requireTicketingManagePre() }], + validate: { + params: Joi.object({ + eventId: Joi.string().hex().length(24).required(), + orderId: Joi.string().hex().length(24).required(), + }), + payload: Joi.object({ + reason: Joi.string().max(2000).allow('', null), + }).default({}), + }, + handler: controller.reject, + }, + }, +]; diff --git a/src/routes/whatsappTemplate.routes.js b/src/routes/whatsappTemplate.routes.js new file mode 100644 index 0000000..ebdb786 --- /dev/null +++ b/src/routes/whatsappTemplate.routes.js @@ -0,0 +1,66 @@ +const Joi = require('joi'); +const controller = require('../controllers/whatsappTemplate.controller'); +const { ALLOWED_MERGE_KEYS } = require('../config/waTemplateMergeKeys'); + +const mongoId = Joi.string().hex().length(24).required(); + +const mergeKeySchema = Joi.string().valid(...ALLOWED_MERGE_KEYS, ''); + +const createPayload = Joi.object({ + name: Joi.string() + .pattern(/^[a-z][a-z0-9_]*$/) + .max(512) + .required(), + language: Joi.string().min(2).max(32).required(), + category: Joi.string().valid('MARKETING', 'UTILITY', 'AUTHENTICATION').required(), + components: Joi.array().items(Joi.object().unknown(true)).min(1).required(), + variableBindings: Joi.object({ + header: Joi.array().items(mergeKeySchema).max(20), + body: Joi.array().items(mergeKeySchema).max(20), + url: Joi.array().items(mergeKeySchema).max(5), + }).optional(), +}); + +module.exports = [ + { + method: 'GET', + path: '/api/whatsapp-templates', + options: { + validate: { + query: Joi.object({ + sync: Joi.alternatives().try(Joi.boolean(), Joi.string().valid('0', '1', 'true', 'false')), + status: Joi.string(), + }), + }, + handler: controller.list, + }, + }, + { + method: 'GET', + path: '/api/whatsapp-templates/{id}', + options: { + validate: { + params: Joi.object({ id: mongoId }), + query: Joi.object({ + sync: Joi.alternatives().try(Joi.boolean(), Joi.string().valid('0', '1', 'true', 'false')), + }), + }, + handler: controller.get, + }, + }, + { + method: 'POST', + path: '/api/whatsapp-templates', + options: { validate: { payload: createPayload }, handler: controller.create }, + }, + { + method: 'POST', + path: '/api/whatsapp-templates/{id}/sync', + options: { validate: { params: Joi.object({ id: mongoId }) }, handler: controller.syncOne }, + }, + { + method: 'DELETE', + path: '/api/whatsapp-templates/{id}', + options: { validate: { params: Joi.object({ id: mongoId }) }, handler: controller.remove }, + }, +]; diff --git a/src/services/attendanceCheckIn.service.js b/src/services/attendanceCheckIn.service.js new file mode 100644 index 0000000..8cb48a8 --- /dev/null +++ b/src/services/attendanceCheckIn.service.js @@ -0,0 +1,260 @@ +const mongoose = require('mongoose'); +const Attendance = require('../models/Attendance'); +const AttendanceParticipant = require('../models/AttendanceParticipant'); +const Participant = require('../models/Participant'); +const { error } = require('../utils/response'); +const { getMembership, flattenParticipantForEvent } = require('../utils/participantMembership'); + +function participantDisplayName(p) { + const parts = [p.firstName, p.lastName].filter(Boolean); + if (parts.length) return parts.join(' ').trim(); + return p.displayName || p.email || 'Participant'; +} + +function snapshotFromParticipant(participant) { + return { + firstName: participant.firstName || '', + lastName: participant.lastName || '', + email: participant.email || '', + phone: participant.phone || '', + }; +} + +async function loadAttendanceById(attendanceId, eventId) { + if (!mongoose.Types.ObjectId.isValid(String(attendanceId))) { + return { err: error('VALIDATION_ERROR', 'Invalid attendance id') }; + } + const attendance = await Attendance.findOne({ _id: attendanceId, event: eventId }).lean(); + if (!attendance) return { err: error('NOT_FOUND', 'Attendance session not found') }; + return { attendance }; +} + +async function loadAttendanceByToken(publicToken) { + const token = String(publicToken || '').trim(); + if (!token) return { err: error('VALIDATION_ERROR', 'Token required') }; + const attendance = await Attendance.findOne({ publicToken: token }).lean(); + if (!attendance) return { err: error('NOT_FOUND', 'Attendance session not found') }; + return { attendance }; +} + +function validateAttendanceActive(attendance, { source } = {}) { + if (attendance.status !== 'active') { + return { err: error('BAD_REQUEST', 'This attendance session is closed') }; + } + if (source === 'public' && !attendance.settings?.publicLinkEnabled) { + return { err: error('FORBIDDEN', 'Public check-in link is disabled') }; + } + return {}; +} + +async function syncEventMembershipCheckIn(participant, eventId) { + const m = getMembership(participant, eventId); + if (!m) return; + if (!m.checkedIn) { + m.checkedIn = true; + m.checkedInAt = new Date(); + m.status = 'checked_in'; + participant.markModified('eventMemberships'); + await participant.save(); + } +} + +function formatCheckInResult(attendance, apDoc, participant) { + const checkIns = apDoc.checkIns || []; + const last = checkIns.length ? checkIns[checkIns.length - 1] : null; + return { + attendanceId: String(attendance._id), + attendanceName: attendance.name, + participantId: String(participant._id), + displayName: participantDisplayName(participant), + email: participant.email || apDoc.email || '', + phone: participant.phone || apDoc.phone || '', + checkInCount: checkIns.length, + lastCheckedInAt: last?.checkedInAt || null, + allowDuplicateCheckIn: Boolean(attendance.settings?.allowDuplicateCheckIn), + alreadyCheckedIn: checkIns.length > 0, + }; +} + +/** + * Record a check-in for an attendance session. + * @param {object} opts + * @param {string} opts.attendanceId + * @param {string} opts.participantId + * @param {'dashboard'|'public'} opts.source + * @param {string} [opts.checkedInBy] - User id when source is dashboard + * @param {string} [opts.eventId] - Required when resolving by attendanceId + */ +async function performCheckIn({ attendanceId, publicToken, participantId, source, checkedInBy, eventId }) { + let attendance; + if (publicToken) { + const r = await loadAttendanceByToken(publicToken); + if (r.err) return { err: r.err, statusCode: 404 }; + attendance = r.attendance; + } else { + const r = await loadAttendanceById(attendanceId, eventId); + if (r.err) return { err: r.err, statusCode: r.err.code === 'VALIDATION_ERROR' ? 400 : 404 }; + attendance = r.attendance; + } + + const activeCheck = validateAttendanceActive(attendance, { source }); + if (activeCheck.err) { + return { err: activeCheck.err, statusCode: activeCheck.err.code === 'FORBIDDEN' ? 403 : 400 }; + } + + if (!mongoose.Types.ObjectId.isValid(String(participantId))) { + return { err: error('VALIDATION_ERROR', 'Invalid participant id'), statusCode: 400 }; + } + + const participant = await Participant.findById(participantId); + if (!participant) return { err: error('NOT_FOUND', 'Participant not found'), statusCode: 404 }; + + const eventIdStr = String(attendance.event); + if (!getMembership(participant, eventIdStr)) { + return { err: error('BAD_REQUEST', 'Participant is not registered for this event'), statusCode: 400 }; + } + + const allowDuplicate = Boolean(attendance.settings?.allowDuplicateCheckIn); + let ap = await AttendanceParticipant.findOne({ + attendance: attendance._id, + participant: participant._id, + }); + + if (ap && ap.checkIns.length > 0 && !allowDuplicate) { + return { err: error('CONFLICT', 'Participant already checked in'), statusCode: 409 }; + } + + const entry = { + checkedInAt: new Date(), + source, + ...(checkedInBy && source === 'dashboard' ? { checkedInBy } : {}), + }; + + if (!ap) { + ap = await AttendanceParticipant.create({ + attendance: attendance._id, + event: attendance.event, + participant: participant._id, + ...snapshotFromParticipant(participant), + checkIns: [entry], + }); + } else { + ap.checkIns.push(entry); + await ap.save(); + } + + await syncEventMembershipCheckIn(participant, eventIdStr); + + const flat = flattenParticipantForEvent(participant.toObject(), eventIdStr); + return { + result: formatCheckInResult(attendance, ap, participant), + participant: flat, + }; +} + +async function getAttendanceStats(attendanceId) { + const rows = await AttendanceParticipant.find({ attendance: attendanceId }).select('checkIns').lean(); + let totalCheckedIn = 0; + for (const r of rows) { + totalCheckedIn += (r.checkIns || []).length; + } + return { uniqueParticipants: rows.length, totalCheckedIn }; +} + +async function searchEventParticipantsForAttendance({ + eventId, + organizationId, + q, + attendanceId, + limit = 20, + maxCap = 50, +}) { + const eid = new mongoose.Types.ObjectId(String(eventId)); + const preStages = [ + { $match: { tenantOrganization: organizationId } }, + { $unwind: '$eventMemberships' }, + { $match: { 'eventMemberships.event': eid } }, + ]; + + if (q && String(q).trim()) { + const search = String(q).trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp(search, 'i'); + preStages.push({ + $match: { + $or: [ + { firstName: re }, + { lastName: re }, + { email: re }, + { phone: re }, + { displayName: re }, + ], + }, + }); + } + + const cap = Math.min(Math.max(parseInt(maxCap, 10) || 50, 1), 5000); + const limitNum = Math.min(Math.max(parseInt(limit, 10) || 20, 1), cap); + const rows = await Participant.aggregate([ + ...preStages, + { $sort: { firstName: 1, lastName: 1 } }, + { $limit: limitNum }, + { + $project: { + _id: 1, + firstName: 1, + lastName: 1, + email: 1, + phone: 1, + displayName: 1, + status: '$eventMemberships.status', + checkedIn: '$eventMemberships.checkedIn', + }, + }, + ]); + + const participantIds = rows.map((r) => r._id); + const checkedMap = new Map(); + if (participantIds.length && attendanceId) { + const existing = await AttendanceParticipant.find({ + attendance: attendanceId, + participant: { $in: participantIds }, + }) + .select('participant checkIns') + .lean(); + for (const ap of existing) { + checkedMap.set(String(ap.participant), { + checkInCount: (ap.checkIns || []).length, + lastCheckedInAt: + ap.checkIns?.length > 0 ? ap.checkIns[ap.checkIns.length - 1].checkedInAt : null, + }); + } + } + + const participants = rows.map((r) => { + const info = checkedMap.get(String(r._id)); + const checkInCount = info?.checkInCount ?? 0; + return { + _id: String(r._id), + firstName: r.firstName, + lastName: r.lastName, + email: r.email, + phone: r.phone, + displayName: participantDisplayName(r), + status: r.status, + eventCheckedIn: Boolean(r.checkedIn), + alreadyCheckedIn: checkInCount > 0, + checkInCount, + lastCheckedInAt: info?.lastCheckedInAt ?? null, + }; + }); + + return { participants }; +} + +module.exports = { + performCheckIn, + getAttendanceStats, + searchEventParticipantsForAttendance, + loadAttendanceByToken, + participantDisplayName, +}; diff --git a/src/services/checkins.service.js b/src/services/checkins.service.js new file mode 100644 index 0000000..235ce09 --- /dev/null +++ b/src/services/checkins.service.js @@ -0,0 +1,113 @@ +const mongoose = require('mongoose'); +const Event = require('../models/Event'); +const Attendance = require('../models/Attendance'); +const OrganizationUserMap = require('../models/OrganizationUserMap'); + +function toObjectId(id) { + if (id instanceof mongoose.Types.ObjectId) return id; + return new mongoose.Types.ObjectId(String(id)); +} + +/** + * Event IDs the user may see for check-ins. + * - null = no eventAccess restriction (all org events with attendance) + * - [] = explicitly no assigned events + * - [...] = only listed events + * + * Aligns with attendance routes (org membership), not a separate ACL. + * Admin/super_admin always get null. Staff with empty eventAccess see all org events. + */ +async function resolveAccessibleEventIds(user, organizationId) { + if (user.role === 'super_admin' || user.role === 'admin') { + return null; + } + + const uid = user._id || user.id; + const map = await OrganizationUserMap.findOne({ + user: uid, + organization: organizationId, + }) + .select('eventAccess role') + .lean(); + + const accessList = map?.eventAccess || []; + if (!accessList.length) { + return null; + } + + const ids = accessList + .map((ea) => ea.event) + .filter((id) => id && mongoose.Types.ObjectId.isValid(String(id))) + .map((id) => new mongoose.Types.ObjectId(String(id))); + + return ids; +} + +/** + * Events that have at least one active attendance session (any date). + */ +async function listEventsWithAttendance(user, organizationId) { + const orgOid = toObjectId(organizationId); + + const attendanceRows = await Attendance.aggregate([ + { $match: { status: 'active' } }, + { + $lookup: { + from: 'events', + localField: 'event', + foreignField: '_id', + as: 'eventDoc', + }, + }, + { $unwind: '$eventDoc' }, + { + $match: { + 'eventDoc.organization': orgOid, + 'eventDoc.recordKind': { $ne: 'activity' }, + }, + }, + { $group: { _id: '$event', count: { $sum: 1 } } }, + ]); + + if (!attendanceRows.length) { + return []; + } + + const eventIdToCount = new Map(attendanceRows.map((r) => [String(r._id), r.count])); + let eventIds = attendanceRows.map((r) => r._id); + + const restrictedIds = await resolveAccessibleEventIds(user, organizationId); + if (restrictedIds !== null) { + const allowed = new Set(restrictedIds.map((id) => String(id))); + eventIds = eventIds.filter((id) => allowed.has(String(id))); + } + + if (!eventIds.length) { + return []; + } + + const events = await Event.find({ + _id: { $in: eventIds }, + organization: orgOid, + recordKind: { $ne: 'activity' }, + }) + .select('name startDate endDate location image status') + .sort({ startDate: -1 }) + .lean(); + + return events.map((ev) => ({ + _id: String(ev._id), + name: ev.name, + startDate: ev.startDate, + endDate: ev.endDate, + location: ev.location, + image: ev.image, + status: ev.status, + attendanceSessionCount: eventIdToCount.get(String(ev._id)) || 0, + })); +} + +module.exports = { + listEventsWithAttendance, + resolveAccessibleEventIds, +}; diff --git a/src/services/conciergeQueue.js b/src/services/conciergeQueue.js new file mode 100644 index 0000000..b235b9a --- /dev/null +++ b/src/services/conciergeQueue.js @@ -0,0 +1,39 @@ +const { Queue } = require('bullmq'); +const { getBullMQConnection } = require('../config/redisConnection'); + +const QUEUE_PREFIX = process.env.CONCIERGE_QUEUE_PREFIX || 'eventbuzz:concierge:'; +const INGEST_QUEUE_NAME = 'concierge-ingest'; + +let ingestQueue; + +function getRedisConnection() { + return getBullMQConnection(); +} + +function getIngestQueue() { + if (!ingestQueue) { + ingestQueue = new Queue(INGEST_QUEUE_NAME, { + connection: getRedisConnection(), + prefix: QUEUE_PREFIX, + }); + } + return ingestQueue; +} + +/** + * @param {{ sourceId: string, botId: string, eventId: string, type: string }} data + */ +async function enqueueConciergeIngest(data) { + const queue = getIngestQueue(); + await queue.add('ingest', data, { + removeOnComplete: 100, + removeOnFail: 200, + }); +} + +module.exports = { + getIngestQueue, + enqueueConciergeIngest, + INGEST_QUEUE_NAME, + QUEUE_PREFIX, +}; diff --git a/src/services/conciergeRag.service.js b/src/services/conciergeRag.service.js new file mode 100644 index 0000000..1f6f96d --- /dev/null +++ b/src/services/conciergeRag.service.js @@ -0,0 +1,124 @@ +const { dot, norm } = require('mathjs'); +const ContextChunk = require('../models/ContextChunk'); +const KnowledgeSource = require('../models/KnowledgeSource'); +const { embedTexts } = require('../utils/bedrockEmbed'); +const { + createBedrockClient, + getBedrockConfig, + buildModelIdCandidates, + isInvalidModelError, +} = require('../utils/bedrockClient'); +const { converse, responseText } = require('../utils/bedrockConverse'); +const { buildConciergeSystemPrompt } = require('../../../chatbot/src/prompts/concierge'); +const Event = require('../models/Event'); +const { registrationHref } = require('../utils/micrositeBuild'); + +function cosineSimilarity(a, b) { + const na = norm(a); + const nb = norm(b); + if (!na || !nb) return 0; + return dot(a, b) / (na * nb); +} + +async function buildEventFactsBlock(eventId) { + const event = await Event.findById(eventId) + .select('name startDate endDate location locationType virtualLink website.folder website.registrationUrl website.registrationFormId') + .lean(); + if (!event) return ''; + const lines = []; + const push = (label, value) => { + if (value != null && String(value).trim()) lines.push(`${label}: ${String(value).trim()}`); + }; + push('Event name', event.name); + if (event.startDate) push('Start date', new Date(event.startDate).toISOString()); + if (event.endDate) push('End date', new Date(event.endDate).toISOString()); + push('City/Location', event.location); + push('Location type', event.locationType); + push('Virtual link', event.virtualLink); + const regUrl = registrationHref(event, event.website); + if (regUrl) push('Registration URL', regUrl); + const slug = event.website?.folder ? String(event.website.folder).trim() : ''; + if (slug) push('Microsite slug', slug); + return lines.length ? `Event facts (ground truth):\n${lines.join('\n')}\n\n` : ''; +} + +function extractUsage(response) { + const usage = response && typeof response === 'object' ? response.usage : null; + const inputTokens = Number(usage?.inputTokens || usage?.input_tokens || 0) || 0; + const outputTokens = Number(usage?.outputTokens || usage?.output_tokens || 0) || 0; + const totalTokens = Number(usage?.totalTokens || usage?.total_tokens || inputTokens + outputTokens) || (inputTokens + outputTokens); + return { inputTokens, outputTokens, totalTokens }; +} + +/** + * @param {string} eventId + * @param {string} userQuery + * @param {string} eventName + * @param {{ includeEventDetails?: boolean, history?: { role: 'user'|'assistant', text: string }[] }} [options] + */ +async function executeConciergeRag(eventId, userQuery, eventName, options = {}) { + const includeEventDetails = options.includeEventDetails !== false; + const history = Array.isArray(options.history) ? options.history : []; + + const { embeddings, modelId: embedModelId } = await embedTexts([userQuery]); + const queryEmbedding = embeddings[0]; + if (!queryEmbedding?.length) return null; + + const chunkFilter = { + eventId, + isActive: true, + $or: [ + { embeddingModel: embedModelId }, + { embeddingModel: { $in: [null, ''] } }, + { embeddingModel: { $exists: false } }, + ], + }; + + if (!includeEventDetails) { + const eventSource = await KnowledgeSource.findOne({ eventId, type: 'event' }).select('_id').lean(); + if (eventSource) chunkFilter.sourceId = { $ne: eventSource._id }; + } + + const activeChunks = await ContextChunk.find(chunkFilter).lean(); + if (!activeChunks.length) return null; + + const ranked = activeChunks + .map((chunk) => ({ text: chunk.rawText, score: cosineSimilarity(queryEmbedding, chunk.embedding) })) + .sort((a, b) => b.score - a.score) + .slice(0, 4); + + const contextBlock = ranked.map((c) => c.text).join('\n\n'); + const { region, modelId: configuredId } = getBedrockConfig(); + const client = createBedrockClient(region); + const system = buildConciergeSystemPrompt(eventName); + const eventFacts = await buildEventFactsBlock(eventId); + + const historyBlock = history + .slice(-6) + .map((m) => `${m.role === 'assistant' ? 'Assistant' : 'User'}: ${String(m.text || '').slice(0, 800)}`) + .join('\n'); + + const userText = [ + eventFacts, + historyBlock ? `Conversation so far:\n${historyBlock}\n\n` : '', + `Context:\n${contextBlock}\n\nQuestion: ${userQuery}`, + ].join(''); + + for (const modelId of buildModelIdCandidates(configuredId)) { + try { + const response = await converse(client, modelId, { + system: [{ text: system }], + messages: [{ role: 'user', content: [{ text: userText }] }], + }); + return { text: responseText(response), modelId, usage: extractUsage(response) }; + } catch (err) { + if (!isInvalidModelError(err)) throw err; + } + } + return null; +} + +module.exports = { + executeConciergeRag, +}; + diff --git a/src/services/featureAccess.js b/src/services/featureAccess.js index 9e1d3b5..6e6b1b4 100644 --- a/src/services/featureAccess.js +++ b/src/services/featureAccess.js @@ -1,5 +1,10 @@ const Boom = require('@hapi/boom'); -const { FEATURES, PLANS } = require('../config/subscriptionCatalog'); +const { defaultFeatureValuesByPlanId } = require('../config/planFeatureMatrix'); +const { + FEATURES, + getPlanForAccess, + canonicalizeFeatureKey, +} = require('../config/subscriptionCatalog'); /** * Subscription active if status not cancelled/past_due (when set) and not past expiresAt. @@ -14,23 +19,29 @@ function isSubscriptionActive(org) { /** * @param {import('mongoose').Document | object} org - Organization doc or lean object - * @returns {{ featureKeys: Set, limits: { maxEvents?: number|null, maxParticipants?: number|null } }} + * @returns {{ featureKeys: Set, limits: { maxEvents?: number|null, maxParticipants?: number|null }, featureValues: object }} */ function resolveEffectiveFeatures(org) { const planKey = org?.subscription?.plan || 'free'; - const plan = PLANS[planKey] || PLANS.free; + const plan = getPlanForAccess(planKey); const keys = new Set(plan.featureKeys); const overrides = org?.subscription?.featureOverrides; if (Array.isArray(overrides)) { overrides.forEach((k) => { - if (typeof k === 'string' && FEATURES[k]) keys.add(k); + const canon = canonicalizeFeatureKey(k); + if (canon && FEATURES[canon]) keys.add(canon); }); } const maxEvents = org?.subscription?.maxEvents ?? plan.defaultLimits?.maxEvents; const maxParticipants = org?.subscription?.maxParticipants ?? plan.defaultLimits?.maxParticipants; + const featureValues = + plan.featureValues && typeof plan.featureValues === 'object' ? + plan.featureValues + : defaultFeatureValuesByPlanId(planKey); return { featureKeys: keys, limits: { maxEvents, maxParticipants }, + featureValues, }; } @@ -41,8 +52,10 @@ function resolveEffectiveFeatures(org) { */ function hasFeature(org, featureKey) { if (!isSubscriptionActive(org)) return false; + const canon = canonicalizeFeatureKey(featureKey) || (FEATURES[featureKey] ? featureKey : null); + if (!canon) return false; const { featureKeys } = resolveEffectiveFeatures(org); - return featureKeys.has(featureKey); + return featureKeys.has(canon); } /** @@ -51,8 +64,9 @@ function hasFeature(org, featureKey) { * @param {{ minEvents?: number }} [params] reserved for future parametric checks */ function assertFeature(org, featureKey, params) { - if (!FEATURES[featureKey]) return false; - if (!hasFeature(org, featureKey)) return false; + const canon = canonicalizeFeatureKey(featureKey) || (FEATURES[featureKey] ? featureKey : null); + if (!canon) return false; + if (!hasFeature(org, canon)) return false; void params; return true; } @@ -76,10 +90,29 @@ function requireFeaturePre(featureKey) { }; } +/** + * Ticket CRUD + manual UPI settings: any active org (or super_admin). Online checkout still requires a configured gateway. + */ +function requireTicketingManagePre() { + return async (request, h) => { + const user = request.auth?.credentials?.user; + if (!user) throw Boom.unauthorized(); + if (user.role === 'super_admin') return h.continue; + const Organization = require('../models/Organization'); + const orgId = user.organization?._id || user.organization; + const orgDoc = await Organization.findById(orgId).lean(); + if (!orgDoc || !isSubscriptionActive(orgDoc)) { + throw Boom.forbidden('Subscription inactive or organisation missing.'); + } + return h.continue; + }; +} + module.exports = { isSubscriptionActive, resolveEffectiveFeatures, hasFeature, assertFeature, requireFeaturePre, + requireTicketingManagePre, }; diff --git a/src/services/payments/createOrderCore.js b/src/services/payments/createOrderCore.js index e62b55a..c3b6aa5 100644 --- a/src/services/payments/createOrderCore.js +++ b/src/services/payments/createOrderCore.js @@ -1,9 +1,16 @@ const PaymentTransaction = require('../../models/PaymentTransaction'); const PlatformPaymentConfig = require('../../models/PlatformPaymentConfig'); -const { resolveCheckout, computeAmounts } = require('./resolver'); +const { resolveCheckout, resolveTicketPurchaseCheckout, resolveForPaymentTransaction, computeAmounts } = require('./resolver'); const { createGatewayOrder } = require('./adapters'); const { getOrCreateProfile } = require('./organizationPaymentProfileService'); +function normalizePurpose(purpose) { + if (purpose === 'saas_subscription') return 'saas_subscription'; + if (purpose === 'ticket_purchase') return 'ticket_purchase'; + if (purpose === 'ticketing_manual_enablement') return 'ticketing_manual_enablement'; + return 'registration'; +} + /** * @param {object} args * @param {import('mongoose').Types.ObjectId} args.organizationId @@ -13,6 +20,9 @@ const { getOrCreateProfile } = require('./organizationPaymentProfileService'); * @param {string} [args.currency] * @param {string} [args.idempotencyKey] * @param {object} [args.registrationContext] + * @param {'registration'|'saas_subscription'|'ticket_purchase'|'ticketing_manual_enablement'} [args.purpose] + * @param {string} [args.subscriptionPlanId] + * @param {import('mongoose').Types.ObjectId} [args.purchasedBy] */ async function createPaymentOrderCore(args) { const { @@ -23,6 +33,9 @@ async function createPaymentOrderCore(args) { currency: overrideCurrency, idempotencyKey, registrationContext, + purpose, + subscriptionPlanId, + purchasedBy, } = args; const subtotal = Math.round(Number(amountSubtotalMinor)); @@ -34,7 +47,11 @@ async function createPaymentOrderCore(args) { const profile = await getOrCreateProfile(organizationId); const platform = await PlatformPaymentConfig.findOne({ key: 'default' }); - const ctx = await resolveCheckout(profile, platform); + const normalizedPurpose = normalizePurpose(purpose); + const ctx = + normalizedPurpose === 'ticket_purchase' + ? await resolveTicketPurchaseCheckout(profile) + : await resolveCheckout(profile, platform); const effectiveCurrency = (overrideCurrency || ctx.currency || 'INR').toUpperCase(); const { commissionAmount, amountGross, amountNet } = computeAmounts(subtotal, ctx.commissionBps); @@ -56,9 +73,10 @@ async function createPaymentOrderCore(args) { throw done; } if (existing.externalOrderId) { + const resumeCtx = await resolveForPaymentTransaction(existing); return { transaction: existing, - keyId: ctx.keyId, + keyId: resumeCtx.keyId, orderId: existing.externalOrderId, amount: amountGross, currency: effectiveCurrency, @@ -69,10 +87,11 @@ async function createPaymentOrderCore(args) { amountNet: existing.amountNet, }; } + const orderCtx = await resolveForPaymentTransaction(existing); const receipt = `eb_${existing._id.toString().slice(-12)}`; const orderResult = await createGatewayOrder( - ctx.gatewayProvider, - { keyId: ctx.keyId, keySecret: ctx.keySecret }, + orderCtx.gatewayProvider, + { keyId: orderCtx.keyId, keySecret: orderCtx.keySecret }, { amountMinor: amountGross, currency: effectiveCurrency, @@ -81,15 +100,17 @@ async function createPaymentOrderCore(args) { txId: existing._id.toString(), eventId: eventId ? String(eventId) : '', participantId: participantId ? String(participantId) : '', + purpose: normalizedPurpose, + subscriptionPlanId: subscriptionPlanId ? String(subscriptionPlanId) : '', }, }, ); existing.externalOrderId = orderResult.externalOrderId; - existing.ledgerKeyId = ctx.keyId || ''; + existing.ledgerKeyId = orderCtx.keyId || ''; await existing.save(); return { transaction: existing, - keyId: ctx.keyId, + keyId: orderCtx.keyId, orderId: orderResult.externalOrderId, amount: amountGross, currency: effectiveCurrency, @@ -118,6 +139,9 @@ async function createPaymentOrderCore(args) { idempotencyKey: idempotencyKey ? String(idempotencyKey) : '', ledgerKeyId: ctx.keyId || '', registrationContext: registrationContext || null, + purpose: normalizedPurpose, + subscriptionPlanId: subscriptionPlanId ? String(subscriptionPlanId) : '', + purchasedBy: purchasedBy || null, }); const receipt = `eb_${tx._id.toString().slice(-12)}`; @@ -132,6 +156,8 @@ async function createPaymentOrderCore(args) { txId: tx._id.toString(), eventId: eventId ? String(eventId) : '', participantId: participantId ? String(participantId) : '', + purpose: normalizedPurpose, + subscriptionPlanId: subscriptionPlanId ? String(subscriptionPlanId) : '', }, }, ); diff --git a/src/services/payments/resolver.js b/src/services/payments/resolver.js index 8e35b69..ea46c6c 100644 --- a/src/services/payments/resolver.js +++ b/src/services/payments/resolver.js @@ -11,6 +11,14 @@ function commissionFromSubtotal(subtotalMinor, bps) { return Math.round((subtotalMinor * b) / 10000); } +/** Prefer RAZORPAY_PLATFORM_*; fall back to RAZORPAY_KEY_* (see .env.example). */ +function getPlatformRazorpayEnvCredentials() { + const keyId = process.env.RAZORPAY_PLATFORM_KEY_ID || process.env.RAZORPAY_KEY_ID; + const keySecret = process.env.RAZORPAY_PLATFORM_KEY_SECRET || process.env.RAZORPAY_KEY_SECRET; + if (keyId && keySecret) return { keyId, keySecret }; + return null; +} + /** * Resolve checkout: credentials source + commission + amounts (minor units). * @param {import('mongoose').Document} orgProfile lean or doc @@ -22,35 +30,36 @@ async function resolveCheckout(orgProfile, platformDoc) { if (mode === 'platform_razorpay') { const platform = platformDoc || (await PlatformPaymentConfig.findOne({ key: 'default' })); - if (!platform || !platform.enabled) { - const err = new Error('Platform checkout is not enabled. Configure platform keys or use a custom gateway.'); - err.code = 'PLATFORM_CHECKOUT_DISABLED'; - throw err; - } let cred; try { cred = - platform.razorpayCredentialsEnc && platform.razorpayCredentialsEnc.length > 8 + platform?.razorpayCredentialsEnc && platform.razorpayCredentialsEnc.length > 8 ? decryptObject(platform.razorpayCredentialsEnc) : {}; } catch { cred = {}; } if (!cred.keyId || !cred.keySecret) { - const envId = process.env.RAZORPAY_PLATFORM_KEY_ID; - const envSecret = process.env.RAZORPAY_PLATFORM_KEY_SECRET; - if (envId && envSecret) cred = { keyId: envId, keySecret: envSecret }; + const envCred = getPlatformRazorpayEnvCredentials(); + if (envCred) cred = envCred; } - if (!cred.keyId || !cred.keySecret) { + const hasRazorpayKeys = Boolean(cred.keyId && cred.keySecret); + // DB toggle off is OK when keys exist in .env or encrypted on the platform doc (self-hosted / dev). + if (!(platform?.enabled || hasRazorpayKeys)) { + const err = new Error('Platform checkout is not enabled. Configure platform keys or use a custom gateway.'); + err.code = 'PLATFORM_CHECKOUT_DISABLED'; + throw err; + } + if (!hasRazorpayKeys) { const err = new Error('Platform Razorpay is not configured'); err.code = 'PLATFORM_RAZORPAY_MISSING'; throw err; } - const defaultBps = platform.defaultCommissionBps ?? 200; + const defaultBps = platform?.defaultCommissionBps ?? 200; const orgBps = orgProfile.platformCommissionBps != null ? orgProfile.platformCommissionBps : defaultBps; let webhookSecret = ''; try { - if (platform.webhookSecretEnc) webhookSecret = decryptString(platform.webhookSecretEnc); + if (platform?.webhookSecretEnc) webhookSecret = decryptString(platform.webhookSecretEnc); } catch { webhookSecret = process.env.RAZORPAY_PLATFORM_WEBHOOK_SECRET || ''; } @@ -60,13 +69,22 @@ async function resolveCheckout(orgProfile, platformDoc) { orgGatewayId: null, keyId: cred.keyId, keySecret: cred.keySecret, - testMode: platform.testMode !== false, + testMode: platform?.testMode !== false, commissionBps: orgBps, currency, webhookSecret, }; } + return resolveOrgCustomGatewayCheckout(orgProfile); +} + +/** + * Org's own gateway only (no platform Razorpay). Used for ticket purchases and custom checkout mode. + * @param {import('mongoose').Document} orgProfile + */ +function resolveOrgCustomGatewayCheckout(orgProfile) { + const currency = orgProfile.defaultCurrency || 'INR'; const gateways = orgProfile.gateways || []; const primary = gateways.find((g) => g.isPrimary && g.isActive) || gateways.find((g) => g.isActive); @@ -109,6 +127,22 @@ async function resolveCheckout(orgProfile, platformDoc) { throw err; } +/** + * Ticket sales never use the platform Razorpay wallet — only the organiser's connected account. + * @param {import('mongoose').Document} orgProfile + */ +function resolveTicketPurchaseCheckout(orgProfile) { + const mode = orgProfile.checkoutMode || 'platform_razorpay'; + if (mode === 'platform_razorpay') { + const err = new Error( + "Ticket payments must go to the organiser's connected Razorpay account, not the platform. Add your Razorpay keys under Settings → Payments, or offer UPI / manual checkout for this event.", + ); + err.code = 'TICKET_ORG_GATEWAY_REQUIRED'; + throw err; + } + return resolveOrgCustomGatewayCheckout(orgProfile); +} + function computeAmounts(subtotalMinor, commissionBps) { const commissionAmount = commissionFromSubtotal(subtotalMinor, commissionBps); const amountGross = subtotalMinor + commissionAmount; @@ -175,6 +209,8 @@ async function resolveForPaymentTransaction(tx) { module.exports = { resolveCheckout, + resolveOrgCustomGatewayCheckout, + resolveTicketPurchaseCheckout, resolveForPaymentTransaction, commissionFromSubtotal, computeAmounts, diff --git a/src/services/payments/verifyPaymentCore.js b/src/services/payments/verifyPaymentCore.js index 0927f37..e8e687c 100644 --- a/src/services/payments/verifyPaymentCore.js +++ b/src/services/payments/verifyPaymentCore.js @@ -2,6 +2,9 @@ const PaymentTransaction = require('../../models/PaymentTransaction'); const Participant = require('../../models/Participant'); const { resolveForPaymentTransaction } = require('./resolver'); const { razorpay: razorpayAdapter } = require('./adapters'); +const { applySubscriptionFromPayment } = require('../subscriptionPayment.service'); +const { applyTicketingManualEnablementFromPayment } = require('../ticketingManualEnablement.service'); +const { fulfillTicketPurchaseIfNeeded } = require('../ticketPurchaseFulfillment'); function minorToMajor(amountMinor, currency) { void currency; @@ -27,6 +30,15 @@ async function applyPaidStatus(tx, paymentId) { }, }); } + + const fresh = await PaymentTransaction.findById(tx._id); + if (fresh) { + try { + await fulfillTicketPurchaseIfNeeded(fresh); + } catch (err) { + console.error('[applyPaidStatus] fulfillTicketPurchaseIfNeeded', err); + } + } } async function verifyPaymentCore(p) { @@ -45,7 +57,23 @@ async function verifyPaymentCore(p) { await applyPaidStatus(tx, paymentId); - return { ok: true, transaction: tx }; + const reloaded = await PaymentTransaction.findById(tx._id); + if (reloaded && reloaded.purpose === 'saas_subscription') { + try { + await applySubscriptionFromPayment(reloaded); + } catch (err) { + console.error('[verifyPaymentCore] applySubscriptionFromPayment failed', err); + } + } + if (reloaded && reloaded.purpose === 'ticketing_manual_enablement') { + try { + await applyTicketingManualEnablementFromPayment(reloaded); + } catch (err) { + console.error('[verifyPaymentCore] applyTicketingManualEnablementFromPayment failed', err); + } + } + + return { ok: true, transaction: reloaded || tx }; } module.exports = { verifyPaymentCore, minorToMajor, applyPaidStatus }; diff --git a/src/services/subscriptionPayment.service.js b/src/services/subscriptionPayment.service.js new file mode 100644 index 0000000..e6f1160 --- /dev/null +++ b/src/services/subscriptionPayment.service.js @@ -0,0 +1,173 @@ +const Organization = require('../models/Organization'); +const { getPlanForAccess } = require('../config/subscriptionCatalog'); +const { createPaymentOrderCore } = require('./payments/createOrderCore'); + +function addMonths(d, m) { + const x = new Date(d); + x.setMonth(x.getMonth() + m); + return x; +} + +function addYears(d, y) { + const x = new Date(d); + x.setFullYear(x.getFullYear() + y); + return x; +} + +/** @param {object} plan - merged plan from getPlanForAccess */ +function computeSubscriptionExpiry(plan) { + const pr = plan.pricing || {}; + const interval = pr.interval; + if (!interval || interval === 'none' || interval === 'custom') return null; + const now = new Date(); + if (interval === 'month') return addMonths(now, 1); + if (interval === 'year') return addYears(now, 1); + return null; +} + +/** + * Apply org subscription after Razorpay verify for `saas_subscription` transactions. + * @param {import('mongoose').Document} tx - PaymentTransaction + */ +async function applySubscriptionFromPayment(tx) { + if (tx.purpose !== 'saas_subscription' || !tx.subscriptionPlanId) return; + const planId = String(tx.subscriptionPlanId).trim(); + const plan = getPlanForAccess(planId); + if (!plan) { + const e = new Error(`Unknown plan ${planId}`); + e.code = 'PLAN_MISSING'; + throw e; + } + const org = await Organization.findById(tx.organization); + if (!org) { + const e = new Error('Organization not found'); + e.code = 'NOT_FOUND'; + throw e; + } + const sub = org.subscription || {}; + sub.plan = planId; + sub.status = 'active'; + sub.expiresAt = computeSubscriptionExpiry(plan); + const lim = plan.defaultLimits || {}; + sub.maxEvents = lim.maxEvents !== undefined ? lim.maxEvents : null; + sub.maxParticipants = lim.maxParticipants !== undefined ? lim.maxParticipants : null; + org.subscription = sub; + org.markModified('subscription'); + await org.save(); +} + +/** + * @param {{ organizationId: import('mongoose').Types.ObjectId, userId: import('mongoose').Types.ObjectId, targetPlanId: string }} p + */ +async function createSubscriptionCheckoutOrder(p) { + const { organizationId, userId, targetPlanId } = p; + const plan = getPlanForAccess(targetPlanId); + if (!plan || plan.id !== targetPlanId) { + const e = new Error('Invalid or unknown plan'); + e.code = 'INVALID_PLAN'; + throw e; + } + const pr = plan.pricing || {}; + if (pr.contactSales) { + const e = new Error('This plan is only available via contact sales'); + e.code = 'CONTACT_SALES'; + throw e; + } + if (targetPlanId === 'free') { + const e = new Error('Cannot purchase the free plan'); + e.code = 'INVALID_PLAN'; + throw e; + } + const amountMajor = pr.amount; + if (amountMajor == null || Number(amountMajor) <= 0) { + const e = new Error('Plan has no payable amount'); + e.code = 'NO_CHARGE'; + throw e; + } + const amountPaise = Math.round(Number(amountMajor) * 100); + return createPaymentOrderCore({ + organizationId, + eventId: null, + participantId: null, + amountSubtotalMinor: amountPaise, + currency: (pr.currency || 'INR').toUpperCase(), + idempotencyKey: '', + purpose: 'saas_subscription', + subscriptionPlanId: targetPlanId, + purchasedBy: userId, + registrationContext: { type: 'saas_subscription', targetPlanId }, + }); +} + +function escapeHtml(s) { + return String(s ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** + * @param {object|null} org - Organization lean doc + * @param {object} tx - PaymentTransaction lean doc + */ +function buildManualEnablementInvoiceHtml(org, tx, eventName) { + const orgName = escapeHtml(org?.name || 'Organisation'); + const ev = escapeHtml(eventName || 'Event'); + const orderId = escapeHtml(tx.externalOrderId || ''); + const payId = escapeHtml(tx.externalPaymentId || ''); + const currency = escapeHtml(tx.currency || 'INR'); + const major = (Number(tx.amountGross) / 100).toFixed(2); + const date = tx.createdAt ? new Date(tx.createdAt).toISOString() : ''; + return ` +Receipt + + +

EventBuzz — manual UPI ticketing enablement

+

One-time fee to unlock manual UPI / proof-based ticketing for this event.

+

${orgName}

+ + + + + + + + +
Event${ev}
DescriptionManual UPI ticketing — enablement fee
Amount${currency} ${major}
Date${escapeHtml(date)}
Razorpay order${orderId}
Razorpay payment${payId}
Transaction id${escapeHtml(String(tx._id))}
+`; +} + +function buildSubscriptionInvoiceHtml(org, tx) { + const orgName = escapeHtml(org?.name || 'Organisation'); + const planId = escapeHtml(tx.subscriptionPlanId || ''); + const orderId = escapeHtml(tx.externalOrderId || ''); + const payId = escapeHtml(tx.externalPaymentId || ''); + const currency = escapeHtml(tx.currency || 'INR'); + const major = (Number(tx.amountGross) / 100).toFixed(2); + const date = tx.createdAt ? new Date(tx.createdAt).toISOString() : ''; + return ` +Invoice + + +

EventBuzz — subscription receipt

+

This document is a receipt for your plan purchase.

+

${orgName}

+ + + + + + + +
Plan${planId}
Amount${currency} ${major}
Date${escapeHtml(date)}
Razorpay order${orderId}
Razorpay payment${payId}
Transaction id${escapeHtml(String(tx._id))}
+`; +} + +module.exports = { + applySubscriptionFromPayment, + computeSubscriptionExpiry, + createSubscriptionCheckoutOrder, + buildSubscriptionInvoiceHtml, + buildManualEnablementInvoiceHtml, +}; diff --git a/src/services/ticketPurchaseFulfillment.js b/src/services/ticketPurchaseFulfillment.js new file mode 100644 index 0000000..5a07f2e --- /dev/null +++ b/src/services/ticketPurchaseFulfillment.js @@ -0,0 +1,192 @@ +const mongoose = require('mongoose'); +const PaymentTransaction = require('../models/PaymentTransaction'); +const EventTicket = require('../models/EventTicket'); +const Event = require('../models/Event'); +const Participant = require('../models/Participant'); +const ParticipantList = require('../models/ParticipantList'); +const Form = require('../models/Form'); +const FormSubmission = require('../models/FormSubmission'); +const { buildMergedFieldData } = require('../utils/ticketFormValidation'); +const { notifyTicketStakeholders } = require('./ticketSaleNotify'); + +function minorToMajor(amountMinor) { + return Math.round(Number(amountMinor)) / 100; +} + +/** + * Core fulfillment from ticket_purchase registrationContext shape. + * @param {object} ctx - { kind, ticketId, eventId, attendees, quote?, billingStateCode?, purchaserEmail? } + * @param {object} [opts] + * @param {import('mongoose').Document} [opts.transaction] - PaymentTransaction to mark fulfilled + * @param {{ amountGrossMinor: number, currency: string, externalId: string } | null} [opts.paymentLine] - first attendee payment row; null skips + * @returns {Promise} participant ids created/updated + */ +async function fulfillTicketPurchaseFromContext(ctx, opts = {}) { + if (!ctx || ctx.kind !== 'ticket_purchase') return []; + if (ctx.fulfilledAt) return Array.isArray(ctx.fulfilledParticipantIds) ? ctx.fulfilledParticipantIds : []; + + const ticketId = ctx.ticketId; + const eventId = ctx.eventId; + if (!ticketId || !eventId) return []; + + const ticket = await EventTicket.findById(ticketId).lean(); + if (!ticket) return []; + const event = await Event.findById(eventId).lean(); + if (!event) return []; + const orgId = event.organization; + + const attendees = Array.isArray(ctx.attendees) ? ctx.attendees : []; + if (!attendees.length) return []; + + let registrationForm = null; + if (ticket.registrationForm) { + registrationForm = await Form.findById(ticket.registrationForm).lean(); + } + + const participantIds = []; + let attendeeIndex = 0; + const paymentLine = opts.paymentLine; + + for (const raw of attendees) { + const attendee = { + firstName: String(raw.firstName || '').trim(), + lastName: String(raw.lastName || '').trim(), + email: String(raw.email || '').trim().toLowerCase(), + phone: raw.phone != null ? String(raw.phone).trim() : '', + organization: raw.organization != null ? String(raw.organization).trim() : '', + designation: raw.designation != null ? String(raw.designation).trim() : '', + displayName: raw.displayName != null ? String(raw.displayName).trim() : '', + formData: raw.formData && typeof raw.formData === 'object' ? raw.formData : {}, + }; + if (!attendee.email) continue; + + const orgOid = orgId instanceof mongoose.Types.ObjectId ? orgId : new mongoose.Types.ObjectId(String(orgId)); + let p = await Participant.findOne({ tenantOrganization: orgOid, email: attendee.email }); + if (!p) { + p = await Participant.create({ + tenantOrganization: orgOid, + email: attendee.email, + firstName: attendee.firstName, + lastName: attendee.lastName, + phone: attendee.phone || undefined, + organization: attendee.organization || undefined, + designation: attendee.designation || undefined, + displayName: attendee.displayName || undefined, + }); + } else { + if (attendee.firstName) p.firstName = attendee.firstName; + if (attendee.lastName) p.lastName = attendee.lastName; + if (attendee.phone) p.phone = attendee.phone; + if (attendee.organization) p.organization = attendee.organization; + if (attendee.designation) p.designation = attendee.designation; + if (attendee.displayName) p.displayName = attendee.displayName; + } + + const evOid = eventId instanceof mongoose.Types.ObjectId ? eventId : new mongoose.Types.ObjectId(String(eventId)); + const memberships = Array.isArray(p.eventMemberships) ? [...p.eventMemberships] : []; + const idx = memberships.findIndex((m) => String(m.event) === String(eventId)); + if (idx === -1) { + memberships.push({ + event: evOid, + status: 'confirmed', + checkedIn: false, + registrationDate: new Date(), + }); + } else { + memberships[idx] = { + ...memberships[idx], + event: evOid, + status: 'confirmed', + }; + } + p.eventMemberships = memberships; + + if (paymentLine && attendeeIndex === 0) { + p.payment = { + amount: minorToMajor(paymentLine.amountGrossMinor), + currency: paymentLine.currency, + status: 'paid', + transactionId: String(paymentLine.externalId), + paidAt: new Date(), + }; + } + await p.save(); + participantIds.push(p._id); + attendeeIndex += 1; + + if (registrationForm && ticket.registrationForm && registrationForm.settings?.isActive) { + const dataObj = buildMergedFieldData(registrationForm, attendee); + await FormSubmission.create({ + form: ticket.registrationForm, + tenantOrganization: orgOid, + event: evOid, + participant: p._id, + data: new Map(Object.entries(dataObj)), + status: 'submitted', + }); + } + } + + const listId = ticket.participantList; + if (listId && participantIds.length) { + await ParticipantList.updateOne( + { _id: listId, type: 'fixed', tenantOrganization: orgId }, + { $addToSet: { participantIds: { $each: participantIds } } }, + ); + } + + if (opts.transaction) { + const doc = await PaymentTransaction.findById(opts.transaction._id); + if (doc) { + const prevCtx = + doc.registrationContext && typeof doc.registrationContext === 'object' ? doc.registrationContext : {}; + doc.registrationContext = { + ...prevCtx, + fulfilledAt: new Date().toISOString(), + fulfilledParticipantIds: participantIds.map((id) => String(id)), + }; + if (participantIds[0]) doc.participant = participantIds[0]; + await doc.save(); + } + } + + return participantIds; +} + +/** + * After Razorpay payment is marked paid on tx. + * @param {import('mongoose').Document} tx - PaymentTransaction + */ +async function fulfillTicketPurchaseIfNeeded(tx) { + const ctx = tx.registrationContext && typeof tx.registrationContext === 'object' ? tx.registrationContext : null; + if (!ctx || ctx.kind !== 'ticket_purchase') return; + if (ctx.fulfilledAt) return; + + const eventId = ctx.eventId || tx.event; + const ids = await fulfillTicketPurchaseFromContext( + { ...ctx, eventId: String(eventId) }, + { + transaction: tx, + paymentLine: { + amountGrossMinor: tx.amountGross, + currency: tx.currency, + externalId: tx.externalPaymentId || String(tx._id), + }, + }, + ); + + if (ids.length) { + const ticket = await EventTicket.findById(ctx.ticketId).select('name').lean(); + void notifyTicketStakeholders({ + kind: 'online_paid', + eventId: String(eventId), + organizationId: String(tx.organization), + ticketName: ticket?.name, + purchaserEmail: ctx.purchaserEmail, + amountMinor: ctx.quote?.totalMinor ?? tx.amountSubtotal, + currency: tx.currency, + }); + } +} + +module.exports = { fulfillTicketPurchaseIfNeeded, fulfillTicketPurchaseFromContext }; diff --git a/src/services/ticketSaleNotify.js b/src/services/ticketSaleNotify.js new file mode 100644 index 0000000..a603470 --- /dev/null +++ b/src/services/ticketSaleNotify.js @@ -0,0 +1,105 @@ +const User = require('../models/User'); +const Event = require('../models/Event'); +const { sendOrgMail } = require('../utils/mail'); + +function escapeHtml(s) { + return String(s || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** + * Emails super_admin and org admin users (active, with email). + * @param {import('mongoose').Types.ObjectId|string} organizationId + * @returns {Promise} + */ +async function collectStakeholderEmails(organizationId) { + const users = await User.find({ + isActive: true, + email: { $exists: true, $nin: ['', null] }, + $or: [{ role: 'super_admin' }, { organization: organizationId, role: 'admin' }], + }) + .select('email') + .lean(); + return [...new Set(users.map((u) => String(u.email || '').trim().toLowerCase()).filter(Boolean))]; +} + +/** + * @param {object} p + * @param {string} p.eventId + * @param {string} p.organizationId + * @param {'online_paid'|'manual_submitted'|'manual_approved'|'manual_rejected'} p.kind + * @param {string} [p.ticketName] + * @param {string} [p.purchaserEmail] + * @param {number} [p.amountMinor] + * @param {string} [p.currency] + * @param {string} [p.orderId] - manual order id for review links + * @param {string} [p.rejectReason] + */ +async function notifyTicketStakeholders(p) { + const event = await Event.findById(p.eventId).select('name').lean(); + const eventName = event?.name || 'Event'; + const toList = await collectStakeholderEmails(p.organizationId); + if (!toList.length) { + console.log('[ticketSaleNotify] no stakeholder emails', p.eventId); + return; + } + + let subject = ''; + let html = ''; + + if (p.kind === 'online_paid') { + subject = `[EventBuzz] Ticket sale — ${eventName}`; + html = ` +

A ticket was purchased online for ${escapeHtml(eventName)}.

+
    +
  • Ticket: ${escapeHtml(p.ticketName || '—')}
  • +
  • Purchaser / receipt email: ${escapeHtml(p.purchaserEmail || '—')}
  • +
  • Amount: ${((p.amountMinor || 0) / 100).toFixed(2)} ${escapeHtml(p.currency || 'INR')}
  • +
+

Open the dashboard → Ticketing to review activity.

+ `; + } else if (p.kind === 'manual_submitted') { + subject = `[EventBuzz] UPI ticket proof pending review — ${eventName}`; + html = ` +

A buyer submitted a UPI payment screenshot for ${escapeHtml(eventName)}.

+
    +
  • Ticket: ${escapeHtml(p.ticketName || '—')}
  • +
  • Contact: ${escapeHtml(p.purchaserEmail || '—')}
  • +
  • Expected: ${((p.amountMinor || 0) / 100).toFixed(2)} ${escapeHtml(p.currency || 'INR')}
  • + ${p.orderId ? `
  • Order id: ${escapeHtml(p.orderId)}
  • ` : ''} +
+

Review and approve or reject in the dashboard → Ticketing → Manual UPI orders.

+ `; + } else if (p.kind === 'manual_approved') { + subject = `[EventBuzz] UPI ticket approved — ${eventName}`; + html = ` +

A manual UPI ticket order was approved for ${escapeHtml(eventName)}.

+
    +
  • Ticket: ${escapeHtml(p.ticketName || '—')}
  • +
  • Contact: ${escapeHtml(p.purchaserEmail || '—')}
  • +
  • Amount: ${((p.amountMinor || 0) / 100).toFixed(2)} ${escapeHtml(p.currency || 'INR')}
  • +
+ `; + } else if (p.kind === 'manual_rejected') { + subject = `[EventBuzz] UPI ticket rejected — ${eventName}`; + html = ` +

A manual UPI ticket order was rejected for ${escapeHtml(eventName)}.

+

Reason: ${escapeHtml(p.rejectReason || '—')}

+ `; + } + + for (const to of toList) { + void sendOrgMail({ + organizationId: p.organizationId, + to, + subject, + html, + text: html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(), + }); + } +} + +module.exports = { notifyTicketStakeholders, collectStakeholderEmails }; diff --git a/src/services/ticketingManualEnablement.service.js b/src/services/ticketingManualEnablement.service.js new file mode 100644 index 0000000..ea11b74 --- /dev/null +++ b/src/services/ticketingManualEnablement.service.js @@ -0,0 +1,87 @@ +const Event = require('../models/Event'); +const { createPaymentOrderCore } = require('./payments/createOrderCore'); +const { defaultManualEnablementFeeInrFromEnv } = require('../utils/manualTicketingFee'); + +const SUBSCRIPTION_PLAN_SENTINEL = 'manual_upi_enablement'; + +/** + * @param {import('mongoose').Types.ObjectId|string} organizationId + * @param {import('mongoose').Types.ObjectId|string} eventId + */ +async function assertEventBelongsToOrg(organizationId, eventId) { + const event = await Event.findById(eventId).select('organization ticketingManualPayment').lean(); + if (!event) { + const e = new Error('Event not found'); + e.code = 'NOT_FOUND'; + throw e; + } + if (String(event.organization) !== String(organizationId)) { + const e = new Error('Event not in organisation'); + e.code = 'FORBIDDEN'; + throw e; + } + return event; +} + +/** + * Razorpay order for one-time manual UPI enablement (billing / invoices). + * @param {{ organizationId: import('mongoose').Types.ObjectId, userId: import('mongoose').Types.ObjectId, eventId: import('mongoose').Types.ObjectId|string }} p + */ +async function createManualEnablementCheckoutOrder(p) { + const { organizationId, userId, eventId } = p; + const event = await assertEventBelongsToOrg(organizationId, eventId); + const mp = event.ticketingManualPayment || {}; + if (mp.enablementFeePaidAt) { + const e = new Error('Manual UPI enablement fee has already been paid for this event'); + e.code = 'ALREADY_PAID'; + throw e; + } + + const feeInr = defaultManualEnablementFeeInrFromEnv(); + if (feeInr <= 0) { + const e = new Error('Enablement fee is not configured'); + e.code = 'INVALID_CONFIG'; + throw e; + } + const amountPaise = Math.round(feeInr * 100); + + return createPaymentOrderCore({ + organizationId, + eventId, + participantId: null, + amountSubtotalMinor: amountPaise, + currency: 'INR', + idempotencyKey: `ticketing-manual-enable:${String(eventId)}`, + purpose: 'ticketing_manual_enablement', + subscriptionPlanId: SUBSCRIPTION_PLAN_SENTINEL, + purchasedBy: userId, + registrationContext: { kind: 'ticketing_manual_enablement', eventId: String(eventId) }, + }); +} + +/** + * After Razorpay verify — unlock manual UPI configuration for the event. + * @param {import('mongoose').Document} tx + */ +async function applyTicketingManualEnablementFromPayment(tx) { + if (tx.purpose !== 'ticketing_manual_enablement') return; + const ctx = tx.registrationContext && typeof tx.registrationContext === 'object' ? tx.registrationContext : null; + if (!ctx || ctx.kind !== 'ticketing_manual_enablement' || !ctx.eventId) return; + + const event = await Event.findById(ctx.eventId); + if (!event || String(event.organization) !== String(tx.organization)) return; + + const prev = event.ticketingManualPayment && typeof event.ticketingManualPayment === 'object' ? event.ticketingManualPayment : {}; + event.ticketingManualPayment = { + ...prev, + enablementFeePaidAt: new Date(), + enablementTransaction: tx._id, + }; + await event.save(); +} + +module.exports = { + createManualEnablementCheckoutOrder, + applyTicketingManualEnablementFromPayment, + SUBSCRIPTION_PLAN_SENTINEL, +}; diff --git a/src/services/whatsappTemplateMetaSync.js b/src/services/whatsappTemplateMetaSync.js new file mode 100644 index 0000000..5658fe7 --- /dev/null +++ b/src/services/whatsappTemplateMetaSync.js @@ -0,0 +1,59 @@ +const WhatsAppTemplate = require('../models/WhatsAppTemplate'); +const { getOrgWhatsAppCredentials, listMessageTemplates } = require('../utils/metaWhatsApp'); + +function normLang(s) { + return String(s || '') + .trim() + .replace(/-/g, '_') + .toLowerCase(); +} + +function rejectionFromMeta(m) { + if (!m || typeof m !== 'object') return ''; + return String(m.rejected_reason || m.reason || '').trim(); +} + +/** + * Pull template rows from Meta and update local docs (by metaTemplateId or name+language). + * @param {import('mongoose').Types.ObjectId|string} organizationId + */ +async function syncOrganizationTemplates(organizationId) { + const creds = await getOrgWhatsAppCredentials(organizationId); + if (!creds) { + return { ok: false, error: 'WhatsApp Cloud is not configured for this organisation (WABA + access token).' }; + } + + const data = await listMessageTemplates(creds.wabaId, creds.accessToken); + const list = Array.isArray(data.data) ? data.data : []; + const locals = await WhatsAppTemplate.find({ organization: organizationId }).lean(); + const now = new Date(); + + for (const loc of locals) { + let m = null; + if (loc.metaTemplateId) { + m = list.find((x) => String(x.id) === String(loc.metaTemplateId)); + } + if (!m) { + m = list.find((x) => x.name === loc.name && normLang(x.language) === normLang(loc.language)); + } + if (m) { + const st = m.status != null ? String(m.status).toUpperCase() : loc.status; + await WhatsAppTemplate.updateOne( + { _id: loc._id }, + { + $set: { + metaTemplateId: String(m.id), + status: st, + rejectionReason: rejectionFromMeta(m), + lastSyncedAt: now, + lastError: '', + }, + } + ); + } + } + + return { ok: true, metaCount: list.length }; +} + +module.exports = { syncOrganizationTemplates }; diff --git a/src/utils/aiEventMergeAndGaps.js b/src/utils/aiEventMergeAndGaps.js new file mode 100644 index 0000000..4a14d37 --- /dev/null +++ b/src/utils/aiEventMergeAndGaps.js @@ -0,0 +1,185 @@ +const { + EVENT_TYPES: AI_EVENT_TYPES, + EVENT_STATUS: AI_EVENT_STATUS, + LOCATION_TYPES: AI_LOCATION_TYPES, +} = require('./bedrockExtractEvent'); + +function defaultAiDateRange() { + const start = new Date(); + start.setUTCDate(start.getUTCDate() + 7); + start.setUTCHours(10, 0, 0, 0); + const end = new Date(start.getTime()); + end.setUTCHours(18, 0, 0, 0); + return { start, end }; +} + +/** + * Same merge as event.controller historical AI path (defaults + inference). + * @param {Record} ai raw Bedrock JSON + */ +function mergeAiExtractedPayload(ai) { + const { start: ds, end: de } = defaultAiDateRange(); + const norm = (v) => (typeof v === 'string' ? v.toLowerCase().trim() : ''); + const type = AI_EVENT_TYPES.includes(norm(ai.type)) ? norm(ai.type) : 'conference'; + const status = AI_EVENT_STATUS.includes(norm(ai.status)) ? norm(ai.status) : 'draft'; + + let name = typeof ai.name === 'string' ? ai.name.trim() : ''; + if (name.length < 3) { + const fromDesc = typeof ai.description === 'string' ? ai.description.trim().slice(0, 80) : ''; + name = (fromDesc || 'New event').slice(0, 100); + if (name.length < 3) name = 'New event'; + } + + let startDate = ai.startDate ? new Date(ai.startDate) : ds; + let endDate = ai.endDate ? new Date(ai.endDate) : de; + if (Number.isNaN(startDate.getTime())) startDate = ds; + if (Number.isNaN(endDate.getTime())) endDate = de; + if (endDate <= startDate) { + endDate = new Date(startDate.getTime() + 8 * 60 * 60 * 1000); + } + + const subtitle = typeof ai.subtitle === 'string' ? ai.subtitle : ''; + const description = typeof ai.description === 'string' ? ai.description : ''; + let location = typeof ai.location === 'string' ? ai.location.trim() : ''; + let virtualLink = typeof ai.virtualLink === 'string' ? ai.virtualLink.trim() : ''; + const tags = Array.isArray(ai.tags) ? ai.tags.filter((t) => typeof t === 'string') : []; + + let locationType; + const hasVenue = location.length > 0; + if (!hasVenue) { + locationType = 'virtual'; + location = ''; + } else { + const aiLoc = AI_LOCATION_TYPES.includes(norm(ai.locationType)) ? norm(ai.locationType) : 'physical'; + if (aiLoc === 'virtual' && !virtualLink) { + locationType = 'physical'; + } else { + locationType = aiLoc; + } + } + + return { + name, + subtitle, + description, + type, + status, + startDate, + endDate, + location, + locationType, + virtualLink, + tags, + }; +} + +function norm(v) { + return typeof v === 'string' ? v.toLowerCase().trim() : ''; +} + +function rawNameSufficient(raw) { + const n = typeof raw.name === 'string' ? raw.name.trim() : ''; + return n.length >= 3; +} + +function rawStartEndSufficient(raw) { + if (!raw.startDate || !raw.endDate) return false; + const sd = new Date(raw.startDate); + const ed = new Date(raw.endDate); + if (Number.isNaN(sd.getTime()) || Number.isNaN(ed.getTime())) return false; + if (ed <= sd) return false; + return true; +} + +function virtualLinkSufficient(link) { + const u = typeof link === 'string' ? link.trim() : ''; + if (!u) return false; + try { + // eslint-disable-next-line no-new + new URL(u.startsWith('http') ? u : `https://${u}`); + return true; + } catch { + return false; + } +} + +/** + * Infer physical | virtual | hybrid from raw only (no merge fallbacks). + * @returns {'physical'|'virtual'|'hybrid'|null} + */ +function inferLocationTypeFromRaw(raw) { + const loc = typeof raw.location === 'string' ? raw.location.trim() : ''; + const link = typeof raw.virtualLink === 'string' ? raw.virtualLink.trim() : ''; + const lt = norm(raw.locationType); + if (AI_LOCATION_TYPES.includes(lt)) return lt; + if (loc && link) return 'hybrid'; + if (loc) return 'physical'; + if (link) return 'virtual'; + return null; +} + +const PROMPTS = { + eventName: + "I couldn't infer a clear event title (at least 3 characters). What should we call this event?", + startEnd: + "I couldn't find a valid start and end date/time in your description. When does the event begin and end?", + locationType: 'Is this event physical, virtual, or hybrid?', + venue: 'What is the venue or address for the in-person part of this event?', + virtualLink: 'What is the meeting or stream URL (e.g. https://…)?', +}; + +/** + * Gaps for quick-create–aligned fields, from **raw** Bedrock output (before merge fallbacks hide omissions). + * @returns {{ id: string, prompt: string }[]} + */ +function computeQuickCreateGapsFromRawAi(raw) { + if (!raw || typeof raw !== 'object') { + return [ + { id: 'eventName', prompt: PROMPTS.eventName }, + { id: 'startEnd', prompt: PROMPTS.startEnd }, + { id: 'locationType', prompt: PROMPTS.locationType }, + ]; + } + + const gaps = []; + if (!rawNameSufficient(raw)) { + gaps.push({ id: 'eventName', prompt: PROMPTS.eventName }); + } + if (!rawStartEndSufficient(raw)) { + gaps.push({ id: 'startEnd', prompt: PROMPTS.startEnd }); + } + + const eff = inferLocationTypeFromRaw(raw); + if (eff === null) { + gaps.push({ id: 'locationType', prompt: PROMPTS.locationType }); + } else { + const loc = typeof raw.location === 'string' ? raw.location.trim() : ''; + const link = typeof raw.virtualLink === 'string' ? raw.virtualLink.trim() : ''; + if ((eff === 'physical' || eff === 'hybrid') && !loc) { + gaps.push({ id: 'venue', prompt: PROMPTS.venue }); + } + if ((eff === 'virtual' || eff === 'hybrid') && !virtualLinkSufficient(link)) { + gaps.push({ id: 'virtualLink', prompt: PROMPTS.virtualLink }); + } + } + + return gaps; +} + +/** + * Serialize merged payload for JSON (ISO dates). + */ +function serializeMergedPreviewForApi(merged) { + const o = { ...merged }; + if (o.startDate instanceof Date) o.startDate = o.startDate.toISOString(); + if (o.endDate instanceof Date) o.endDate = o.endDate.toISOString(); + return o; +} + +module.exports = { + defaultAiDateRange, + mergeAiExtractedPayload, + computeQuickCreateGapsFromRawAi, + serializeMergedPreviewForApi, + inferLocationTypeFromRaw, +}; diff --git a/src/utils/assertOrgBillingManager.js b/src/utils/assertOrgBillingManager.js new file mode 100644 index 0000000..75994b6 --- /dev/null +++ b/src/utils/assertOrgBillingManager.js @@ -0,0 +1,18 @@ +const Boom = require('@hapi/boom'); + +/** + * Org `super_admin` and `admin` may manage subscription checkout and invoices. + */ +async function assertOrgBillingManager(request, h) { + const user = request.auth?.credentials?.user; + if (!user) throw Boom.unauthorized('Not authenticated'); + const role = user.role; + if (!['super_admin', 'admin'].includes(role)) { + throw Boom.forbidden('Only organisation super administrators and administrators can manage billing'); + } + const orgId = user.organization?._id || user.organization; + if (!orgId) throw Boom.badRequest('No organisation'); + return h.continue; +} + +module.exports = { assertOrgBillingManager }; diff --git a/src/utils/assertPlatformAdmin.js b/src/utils/assertPlatformAdmin.js new file mode 100644 index 0000000..78fd125 --- /dev/null +++ b/src/utils/assertPlatformAdmin.js @@ -0,0 +1,11 @@ +const Boom = require('@hapi/boom'); + +async function assertPlatformAdmin(request, h) { + const u = request.auth?.credentials?.user; + if (!u || u.platform_admin !== 'yes') { + throw Boom.forbidden('Platform admin access required.'); + } + return h.continue; +} + +module.exports = { assertPlatformAdmin }; diff --git a/src/utils/badgeRecipients.js b/src/utils/badgeRecipients.js new file mode 100644 index 0000000..204ca86 --- /dev/null +++ b/src/utils/badgeRecipients.js @@ -0,0 +1,157 @@ +const mongoose = require('mongoose'); +const Participant = require('../models/Participant'); +const ParticipantList = require('../models/ParticipantList'); +const Event = require('../models/Event'); +const Session = require('../models/Session'); +const Speaker = require('../models/Speaker'); +const { flattenParticipantForEvent, flattenParticipantForCampaignList } = require('./participantMembership'); +const { rulesToQuery } = require('./participantListQuery'); + +const BATCH = 500; + +function hasBadgeName(flat) { + if (!flat) return false; + const n = [flat.firstName, flat.lastName, flat.displayName].map((x) => String(x || '').trim()).filter(Boolean); + if (n.length) return true; + return Boolean(String(flat.email || '').trim()); +} + +/** + * Speakers linked to any session for this event (faculty). + * @param {string|import('mongoose').Types.ObjectId} eventId + */ +async function resolveFacultyForEvent(eventId) { + const eid = new mongoose.Types.ObjectId(String(eventId)); + const sessions = await Session.find({ event: eid }).select('speakers').lean(); + const speakerIds = [...new Set(sessions.flatMap((s) => (s.speakers || []).map((sp) => String(sp))))].filter((id) => + mongoose.Types.ObjectId.isValid(id), + ); + if (!speakerIds.length) return []; + const speakers = await Speaker.find({ _id: { $in: speakerIds } }).lean(); + return speakers.map((s) => ({ kind: 'faculty', speaker: s, id: String(s._id) })); +} + +async function resolveAllParticipantsForBadges(tenantOrganization, eventId) { + const eid = new mongoose.Types.ObjectId(String(eventId)); + const toid = + tenantOrganization instanceof mongoose.Types.ObjectId + ? tenantOrganization + : new mongoose.Types.ObjectId(String(tenantOrganization)); + const match = { + tenantOrganization: toid, + eventMemberships: { $elemMatch: { event: eid } }, + }; + const out = []; + let skip = 0; + for (;;) { + const batch = await Participant.find(match).skip(skip).limit(BATCH).lean(); + if (!batch.length) break; + for (const p of batch) { + const flat = flattenParticipantForEvent(p, eventId); + if (flat && hasBadgeName(flat)) out.push({ kind: 'participant', participant: p, flat, id: String(p._id) }); + } + if (batch.length < BATCH) break; + skip += BATCH; + } + return out; +} + +async function resolveCustomListForBadges(ids, eventId, tenantOrganization) { + const oids = ids.filter((id) => mongoose.Types.ObjectId.isValid(String(id))).map((id) => new mongoose.Types.ObjectId(String(id))); + if (!oids.length) return []; + const q = { _id: { $in: oids } }; + if (tenantOrganization != null && mongoose.Types.ObjectId.isValid(String(tenantOrganization))) { + q.tenantOrganization = + tenantOrganization instanceof mongoose.Types.ObjectId + ? tenantOrganization + : new mongoose.Types.ObjectId(String(tenantOrganization)); + } + const raw = await Participant.find(q).lean(); + const out = []; + for (const p of raw) { + const flat = flattenParticipantForCampaignList(p, eventId) || flattenParticipantForEvent(p, eventId); + if (flat && hasBadgeName(flat)) out.push({ kind: 'participant', participant: p, flat, id: String(p._id) }); + } + return out; +} + +/** + * @param {string|import('mongoose').Types.ObjectId} listId + * @param {string|import('mongoose').Types.ObjectId} eventId + * @param {import('mongoose').Types.ObjectId[]} allowedEventIds + */ +async function resolveParticipantListForBadges(listId, eventId, allowedEventIds) { + const doc = await ParticipantList.findById(listId).lean(); + if (!doc) return { error: { code: 'NOT_FOUND', message: 'Contact list not found' } }; + const eventDoc = await Event.findById(eventId).select('organization').lean(); + if (eventDoc && doc.tenantOrganization?.toString() !== String(eventDoc.organization)) { + return { error: { code: 'FORBIDDEN', message: 'Contact list belongs to another organisation' } }; + } + const eidStr = String(eventId); + if (!allowedEventIds.some((id) => String(id) === eidStr)) { + return { error: { code: 'FORBIDDEN', message: 'List scope does not include this event' } }; + } + + if (doc.type === 'fixed') { + const idList = (doc.participantIds || []).map((id) => String(id)); + return { rows: await resolveCustomListForBadges(idList, eventId, doc.tenantOrganization) }; + } + + const rulesPayload = doc.rules && typeof doc.rules === 'object' ? doc.rules : {}; + const rules = rulesPayload.rules || []; + const conjunction = rulesPayload.conjunction || 'AND'; + const match = rulesToQuery(rules, conjunction, allowedEventIds, doc.tenantOrganization); + const andEvent = { + $and: [ + match, + { eventMemberships: { $elemMatch: { event: new mongoose.Types.ObjectId(String(eventId)) } } }, + ], + }; + const out = []; + let skip = 0; + for (;;) { + const batch = await Participant.find(andEvent).skip(skip).limit(BATCH).lean(); + if (!batch.length) break; + for (const p of batch) { + const flat = flattenParticipantForEvent(p, eventId); + if (flat && hasBadgeName(flat)) out.push({ kind: 'participant', participant: p, flat, id: String(p._id) }); + } + if (batch.length < BATCH) break; + skip += BATCH; + } + return { rows: out }; +} + +/** + * @param {object} config — BadgeBatch.config plain object + * @param {import('mongoose').Types.ObjectId|string} eventId + * @param {import('mongoose').Types.ObjectId|string} tenantOrganization — event.organization + * @param {import('mongoose').Types.ObjectId[]} allowedEventIds + */ +async function resolveBadgeRecipients(config, eventId, tenantOrganization, allowedEventIds) { + const rows = []; + if (config.includeFaculty) { + const faculty = await resolveFacultyForEvent(eventId); + rows.push(...faculty); + } + if (config.includeParticipants) { + if (config.participantScope === 'participant_list') { + if (!config.participantListId) { + return { error: { code: 'VALIDATION_ERROR', message: 'participantListId required for participant_list scope' } }; + } + const r = await resolveParticipantListForBadges(config.participantListId, eventId, allowedEventIds); + if (r.error) return r; + rows.push(...(r.rows || [])); + } else { + const part = await resolveAllParticipantsForBadges(tenantOrganization, eventId); + rows.push(...part); + } + } + return { rows }; +} + +module.exports = { + resolveFacultyForEvent, + resolveBadgeRecipients, + hasBadgeName, +}; diff --git a/src/utils/badgeRender.js b/src/utils/badgeRender.js new file mode 100644 index 0000000..e6e0dc1 --- /dev/null +++ b/src/utils/badgeRender.js @@ -0,0 +1,330 @@ +const { createCanvas, loadImage } = require('@napi-rs/canvas'); +const QRCode = require('qrcode'); +const sharp = require('sharp'); +const { PDFDocument } = require('pdf-lib'); + +const PRINT_DPI = 200; + +function mmToPx(mm) { + return Math.max(80, Math.round((mm * PRINT_DPI) / 25.4)); +} + +/** + * @param {object} config — BadgeBatch.config + * @returns {{ widthMm: number, heightMm: number, widthPx: number, heightPx: number }} + */ +function getBadgeDimensions(config) { + const presets = { + a6: { w: 105, h: 148 }, + a7: { w: 74, h: 105 }, + '4x3in': { w: 101.6, h: 76.2 }, + '4x6in': { w: 101.6, h: 152.4 }, + }; + let w; + let h; + if (config.sizePreset === 'custom') { + w = Number(config.widthMm) || 105; + h = Number(config.heightMm) || 148; + } else { + const p = presets[config.sizePreset] || presets.a6; + w = p.w; + h = p.h; + } + if (config.orientation === 'landscape') { + [w, h] = [h, w]; + } + return { + widthMm: w, + heightMm: h, + widthPx: mmToPx(w), + heightPx: mmToPx(h), + }; +} + +function displayNameFromRow(row) { + if (row.kind === 'faculty') { + const s = row.speaker; + return [s.firstName, s.lastName].filter(Boolean).join(' ').trim() || 'Speaker'; + } + const f = row.flat; + if (f.displayName) return String(f.displayName).trim(); + return [f.firstName, f.lastName].filter(Boolean).join(' ').trim() || String(f.email || 'Guest'); +} + +function titleFromRow(row) { + if (row.kind === 'faculty') return String(row.speaker.title || row.speaker.speakerRole || '').trim(); + return String(row.flat.designation || '').trim(); +} + +function orgFromRow(row) { + if (row.kind === 'faculty') return String(row.speaker.organizationName || '').trim(); + return String(row.flat.organization || '').trim(); +} + +function badgeTypeFromRow(row, config) { + if (row.kind === 'faculty') return String(row.speaker.speakerRole || 'Faculty').trim(); + return String(config.badgeTypeLabel || 'Attendee').trim(); +} + +function photoUrlFromRow(row) { + if (row.kind === 'faculty' && row.speaker.photo) return String(row.speaker.photo).trim(); + return ''; +} + +async function fetchImageBuffer(url) { + const u = String(url || '').trim(); + if (!u.startsWith('http://') && !u.startsWith('https://')) return null; + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 20000); + try { + const res = await fetch(u, { signal: ctrl.signal }); + if (!res.ok) return null; + return Buffer.from(await res.arrayBuffer()); + } catch { + return null; + } finally { + clearTimeout(t); + } +} + +function slug(s) { + return String(s || 'x') + .replace(/[^a-zA-Z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 40) || 'x'; +} + +/** + * @returns {Promise} PNG + */ +async function renderBadgePng(row, config, dims) { + const { widthPx, heightPx } = dims; + const canvas = createCanvas(widthPx, heightPx); + const ctx = canvas.getContext('2d'); + + if (config.backgroundMode === 'image' && config.backgroundImageUrl) { + const buf = await fetchImageBuffer(config.backgroundImageUrl); + if (buf) { + try { + const img = await loadImage(buf); + const scale = Math.max(widthPx / img.width, heightPx / img.height); + const dw = img.width * scale; + const dh = img.height * scale; + const dx = (widthPx - dw) / 2; + const dy = (heightPx - dh) / 2; + ctx.drawImage(img, dx, dy, dw, dh); + } catch { + ctx.fillStyle = '#f1f5f9'; + ctx.fillRect(0, 0, widthPx, heightPx); + } + } else { + ctx.fillStyle = '#f1f5f9'; + ctx.fillRect(0, 0, widthPx, heightPx); + } + } else { + ctx.fillStyle = config.backgroundColor || '#0B4DD1'; + ctx.fillRect(0, 0, widthPx, heightPx); + } + + const fields = new Set(config.fields || []); + const pad = Math.round(widthPx * 0.06); + let textColor = '#0f172a'; + if (config.backgroundMode === 'color') { + const hex = (config.backgroundColor || '#000').replace('#', ''); + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + textColor = lum > 0.55 ? '#0f172a' : '#ffffff'; + } + + let cursorY = pad + 8; + const maxTextW = widthPx - pad * 2 - (fields.has('photo') && photoUrlFromRow(row) ? Math.round(widthPx * 0.22) : 0); + + ctx.fillStyle = textColor; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + + if (fields.has('photo')) { + const purl = photoUrlFromRow(row); + if (purl) { + const pbuf = await fetchImageBuffer(purl); + if (pbuf) { + try { + const ph = await loadImage(pbuf); + const r = Math.round(widthPx * 0.18); + const cx = widthPx - pad - r; + const cy = pad + r; + ctx.save(); + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.closePath(); + ctx.clip(); + ctx.drawImage(ph, cx - r, cy - r, r * 2, r * 2); + ctx.restore(); + } catch { + /* skip */ + } + } + } + } + + if (fields.has('name')) { + ctx.font = `bold ${Math.round(widthPx * 0.07)}px sans-serif`; + const name = displayNameFromRow(row); + wrapDraw(ctx, name, pad, cursorY, maxTextW, Math.round(widthPx * 0.065), textColor); + cursorY += lineBlockHeight(ctx, name, maxTextW, Math.round(widthPx * 0.065)) + 6; + } + + if (fields.has('title')) { + const t = titleFromRow(row); + if (t) { + ctx.font = `${Math.round(widthPx * 0.045)}px sans-serif`; + wrapDraw(ctx, t, pad, cursorY, maxTextW, Math.round(widthPx * 0.042), textColor); + cursorY += lineBlockHeight(ctx, t, maxTextW, Math.round(widthPx * 0.042)) + 4; + } + } + + if (fields.has('org')) { + const o = orgFromRow(row); + if (o) { + ctx.font = `${Math.round(widthPx * 0.04)}px sans-serif`; + wrapDraw(ctx, o, pad, cursorY, maxTextW, Math.round(widthPx * 0.038), textColor); + cursorY += lineBlockHeight(ctx, o, maxTextW, Math.round(widthPx * 0.038)) + 4; + } + } + + if (fields.has('badge_type')) { + const bt = badgeTypeFromRow(row, config); + ctx.font = `${Math.round(widthPx * 0.035)}px sans-serif`; + const tw = ctx.measureText(bt).width; + const boxH = Math.round(widthPx * 0.055); + ctx.fillStyle = textColor === '#ffffff' ? 'rgba(255,255,255,0.25)' : 'rgba(15,23,42,0.12)'; + ctx.fillRect(pad, cursorY, tw + 16, boxH); + ctx.fillStyle = textColor; + ctx.fillText(bt, pad + 8, cursorY + Math.round(widthPx * 0.012)); + cursorY += Math.round(widthPx * 0.07); + } + + if (fields.has('qr')) { + const data = `eventbuzz:${row.kind}:${row.id}`; + try { + const qrBuf = await QRCode.toBuffer(data, { type: 'png', width: Math.min(320, Math.round(widthPx * 0.35)), margin: 1 }); + const qrImg = await loadImage(qrBuf); + const qw = Math.round(widthPx * 0.28); + const qh = Math.round((qrImg.height / qrImg.width) * qw); + const qx = Math.round((widthPx - qw) / 2); + const qy = heightPx - qh - pad; + ctx.drawImage(qrImg, qx, qy, qw, qh); + } catch { + /* skip qr */ + } + } + + return canvas.toBuffer('image/png'); +} + +function wrapDraw(ctx, text, x, y, maxW, lineHeight, color) { + ctx.fillStyle = color; + const words = String(text).split(/\s+/); + let line = ''; + let yy = y; + for (let n = 0; n < words.length; n += 1) { + const test = line + words[n] + ' '; + if (ctx.measureText(test).width > maxW && n > 0) { + ctx.fillText(line, x, yy); + line = `${words[n]} `; + yy += lineHeight; + } else { + line = test; + } + } + ctx.fillText(line, x, yy); +} + +function lineBlockHeight(ctx, text, maxW, lineHeight) { + const words = String(text).split(/\s+/); + let line = ''; + let lines = 1; + for (let n = 0; n < words.length; n += 1) { + const test = line + words[n] + ' '; + if (ctx.measureText(test).width > maxW && n > 0) { + lines += 1; + line = `${words[n]} `; + } else { + line = test; + } + } + return lines * lineHeight; +} + +async function pngToJpeg(pngBuf) { + return sharp(pngBuf).jpeg({ quality: 88 }).toBuffer(); +} + +async function buildMultiPagePdf(pngBuffers) { + const pdf = await PDFDocument.create(); + for (const pngBuf of pngBuffers) { + const img = await pdf.embedPng(pngBuf); + const page = pdf.addPage([img.width, img.height]); + page.drawImage(img, { x: 0, y: 0, width: img.width, height: img.height }); + } + return Buffer.from(await pdf.save()); +} + +/** + * @param {object[]} rows — from resolveBadgeRecipients + * @param {object} config + * @param {'pdf'|'png'|'jpeg'} outputFormat + * @returns {Promise<{ zipBuffer: Buffer, itemCount: number }>} + */ +async function buildBadgeZip(rows, config, outputFormat) { + const dims = getBadgeDimensions(config); + const archiver = require('archiver'); + const pngBuffers = []; + + const files = []; + let idx = 0; + for (const row of rows) { + idx += 1; + const png = await renderBadgePng(row, config, dims); + pngBuffers.push(png); + const base = `${row.kind}-${slug(displayNameFromRow(row))}-${row.id}`.slice(0, 120); + if (outputFormat === 'png') { + files.push({ name: `${base}.png`, buffer: png }); + } else if (outputFormat === 'jpeg') { + files.push({ name: `${base}.jpg`, buffer: await pngToJpeg(png) }); + } + } + + let pdfBuffer = null; + if (outputFormat === 'pdf') { + pdfBuffer = await buildMultiPagePdf(pngBuffers); + } + + const chunks = []; + const archive = archiver('zip', { zlib: { level: 6 } }); + archive.on('data', (c) => chunks.push(c)); + const zipped = new Promise((resolve, reject) => { + archive.on('end', () => resolve(Buffer.concat(chunks))); + archive.on('error', reject); + }); + + if (outputFormat === 'pdf' && pdfBuffer) { + archive.append(pdfBuffer, { name: 'badges.pdf' }); + } else { + for (const f of files) { + archive.append(f.buffer, { name: f.name }); + } + } + await archive.finalize(); + const zipBuffer = await zipped; + return { zipBuffer, itemCount: rows.length }; +} + +module.exports = { + getBadgeDimensions, + renderBadgePng, + buildBadgeZip, + mmToPx, +}; diff --git a/src/utils/bedrockClient.js b/src/utils/bedrockClient.js new file mode 100644 index 0000000..c1a9e61 --- /dev/null +++ b/src/utils/bedrockClient.js @@ -0,0 +1,87 @@ +const { BedrockRuntimeClient } = require('@aws-sdk/client-bedrock-runtime'); + +const DEFAULT_BEDROCK_MODEL_ID = 'meta.llama3-8b-instruct-v1:0'; +const DEFAULT_BEDROCK_EMBED_MODEL_ID = 'amazon.titan-embed-text-v2:0'; + +const REGION_FALLBACK_MODEL_IDS = [ + 'meta.llama3-8b-instruct-v1:0', + 'meta.llama3-70b-instruct-v1:0', +]; + +const REGION_FALLBACK_EMBED_MODEL_IDS = [ + 'amazon.titan-embed-text-v2:0', + 'amazon.titan-embed-text-v1:0', + 'amazon.titan-embed-text-v1', +]; + +function createBedrockClient(region) { + const config = { region }; + if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) { + config.credentials = { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + ...(process.env.AWS_SESSION_TOKEN + ? { sessionToken: process.env.AWS_SESSION_TOKEN } + : {}), + }; + } + return new BedrockRuntimeClient(config); +} + +function getBedrockConfig() { + const region = + process.env.BEDROCK_REGION || + process.env.AWS_DEFAULT_REGION || + process.env.AWS_REGION || + 'ap-south-1'; + const modelId = process.env.BEDROCK_MODEL_ID || DEFAULT_BEDROCK_MODEL_ID; + const embedModelId = process.env.BEDROCK_EMBED_MODEL_ID || DEFAULT_BEDROCK_EMBED_MODEL_ID; + return { region, modelId, embedModelId }; +} + +function buildModelIdCandidates(primaryId) { + const fromEnv = (process.env.BEDROCK_MODEL_FALLBACKS || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const chain = [primaryId, ...fromEnv, ...REGION_FALLBACK_MODEL_IDS]; + return [...new Set(chain)]; +} + +function buildEmbedModelIdCandidates(primaryId) { + const fromEnv = (process.env.BEDROCK_EMBED_MODEL_FALLBACKS || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const chain = [primaryId, ...fromEnv, ...REGION_FALLBACK_EMBED_MODEL_IDS]; + return [...new Set(chain)]; +} + +function isInvalidModelError(err) { + const name = err.name || err.Code || ''; + const msg = String(err.message || '').toLowerCase(); + if (name === 'ResourceNotFoundException') { + return ( + msg.includes('could not resolve the foundation model') || + msg.includes('model identifier is invalid') + ); + } + return ( + name === 'ValidationException' && + (msg.includes('model identifier is invalid') || + msg.includes('model id') || + msg.includes("on-demand throughput isn't supported")) + ); +} + +module.exports = { + createBedrockClient, + getBedrockConfig, + buildModelIdCandidates, + buildEmbedModelIdCandidates, + isInvalidModelError, + DEFAULT_BEDROCK_MODEL_ID, + DEFAULT_BEDROCK_EMBED_MODEL_ID, + REGION_FALLBACK_MODEL_IDS, + REGION_FALLBACK_EMBED_MODEL_IDS, +}; diff --git a/src/utils/bedrockConverse.js b/src/utils/bedrockConverse.js new file mode 100644 index 0000000..babaaa6 --- /dev/null +++ b/src/utils/bedrockConverse.js @@ -0,0 +1,37 @@ +const { ConverseCommand } = require('@aws-sdk/client-bedrock-runtime'); + +function responseText(response) { + return ( + response.output?.message?.content + ?.map((block) => block.text) + .filter(Boolean) + .join('\n') || '' + ); +} + +/** + * @param {import('@aws-sdk/client-bedrock-runtime').BedrockRuntimeClient} client + * @param {string} modelId + * @param {{ system?: { text: string }[], messages: object[], inferenceConfig?: object }} opts + */ +async function converse(client, modelId, opts) { + const { system, messages, inferenceConfig } = opts; + return client.send( + new ConverseCommand({ + modelId, + ...(system && system.length ? { system } : {}), + messages, + inferenceConfig: { + maxTokens: 2048, + temperature: 0.3, + topP: 0.9, + ...inferenceConfig, + }, + }), + ); +} + +module.exports = { + converse, + responseText, +}; diff --git a/src/utils/bedrockEmbed.js b/src/utils/bedrockEmbed.js new file mode 100644 index 0000000..69c485f --- /dev/null +++ b/src/utils/bedrockEmbed.js @@ -0,0 +1,125 @@ +const { InvokeModelCommand } = require('@aws-sdk/client-bedrock-runtime'); +const { + createBedrockClient, + getBedrockConfig, + buildEmbedModelIdCandidates, + isInvalidModelError, +} = require('./bedrockClient'); + +/** @type {string | null} */ +let resolvedEmbedModelId = null; + +function buildEmbedRequestBody(modelId, inputText) { + const body = { inputText }; + if (modelId.includes('titan-embed-text-v2')) { + const dimensions = Number(process.env.BEDROCK_EMBED_DIMENSIONS) || 1024; + body.dimensions = dimensions; + body.normalize = true; + } + return JSON.stringify(body); +} + +/** + * @param {import('@aws-sdk/client-bedrock-runtime').BedrockRuntimeClient} client + * @param {string} modelId + * @param {string} inputText + * @returns {Promise} + */ +async function invokeEmbed(client, modelId, inputText) { + const response = await client.send( + new InvokeModelCommand({ + modelId, + contentType: 'application/json', + accept: 'application/json', + body: buildEmbedRequestBody(modelId, inputText), + }), + ); + const raw = new TextDecoder().decode(response.body); + const parsed = JSON.parse(raw); + const vector = parsed.embedding; + if (!Array.isArray(vector)) { + throw new Error('Bedrock embed response missing embedding array'); + } + return vector; +} + +/** + * @param {import('@aws-sdk/client-bedrock-runtime').BedrockRuntimeClient} client + * @param {string} inputText + * @param {string} configuredId + * @returns {Promise<{ vector: number[], modelId: string }>} + */ +async function embedOneWithFallback(client, inputText, configuredId) { + if (resolvedEmbedModelId) { + const vector = await invokeEmbed(client, resolvedEmbedModelId, inputText); + return { vector, modelId: resolvedEmbedModelId }; + } + + const candidates = buildEmbedModelIdCandidates(configuredId); + let lastError; + + for (const modelId of candidates) { + try { + const vector = await invokeEmbed(client, modelId, inputText); + resolvedEmbedModelId = modelId; + if (modelId !== configuredId) { + const { region } = getBedrockConfig(); + console.warn( + `[bedrock] BEDROCK_EMBED_MODEL_ID "${configuredId}" unavailable in ${region}; using "${modelId}"`, + ); + } + return { vector, modelId }; + } catch (err) { + lastError = err; + if (!isInvalidModelError(err)) { + throw err; + } + } + } + + const { region } = getBedrockConfig(); + const tried = candidates.join(', '); + const hint = + ` Enable Titan Embeddings in Bedrock (${region}). Tried: ${tried}.`; + const err = lastError || new Error('Bedrock embed request failed'); + const name = err.name || err.Code || 'BedrockError'; + throw new Error(`${name}: ${err.message || 'Bedrock embed request failed'}${hint}`); +} + +/** + * @param {string[]} texts + * @returns {Promise<{ embeddings: number[][], modelId: string }>} + */ +async function embedTexts(texts) { + const list = (texts || []).map((t) => String(t || '').trim()).filter(Boolean); + const { region, embedModelId } = getBedrockConfig(); + if (!list.length) { + return { embeddings: [], modelId: resolvedEmbedModelId || embedModelId }; + } + + const client = createBedrockClient(region); + const embeddings = []; + let modelId = resolvedEmbedModelId || embedModelId; + + for (const inputText of list) { + const result = await embedOneWithFallback(client, inputText, embedModelId); + embeddings.push(result.vector); + modelId = result.modelId; + } + + return { embeddings, modelId }; +} + +/** + * @param {string} text + * @returns {Promise} + */ +async function embedText(text) { + const { embeddings } = await embedTexts([text]); + return embeddings[0] || []; +} + +module.exports = { + embedTexts, + embedText, +}; diff --git a/src/utils/bedrockExtractEvent.js b/src/utils/bedrockExtractEvent.js index 14a98b4..ce05e7c 100644 --- a/src/utils/bedrockExtractEvent.js +++ b/src/utils/bedrockExtractEvent.js @@ -1,22 +1,141 @@ -const { BedrockRuntimeClient, InvokeModelCommand } = require('@aws-sdk/client-bedrock-runtime'); +const { + createBedrockClient, + getBedrockConfig, + buildModelIdCandidates, + isInvalidModelError, + DEFAULT_BEDROCK_MODEL_ID, + REGION_FALLBACK_MODEL_IDS, +} = require('./bedrockClient'); +const { converse, responseText } = require('./bedrockConverse'); const EVENT_TYPES = ['conference', 'workshop', 'summit', 'cme', 'webinar']; const EVENT_STATUS = ['draft', 'upcoming', 'live', 'completed']; const LOCATION_TYPES = ['physical', 'virtual', 'hybrid']; +const JSON_EXAMPLE = `{ + "name": "Annual Tech Summit", + "subtitle": "Innovation and networking", + "description": "Full description here", + "type": "conference", + "status": "draft", + "startDate": null, + "endDate": null, + "location": null, + "locationType": "virtual", + "virtualLink": null, + "tags": [] +}`; + +/** Strip markdown fences and try every balanced `{...}` substring. */ function extractJsonObject(text) { if (!text || typeof text !== 'string') return null; - const trimmed = text.trim(); - const fence = /^```(?:json)?\s*([\s\S]*?)```$/im.exec(trimmed); - const body = fence ? fence[1].trim() : trimmed; - const start = body.indexOf('{'); - const end = body.lastIndexOf('}'); - if (start === -1 || end <= start) return null; - try { - return JSON.parse(body.slice(start, end + 1)); - } catch { - return null; + + let body = text.trim(); + const fenced = [...body.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)]; + if (fenced.length) { + body = fenced.map((m) => m[1].trim()).join('\n'); } + + const candidates = []; + let depth = 0; + let start = -1; + for (let i = 0; i < body.length; i += 1) { + const ch = body[i]; + if (ch === '{') { + if (depth === 0) start = i; + depth += 1; + } else if (ch === '}') { + depth -= 1; + if (depth === 0 && start >= 0) { + candidates.push(body.slice(start, i + 1)); + start = -1; + } + } + } + + if (!candidates.length && body.includes('{')) { + const s = body.indexOf('{'); + const e = body.lastIndexOf('}'); + if (e > s) candidates.push(body.slice(s, e + 1)); + } + + candidates.sort((a, b) => b.length - a.length); + + for (const raw of candidates) { + const attempts = [ + raw, + raw.replace(/,\s*([}\]])/g, '$1'), + raw.replace(/'/g, '"'), + ]; + for (const attempt of attempts) { + try { + const parsed = JSON.parse(attempt); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed; + } + } catch { + // try next + } + } + } + return null; +} + +function buildSystemPrompt() { + return `You are a JSON-only API. Output exactly one JSON object and nothing else: no markdown, no code fences, no explanation before or after. + +Required shape (copy this structure; use null for unknown dates/fields): +${JSON_EXAMPLE} + +Rules: +- "type" must be one of: ${EVENT_TYPES.join(', ')} +- "status" must be one of: ${EVENT_STATUS.join(', ')} or omit +- "locationType" must be one of: ${LOCATION_TYPES.join(', ')} +- "name" must be at least 3 characters +- Use null for startDate/endDate unless the user gave dates (ISO 8601 UTC strings when set) +- "tags" is a JSON array of strings`; +} + +async function invokeModelForJson(client, modelId, userDescription) { + const messages = [ + { + role: 'user', + content: [{ text: `Extract event fields from this description:\n\n${userDescription}` }], + }, + ]; + + let response = await converse(client, modelId, { + system: [{ text: buildSystemPrompt() }], + messages, + inferenceConfig: { temperature: 0 }, + }); + let text = responseText(response); + let parsed = extractJsonObject(text); + + if (!parsed) { + messages.push({ + role: 'assistant', + content: [{ text: text.slice(0, 4000) }], + }); + messages.push({ + role: 'user', + content: [ + { + text: + 'Your previous reply was not valid JSON. Reply again with ONLY a single JSON object matching the required shape. No markdown, no extra words.', + }, + ], + }); + response = await converse(client, modelId, { + system: [{ text: buildSystemPrompt() }], + messages, + inferenceConfig: { temperature: 0 }, + }); + text = responseText(response); + parsed = extractJsonObject(text); + } + + return { parsed, text }; } /** @@ -24,55 +143,59 @@ function extractJsonObject(text) { * @returns {Promise>} */ async function extractEventFieldsFromDescription(userDescription) { - const region = process.env.AWS_DEFAULT_REGION || process.env.AWS_REGION || 'ap-south-1'; - const modelId = - process.env.BEDROCK_MODEL_ID || 'anthropic.claude-3-haiku-20240307-v1:0'; + const { region, modelId: configuredId } = getBedrockConfig(); + const modelCandidates = buildModelIdCandidates(configuredId); + const client = createBedrockClient(region); - const systemPrompt = `You extract structured event data from free-form text. Reply with ONLY a single JSON object, no markdown, no explanation. -Keys (use null for unknown optional fields): -- name: string, required, at least 3 characters, event title -- subtitle: string or null, short tagline -- description: string or null, longer details -- type: one of ${JSON.stringify(EVENT_TYPES)} -- status: one of ${JSON.stringify(EVENT_STATUS)} or omit (server defaults to draft) -- startDate: ISO8601 date-time string (UTC) for event start -- endDate: ISO 8601 date-time string (UTC) for event end, must be after startDate -- location: string or null, venue or city -- locationType: one of ${JSON.stringify(LOCATION_TYPES)} -- virtualLink: string or null, URL if virtual/hybrid -- tags: string array or omit. + let lastError; + let usedModelId; + let lastRawText = ''; -Infer reasonable dates only if the user mentions dates; otherwise use null for startDate and endDate so defaults apply.`; - - const client = new BedrockRuntimeClient({ region }); - const body = JSON.stringify({ - anthropic_version: 'bedrock-2023-05-31', - max_tokens: 2048, - messages: [ - { - role: 'user', - content: [{ type: 'text', text: `${systemPrompt}\n\nUser description:\n${userDescription}` }], - }, - ], - }); - - const out = await client.send( - new InvokeModelCommand({ - modelId, - contentType: 'application/json', - accept: 'application/json', - body: Buffer.from(body, 'utf8'), - }), - ); - - const raw = new TextDecoder().decode(out.body); - const decoded = JSON.parse(raw); - const text = decoded.content?.[0]?.text; - const parsed = extractJsonObject(text || ''); - if (!parsed || typeof parsed !== 'object') { - throw new Error('Model did not return valid JSON'); + for (const modelId of modelCandidates) { + try { + const { parsed, text } = await invokeModelForJson(client, modelId, userDescription); + lastRawText = text; + if (parsed) { + usedModelId = modelId; + if (usedModelId !== configuredId) { + console.warn( + `[bedrock] BEDROCK_MODEL_ID "${configuredId}" unavailable in ${region}; used "${usedModelId}"`, + ); + } + return parsed; + } + lastError = new Error('Model did not return valid JSON'); + } catch (err) { + lastError = err; + if (!isInvalidModelError(err)) { + break; + } + } } - return parsed; + + if (process.env.NODE_ENV !== 'production' && lastRawText) { + console.warn('[bedrock] Raw model output (truncated):', lastRawText.slice(0, 800)); + } + + const err = lastError || new Error('Bedrock request failed'); + const name = err.name || err.Code || 'BedrockError'; + if (name === 'BedrockError' && err.message === 'Model did not return valid JSON') { + throw err; + } + const tried = modelCandidates.join(', '); + const hint = + name === 'AccessDeniedException' || name === 'ValidationException' + ? ` Enable a Meta Llama model in Bedrock (${region}). Tried: ${tried}.` + : ''; + throw new Error(`${name}: ${err.message || 'Bedrock request failed'}${hint}`); } -module.exports = { extractEventFieldsFromDescription, EVENT_TYPES, EVENT_STATUS, LOCATION_TYPES }; +module.exports = { + extractEventFieldsFromDescription, + EVENT_TYPES, + EVENT_STATUS, + LOCATION_TYPES, + DEFAULT_BEDROCK_MODEL_ID, + REGION_FALLBACK_MODEL_IDS, + extractJsonObject, +}; diff --git a/src/utils/campaignContent.js b/src/utils/campaignContent.js new file mode 100644 index 0000000..b18d0c3 --- /dev/null +++ b/src/utils/campaignContent.js @@ -0,0 +1,10 @@ +/** + * @param {import('mongoose').Document} campaignDoc + */ +function getCampaignContentObject(campaignDoc) { + const c = campaignDoc.content; + if (c && typeof c.toObject === 'function') return c.toObject(); + return c && typeof c === 'object' ? { ...c } : {}; +} + +module.exports = { getCampaignContentObject }; diff --git a/src/utils/campaignMerge.js b/src/utils/campaignMerge.js new file mode 100644 index 0000000..af57f55 --- /dev/null +++ b/src/utils/campaignMerge.js @@ -0,0 +1,103 @@ +/** + * Placeholders for email campaigns. Use {{contact.x}} and {{event.x}}. + */ +const PLACEHOLDER_HELP = { + '{{contact.firstName}}': 'Participant first name', + '{{contact.lastName}}': 'Participant last name', + '{{contact.name}}': 'Full name', + '{{contact.email}}': 'Email', + '{{contact.organization}}': 'Company / organization (text)', + '{{contact.phone}}': 'Phone', + '{{event.name}}': 'Event name', + '{{event.startDate}}': 'Event start (localized string)', + '{{event.endDate}}': 'Event end (localized string)', + '{{event.venue}}': 'Location / venue', + '{{event.virtualLink}}': 'Virtual link', +}; + +function formatDate(d) { + if (!d) return ''; + try { + return new Date(d).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }); + } catch { + return String(d); + } +} + +/** + * @param {object} participant — lean Participant + optional event slice + * @param {object} event — lean Event + */ +function buildVars(participant, event) { + const p = participant || {}; + const e = event || {}; + const first = p.firstName || ''; + const last = p.lastName || ''; + const full = [first, last].filter(Boolean).join(' ').trim() || p.displayName || ''; + return { + contact: { + firstName: first, + lastName: last, + name: full, + email: p.email || '', + organization: p.organization || '', + phone: p.phone || '', + }, + event: { + name: e.name || '', + startDate: formatDate(e.startDate), + endDate: formatDate(e.endDate), + venue: e.location || '', + virtualLink: e.virtualLink || '', + }, + }; +} + +/** + * Replace {{a.b}} tokens from flat nested vars object. + * @param {string} text + * @param {ReturnType} vars + */ +function mergeText(text, vars) { + if (text == null) return ''; + let out = String(text); + const flat = { + 'contact.firstName': vars.contact.firstName, + 'contact.lastName': vars.contact.lastName, + 'contact.name': vars.contact.name, + 'contact.email': vars.contact.email, + 'contact.organization': vars.contact.organization, + 'contact.phone': vars.contact.phone, + 'event.name': vars.event.name, + 'event.startDate': vars.event.startDate, + 'event.endDate': vars.event.endDate, + 'event.venue': vars.event.venue, + 'event.virtualLink': vars.event.virtualLink, + }; + for (const [k, v] of Object.entries(flat)) { + const re = new RegExp(`\\{\\{\\s*${k.replace(/\./g, '\\.')}\\s*\\}\\}`, 'gi'); + out = out.replace(re, v == null ? '' : String(v)); + } + return out; +} + +/** + * @param {string} subject + * @param {string} bodyHtml + * @param {object} participant + * @param {object} event + */ +function mergeCampaignContent(subject, bodyHtml, participant, event) { + const vars = buildVars(participant, event); + return { + subject: mergeText(subject, vars), + body: mergeText(bodyHtml, vars), + }; +} + +module.exports = { + PLACEHOLDER_HELP, + buildVars, + mergeText, + mergeCampaignContent, +}; diff --git a/src/utils/campaignRecipients.js b/src/utils/campaignRecipients.js new file mode 100644 index 0000000..c423de9 --- /dev/null +++ b/src/utils/campaignRecipients.js @@ -0,0 +1,281 @@ +const mongoose = require('mongoose'); +const Participant = require('../models/Participant'); +const ParticipantList = require('../models/ParticipantList'); +const Event = require('../models/Event'); +const { flattenParticipantForEvent, flattenParticipantForCampaignList } = require('./participantMembership'); +const { rulesToQuery } = require('./participantListQuery'); +const { normalizeWhatsAppTo } = require('./phone'); + +const BATCH = 500; + +/** + * Participants registered for event with non-empty email. + * @param {import('mongoose').Types.ObjectId} tenantOrganization + * @param {import('mongoose').Types.ObjectId|string} eventId + */ +async function resolveAllForEvent(tenantOrganization, eventId) { + const eid = new mongoose.Types.ObjectId(String(eventId)); + const toid = + tenantOrganization instanceof mongoose.Types.ObjectId + ? tenantOrganization + : new mongoose.Types.ObjectId(String(tenantOrganization)); + const match = { + tenantOrganization: toid, + email: { $exists: true, $nin: [null, ''] }, + eventMemberships: { $elemMatch: { event: eid } }, + }; + const out = []; + let skip = 0; + for (;;) { + const batch = await Participant.find(match).skip(skip).limit(BATCH).lean(); + if (!batch.length) break; + for (const p of batch) { + const flat = flattenParticipantForEvent(p, eventId); + if (flat && flat.email) { + out.push({ participant: p, flat }); + } + } + if (batch.length < BATCH) break; + skip += BATCH; + } + return out; +} + +/** + * @param {string[]} ids participant ids + * @param {string|import('mongoose').Types.ObjectId} eventId + * @param {string|import('mongoose').Types.ObjectId} [tenantOrganization] — restrict to this tenant + */ +async function resolveCustomList(ids, eventId, tenantOrganization) { + const oids = ids.filter((id) => mongoose.Types.ObjectId.isValid(String(id))).map((id) => new mongoose.Types.ObjectId(String(id))); + if (!oids.length) return []; + const q = { _id: { $in: oids } }; + if (tenantOrganization != null && mongoose.Types.ObjectId.isValid(String(tenantOrganization))) { + q.tenantOrganization = + tenantOrganization instanceof mongoose.Types.ObjectId + ? tenantOrganization + : new mongoose.Types.ObjectId(String(tenantOrganization)); + } + const raw = await Participant.find(q).lean(); + const out = []; + for (const p of raw) { + const flat = flattenParticipantForCampaignList(p, eventId); + if (flat && flat.email) out.push({ participant: p, flat }); + } + return out; +} + +/** + * @param {import('mongoose').Types.ObjectId|string} listId + * @param {import('mongoose').Types.ObjectId|string} eventId + * @param {import('mongoose').Types.ObjectId[]} allowedEventIds — org’s events + */ +async function resolveParticipantList(listId, eventId, allowedEventIds) { + const doc = await ParticipantList.findById(listId).lean(); + if (!doc) return { error: { code: 'NOT_FOUND', message: 'Contact list not found' } }; + const eventDoc = await Event.findById(eventId).select('organization').lean(); + if (eventDoc && doc.tenantOrganization?.toString() !== String(eventDoc.organization)) { + return { error: { code: 'FORBIDDEN', message: 'Contact list belongs to another organisation' } }; + } + const eidStr = String(eventId); + if (!allowedEventIds.some((id) => String(id) === eidStr)) { + return { error: { code: 'FORBIDDEN', message: 'List scope does not include this event' } }; + } + + if (doc.type === 'fixed') { + const idList = (doc.participantIds || []).map((id) => String(id)); + return { rows: await resolveCustomList(idList, eventId, doc.tenantOrganization) }; + } + + const rulesPayload = doc.rules && typeof doc.rules === 'object' ? doc.rules : {}; + const rules = rulesPayload.rules || []; + const conjunction = rulesPayload.conjunction || 'AND'; + const match = rulesToQuery(rules, conjunction, allowedEventIds, doc.tenantOrganization); + const andEvent = { + $and: [ + match, + { email: { $exists: true, $nin: [null, ''] } }, + { eventMemberships: { $elemMatch: { event: new mongoose.Types.ObjectId(String(eventId)) } } }, + ], + }; + const out = []; + let skip = 0; + for (;;) { + const batch = await Participant.find(andEvent).skip(skip).limit(BATCH).lean(); + if (!batch.length) break; + for (const p of batch) { + const flat = flattenParticipantForEvent(p, eventId); + if (flat && flat.email) out.push({ participant: p, flat }); + } + if (batch.length < BATCH) break; + skip += BATCH; + } + return { rows: out }; +} + +/** + * @param {object} campaign — MessagingCampaign doc + * @param {import('mongoose').Types.ObjectId} tenantOrganization — event.organization + * @param {import('mongoose').Types.ObjectId[]} allowedEventIds + */ +async function resolveRecipientsForCampaign(campaign, tenantOrganization, allowedEventIds) { + const eventId = campaign.event; + const rec = campaign.recipients || {}; + const type = rec.type || 'all'; + + if (type === 'all') { + const rows = await resolveAllForEvent(tenantOrganization, eventId); + return { rows }; + } + if (type === 'custom' && Array.isArray(rec.customList) && rec.customList.length) { + const ids = rec.customList.map((id) => String(id)); + const rows = await resolveCustomList(ids, eventId, tenantOrganization); + return { rows }; + } + if (type === 'participant_list' && rec.participantList) { + return resolveParticipantList(rec.participantList, eventId, allowedEventIds); + } + return { error: { code: 'VALIDATION_ERROR', message: 'Invalid or empty recipients configuration' } }; +} + +// ─── WhatsApp: require normalized phone, dedupe by phone (digits) ─── + +async function resolveAllForEventWhatsApp(tenantOrganization, eventId) { + const eid = new mongoose.Types.ObjectId(String(eventId)); + const toid = + tenantOrganization instanceof mongoose.Types.ObjectId + ? tenantOrganization + : new mongoose.Types.ObjectId(String(tenantOrganization)); + const match = { + tenantOrganization: toid, + phone: { $exists: true, $nin: [null, ''] }, + eventMemberships: { $elemMatch: { event: eid } }, + }; + const out = []; + let skip = 0; + for (;;) { + const batch = await Participant.find(match).skip(skip).limit(BATCH).lean(); + if (!batch.length) break; + for (const p of batch) { + const flat = flattenParticipantForEvent(p, eventId); + if (flat && normalizeWhatsAppTo(flat.phone)) { + out.push({ participant: p, flat }); + } + } + if (batch.length < BATCH) break; + skip += BATCH; + } + return out; +} + +async function resolveCustomListWhatsApp(ids, eventId, tenantOrganization) { + const oids = ids.filter((id) => mongoose.Types.ObjectId.isValid(String(id))).map((id) => new mongoose.Types.ObjectId(String(id))); + if (!oids.length) return []; + const q = { _id: { $in: oids } }; + if (tenantOrganization != null && mongoose.Types.ObjectId.isValid(String(tenantOrganization))) { + q.tenantOrganization = + tenantOrganization instanceof mongoose.Types.ObjectId + ? tenantOrganization + : new mongoose.Types.ObjectId(String(tenantOrganization)); + } + const raw = await Participant.find(q).lean(); + const out = []; + for (const p of raw) { + const flat = flattenParticipantForCampaignList(p, eventId); + if (flat && normalizeWhatsAppTo(flat.phone)) out.push({ participant: p, flat }); + } + return out; +} + +async function resolveParticipantListWhatsApp(listId, eventId, allowedEventIds) { + const doc = await ParticipantList.findById(listId).lean(); + if (!doc) return { error: { code: 'NOT_FOUND', message: 'Contact list not found' } }; + const eventDoc = await Event.findById(eventId).select('organization').lean(); + if (eventDoc && doc.tenantOrganization?.toString() !== String(eventDoc.organization)) { + return { error: { code: 'FORBIDDEN', message: 'Contact list belongs to another organisation' } }; + } + const eidStr = String(eventId); + if (!allowedEventIds.some((id) => String(id) === eidStr)) { + return { error: { code: 'FORBIDDEN', message: 'List scope does not include this event' } }; + } + + if (doc.type === 'fixed') { + const idList = (doc.participantIds || []).map((id) => String(id)); + return { rows: await resolveCustomListWhatsApp(idList, eventId, doc.tenantOrganization) }; + } + + const rulesPayload = doc.rules && typeof doc.rules === 'object' ? doc.rules : {}; + const rules = rulesPayload.rules || []; + const conjunction = rulesPayload.conjunction || 'AND'; + const match = rulesToQuery(rules, conjunction, allowedEventIds, doc.tenantOrganization); + const andEvent = { + $and: [ + match, + { phone: { $exists: true, $nin: [null, ''] } }, + { eventMemberships: { $elemMatch: { event: new mongoose.Types.ObjectId(String(eventId)) } } }, + ], + }; + const out = []; + let skip = 0; + for (;;) { + const batch = await Participant.find(andEvent).skip(skip).limit(BATCH).lean(); + if (!batch.length) break; + for (const p of batch) { + const flat = flattenParticipantForEvent(p, eventId); + if (flat && normalizeWhatsAppTo(flat.phone)) out.push({ participant: p, flat }); + } + if (batch.length < BATCH) break; + skip += BATCH; + } + return { rows: out }; +} + +/** + * @param {object} campaign + * @param {import('mongoose').Types.ObjectId} tenantOrganization + * @param {import('mongoose').Types.ObjectId[]} allowedEventIds + */ +async function resolveRecipientsForWhatsAppCampaign(campaign, tenantOrganization, allowedEventIds) { + const eventId = campaign.event; + const rec = campaign.recipients || {}; + const type = rec.type || 'all'; + + if (type === 'all') { + const rows = await resolveAllForEventWhatsApp(tenantOrganization, eventId); + return { rows: dedupeWhatsappRows(rows) }; + } + if (type === 'custom' && Array.isArray(rec.customList) && rec.customList.length) { + const ids = rec.customList.map((id) => String(id)); + const rows = await resolveCustomListWhatsApp(ids, eventId, tenantOrganization); + return { rows: dedupeWhatsappRows(rows) }; + } + if (type === 'participant_list' && rec.participantList) { + const r = await resolveParticipantListWhatsApp(rec.participantList, eventId, allowedEventIds); + if (r.error) return r; + return { rows: dedupeWhatsappRows(r.rows || []) }; + } + return { error: { code: 'VALIDATION_ERROR', message: 'Invalid or empty recipients configuration' } }; +} + +/** + * @param {{ participant: object, flat: object }[]} rows + */ +function dedupeWhatsappRows(rows) { + const seen = new Set(); + const out = []; + for (const row of rows) { + const to = normalizeWhatsAppTo(row.flat && row.flat.phone); + if (!to || seen.has(to)) continue; + seen.add(to); + out.push(row); + } + return out; +} + +module.exports = { + resolveRecipientsForCampaign, + resolveRecipientsForWhatsAppCampaign, + resolveAllForEvent, + resolveCustomList, + resolveParticipantList, +}; diff --git a/src/utils/conciergeChunking.js b/src/utils/conciergeChunking.js new file mode 100644 index 0000000..b80d299 --- /dev/null +++ b/src/utils/conciergeChunking.js @@ -0,0 +1,32 @@ +const CHUNK_SIZE = 500; +const CHUNK_OVERLAP = 50; + +/** + * Sliding window text chunker. + * @param {string} text + * @returns {string[]} + */ +function chunkText(text) { + const normalized = String(text || '') + .replace(/\s+/g, ' ') + .trim(); + if (!normalized) return []; + if (normalized.length <= CHUNK_SIZE) return [normalized]; + + const chunks = []; + let start = 0; + while (start < normalized.length) { + const end = Math.min(start + CHUNK_SIZE, normalized.length); + chunks.push(normalized.slice(start, end)); + if (end >= normalized.length) break; + start = end - CHUNK_OVERLAP; + if (start < 0) start = 0; + } + return chunks; +} + +module.exports = { + chunkText, + CHUNK_SIZE, + CHUNK_OVERLAP, +}; diff --git a/src/utils/conciergeDocumentExtract.js b/src/utils/conciergeDocumentExtract.js new file mode 100644 index 0000000..290a00d --- /dev/null +++ b/src/utils/conciergeDocumentExtract.js @@ -0,0 +1,82 @@ +const pdfParse = require('pdf-parse'); +const officeParser = require('officeparser'); + +/** + * @param {Buffer} buffer + * @returns {Promise} + */ +async function extractTextFromPdfBuffer(buffer) { + const parsed = await pdfParse(buffer); + return String(parsed.text || '') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * @param {unknown} raw + * @returns {string} + */ +function officeParserResultToText(raw) { + if (typeof raw === 'string') return raw; + if (!raw || typeof raw !== 'object') return ''; + if (typeof officeParser.getText === 'function') { + return String(officeParser.getText(raw) || ''); + } + if (typeof raw.toText === 'function') { + return String(raw.toText() || ''); + } + if (Array.isArray(raw)) { + return raw.map((n) => (n && n.text) || '').join(' '); + } + return ''; +} + +/** + * @param {Buffer} buffer + * @param {string} [filename] Used to infer format (.docx / .pptx) + * @returns {Promise} + */ +async function extractTextFromOfficeBuffer(buffer, filename = '') { + const ext = filename.toLowerCase().split('.').pop() || ''; + if (!['docx', 'pptx', 'doc', 'ppt'].includes(ext)) { + throw new Error(`Unsupported office format: ${ext || 'unknown'}`); + } + + let raw; + if (typeof officeParser.parseOfficeAsync === 'function') { + raw = await officeParser.parseOfficeAsync(buffer); + } else { + raw = await new Promise((resolve, reject) => { + officeParser.parseOffice(buffer, (err, data) => (err ? reject(err) : resolve(data))); + }); + } + + return officeParserResultToText(raw).replace(/\s+/g, ' ').trim(); +} + +/** + * @param {Buffer} buffer + * @param {string} url + * @returns {Promise} + */ +async function extractTextFromDocumentBuffer(buffer, url) { + const lower = url.toLowerCase().split('?')[0]; + if (lower.endsWith('.pdf')) { + return extractTextFromPdfBuffer(buffer); + } + if ( + lower.endsWith('.docx') || + lower.endsWith('.pptx') || + lower.endsWith('.doc') || + lower.endsWith('.ppt') + ) { + return extractTextFromOfficeBuffer(buffer, lower); + } + throw new Error(`Unsupported document URL: ${url}`); +} + +module.exports = { + extractTextFromPdfBuffer, + extractTextFromOfficeBuffer, + extractTextFromDocumentBuffer, +}; diff --git a/src/utils/conciergeEventKnowledge.js b/src/utils/conciergeEventKnowledge.js new file mode 100644 index 0000000..762fdc6 --- /dev/null +++ b/src/utils/conciergeEventKnowledge.js @@ -0,0 +1,254 @@ +const Event = require('../models/Event'); +const Organization = require('../models/Organization'); +const EventMicrosite = require('../models/EventMicrosite'); +const Form = require('../models/Form'); +const EventTicket = require('../models/EventTicket'); +const { loadTracks, loadAgendaAndSpeakers, speakerDisplayName } = require('./eventAgendaData'); +const { registrationHref, registrationUrlForForm } = require('./micrositeBuild'); + +function micrositePublicBase() { + return (process.env.MICROSITE_PUBLIC_BASE_URL || 'https://web.eventbuzz.live').replace(/\/$/, ''); +} + +function eventBuzzPublicBase() { + return (process.env.EVENTBUZZ_PUBLIC_URL || 'http://localhost:5174').replace(/\/$/, ''); +} + +function joinCodeUrl(code) { + const c = code != null ? String(code).trim() : ''; + if (!c) return ''; + return `${eventBuzzPublicBase()}/code/${c}`; +} + +function micrositeUrlForFolder(folder) { + const slug = folder != null ? String(folder).trim().toLowerCase().replace(/[^a-z0-9-]/g, '') : ''; + if (!slug) return ''; + return `${micrositePublicBase()}/${slug}/`; +} + +function formatPrice(ticket) { + const minor = ticket.basePriceMinor; + if (minor == null || Number.isNaN(Number(minor))) return ''; + const major = Number(minor) / 100; + const currency = (ticket.currency || 'INR').toUpperCase(); + return `${currency} ${major.toFixed(2)}`; +} + +/** + * @param {import('mongoose').Types.ObjectId | string} eventId + */ +async function loadEventKnowledgeContext(eventId) { + const event = await Event.findById(eventId).lean(); + if (!event) throw new Error('Event not found'); + + const [ + organization, + tracks, + agendaBundle, + microsites, + registrationForms, + tickets, + ] = await Promise.all([ + event.organization + ? Organization.findById(event.organization).select('name').lean() + : null, + loadTracks(eventId), + loadAgendaAndSpeakers(eventId, { speakerVisibility: 'all', fullDetail: true }), + EventMicrosite.find({ event: eventId }).sort({ name: 1 }).lean(), + Form.find({ event: eventId, type: 'registration' }).sort({ name: 1 }).lean(), + EventTicket.find({ event: eventId, isActive: true }).sort({ sortOrder: 1, name: 1 }).lean(), + ]); + + return { + event, + organization, + tracks, + agendaItems: agendaBundle.agendaItems, + speakerRecords: agendaBundle.speakerRecords, + microsites, + registrationForms, + tickets, + }; +} + +/** + * @param {Record} ctx from loadEventKnowledgeContext + * @returns {string} + */ +function buildEventKnowledgeText(ctx) { + const lines = []; + const push = (label, value) => { + if (value != null && String(value).trim() !== '') { + lines.push(`${label}: ${String(value).trim()}`); + } + }; + const section = (title) => { + lines.push(''); + lines.push(`=== ${title} ===`); + }; + + const { event, organization, tracks, agendaItems, speakerRecords, microsites, registrationForms, tickets } = + ctx; + const website = event.website && typeof event.website === 'object' ? event.website : {}; + + section('EVENT OVERVIEW'); + if (organization?.name) push('Organization', organization.name); + push('Event name', event.name); + push('Subtitle', event.subtitle); + push('Description', event.description); + push('Type', event.type); + push('Status', event.status); + push('Category', event.category); + if (event.startDate) push('Start date', new Date(event.startDate).toISOString()); + if (event.endDate) push('End date', new Date(event.endDate).toISOString()); + push('Location', event.location); + push('Location type', event.locationType); + push('Virtual link', event.virtualLink); + if (Array.isArray(event.tags) && event.tags.length) { + push('Tags', event.tags.join(', ')); + } + push('Join code', event.joinCode); + + const settings = event.settings && typeof event.settings === 'object' ? event.settings : {}; + if (settings.registrationOpen === false) push('Registration', 'Closed'); + else if (settings.registrationOpen === true) push('Registration', 'Open'); + if (settings.registrationDeadline) { + push('Registration deadline', new Date(settings.registrationDeadline).toISOString()); + } + push('Registration mode', settings.registrationMode); + push('Authentication type', settings.authenticationType); + push('Registration fields', settings.registrationBaseFields); + + if (website.title) push('Website title', website.title); + if (website.description || website.seoDescription) { + push('Website description', website.description || website.seoDescription); + } + + section('LINKS'); + const primaryMicrosite = micrositeUrlForFolder(website.folder); + if (primaryMicrosite) push('Primary microsite', primaryMicrosite); + if (website.enabled === false) push('Primary microsite status', 'Disabled'); + else if (website.enabled) push('Primary microsite status', 'Enabled'); + + const regDefault = registrationHref(event, website); + if (regDefault) push('Registration URL', regDefault); + + for (const ms of microsites || []) { + const folder = ms.publishFolder ? String(ms.publishFolder).trim().toLowerCase() : ''; + if (!folder) continue; + const label = ms.name ? `Microsite (${ms.name})` : `Microsite (${folder})`; + push(label, `${micrositePublicBase()}/${folder}/`); + } + + for (const form of registrationForms || []) { + const href = registrationUrlForForm(event, website, form._id); + if (href) push(`Registration form: ${form.name || 'Form'}`, href); + } + + const eventJoinUrl = joinCodeUrl(event.joinCode); + if (eventJoinUrl) push('Join event (code link)', eventJoinUrl); + + section('TRACKS'); + if (!tracks.length) { + lines.push('No tracks configured.'); + } else { + for (const t of tracks) { + lines.push(''); + push('Track', t.name); + push(' Hall', t.hall); + push(' Type', t.trackType); + push(' Status', t.status); + push(' Join code', t.joinCode); + const trackJoin = joinCodeUrl(t.joinCode); + if (trackJoin) push(' Join link', trackJoin); + } + } + + section('AGENDA'); + if (!agendaItems.length) { + lines.push('No sessions scheduled.'); + } else { + for (const item of agendaItems) { + lines.push(''); + push('Session', item.title); + push(' Time', item.time); + if (item.startTime) push(' Start', new Date(item.startTime).toISOString()); + if (item.endTime) push(' End', new Date(item.endTime).toISOString()); + push(' Type', item.sessionType); + push(' Status', item.status); + push(' Track', item.trackName || item.track); + push(' Location', item.location || item.track); + if (item.speakerNames && item.speakerNames.length) { + push(' Speakers', item.speakerNames.join('; ')); + } + push(' Moderator', item.moderatorName); + push(' Description', item.description); + push(' Join code', item.joinCode); + const sessionJoin = joinCodeUrl(item.joinCode); + if (sessionJoin) push(' Join link', sessionJoin); + for (const res of item.resources || []) { + if (res.name || res.url) { + push(' Resource', [res.name, res.url].filter(Boolean).join(' — ')); + } + } + } + } + + section('SPEAKERS AND FACULTY'); + if (!speakerRecords.length) { + lines.push('No speakers linked to sessions.'); + } else { + for (const s of speakerRecords) { + lines.push(''); + push('Name', speakerDisplayName(s)); + push(' Role', s.speakerRole); + push(' Title', s.title); + push(' Organization', s.organizationName); + push(' Bio', s.bio); + if (Array.isArray(s.expertise) && s.expertise.length) { + push(' Expertise', s.expertise.join(', ')); + } + const social = s.socialLinks && typeof s.socialLinks === 'object' ? s.socialLinks : {}; + push(' LinkedIn', social.linkedin); + push(' Twitter', social.twitter); + push(' Website', social.website); + push(' Profile visibility', s.profileVisibility); + } + } + + section('TICKETING'); + if (!tickets.length) { + lines.push('No active tickets.'); + } else { + for (const ticket of tickets) { + lines.push(''); + push('Ticket', ticket.name); + push(' Price', formatPrice(ticket)); + push(' Description', ticket.description); + if (Array.isArray(ticket.keyPoints) && ticket.keyPoints.length) { + push(' Key points', ticket.keyPoints.join('; ')); + } + } + } + + return lines.join('\n').trim(); +} + +/** + * @param {import('mongoose').Types.ObjectId | string} eventId + * @returns {Promise} + */ +async function buildEventKnowledgeTextFromEventId(eventId) { + const ctx = await loadEventKnowledgeContext(eventId); + return buildEventKnowledgeText(ctx); +} + +module.exports = { + loadEventKnowledgeContext, + buildEventKnowledgeText, + buildEventKnowledgeTextFromEventId, + micrositePublicBase, + eventBuzzPublicBase, + joinCodeUrl, + micrositeUrlForFolder, +}; diff --git a/src/utils/conciergeEventKnowledge.test.js b/src/utils/conciergeEventKnowledge.test.js new file mode 100644 index 0000000..a6b3482 --- /dev/null +++ b/src/utils/conciergeEventKnowledge.test.js @@ -0,0 +1,123 @@ +const { buildEventKnowledgeText } = require('./conciergeEventKnowledge'); + +describe('buildEventKnowledgeText', () => { + const baseCtx = () => ({ + event: { + _id: 'evt1', + name: 'Annual Summit', + subtitle: 'Innovation day', + description: 'A full-day conference.', + type: 'conference', + status: 'upcoming', + startDate: new Date('2026-06-01T09:00:00.000Z'), + endDate: new Date('2026-06-01T18:00:00.000Z'), + location: 'Mumbai', + locationType: 'hybrid', + virtualLink: 'https://meet.example.com/room', + tags: ['health', 'tech'], + joinCode: 'EVT123', + website: { + enabled: true, + folder: 'annual-summit-2026', + title: 'Summit Site', + registrationUrl: 'https://register.example.com', + }, + settings: { + registrationOpen: true, + registrationMode: 'open', + }, + }, + organization: { name: 'Acme Org' }, + tracks: [ + { name: 'Main Hall', hall: 'A', trackType: 'general', status: 'active', joinCode: 'TRK1' }, + ], + agendaItems: [ + { + title: 'Opening Keynote', + time: '9:00 AM – 10:00 AM', + startTime: new Date('2026-06-01T09:00:00.000Z'), + endTime: new Date('2026-06-01T10:00:00.000Z'), + sessionType: 'keynote', + status: 'scheduled', + trackName: 'Main Hall', + track: 'Main Hall', + location: 'Hall A', + speakerNames: ['Dr Jane Doe', 'John Smith'], + moderatorName: 'Alex Host', + description: 'Welcome and vision.', + joinCode: 'SES1', + resources: [{ name: 'Slides', url: 'https://cdn.example.com/slides.pdf' }], + }, + ], + speakerRecords: [ + { + firstName: 'Jane', + lastName: 'Doe', + email: 'jane@secret.example', + phone: '+919999999999', + title: 'Chief Medical Officer', + organizationName: 'Hospital X', + speakerRole: 'Keynote Speaker', + bio: 'Expert in cardiology.', + expertise: ['cardiology'], + profileVisibility: 'private', + socialLinks: { linkedin: 'https://linkedin.com/in/jane' }, + logistics: { internalNotes: 'VIP arrival 8am' }, + }, + ], + microsites: [{ name: 'VIP microsite', publishFolder: 'summit-vip' }], + registrationForms: [{ _id: 'form1', name: 'General registration' }], + tickets: [ + { + name: 'Standard pass', + description: 'Full access', + keyPoints: ['Lunch included'], + basePriceMinor: 150000, + currency: 'INR', + }, + ], + }); + + beforeEach(() => { + process.env.MICROSITE_PUBLIC_BASE_URL = 'https://web.eventbuzz.live'; + process.env.EVENTBUZZ_PUBLIC_URL = 'https://app.eventbuzz.live'; + process.env.MICROSITE_REGISTRATION_URL_TEMPLATE = ''; + }); + + it('includes major knowledge sections', () => { + const text = buildEventKnowledgeText(baseCtx()); + expect(text).toContain('=== EVENT OVERVIEW ==='); + expect(text).toContain('=== LINKS ==='); + expect(text).toContain('=== TRACKS ==='); + expect(text).toContain('=== AGENDA ==='); + expect(text).toContain('=== SPEAKERS AND FACULTY ==='); + expect(text).toContain('=== TICKETING ==='); + }); + + it('includes agenda with all speakers and links', () => { + const text = buildEventKnowledgeText(baseCtx()); + expect(text).toContain('Opening Keynote'); + expect(text).toContain('Dr Jane Doe; John Smith'); + expect(text).toContain('Alex Host'); + expect(text).toContain('https://web.eventbuzz.live/annual-summit-2026/'); + expect(text).toContain('https://web.eventbuzz.live/summit-vip/'); + expect(text).toContain('https://app.eventbuzz.live/code/EVT123'); + }); + + it('includes private speaker bio without PII', () => { + const text = buildEventKnowledgeText(baseCtx()); + expect(text).toContain('Jane Doe'); + expect(text).toContain('Expert in cardiology'); + expect(text).toContain('private'); + expect(text).not.toContain('jane@secret.example'); + expect(text).not.toContain('+919999999999'); + expect(text).not.toContain('VIP arrival'); + expect(text).not.toContain('internalNotes'); + }); + + it('formats ticket pricing', () => { + const text = buildEventKnowledgeText(baseCtx()); + expect(text).toContain('Standard pass'); + expect(text).toContain('INR 1500.00'); + }); +}); diff --git a/src/utils/conciergeEventSnapshot.js b/src/utils/conciergeEventSnapshot.js new file mode 100644 index 0000000..672edc7 --- /dev/null +++ b/src/utils/conciergeEventSnapshot.js @@ -0,0 +1,45 @@ +const { buildEventKnowledgeTextFromEventId } = require('./conciergeEventKnowledge'); + +/** + * @deprecated Use buildEventKnowledgeTextFromEventId from conciergeEventKnowledge.js + * @param {Record} event lean Event doc + * @returns {string} + */ +function buildEventSnapshotText(event) { + if (!event) return ''; + const lines = []; + const push = (label, value) => { + if (value != null && String(value).trim() !== '') { + lines.push(`${label}: ${String(value).trim()}`); + } + }; + + push('Event name', event.name); + push('Subtitle', event.subtitle); + push('Description', event.description); + push('Type', event.type); + push('Status', event.status); + if (event.startDate) push('Start date', new Date(event.startDate).toISOString()); + if (event.endDate) push('End date', new Date(event.endDate).toISOString()); + push('Location', event.location); + push('Location type', event.locationType); + push('Virtual link', event.virtualLink); + if (Array.isArray(event.tags) && event.tags.length) { + push('Tags', event.tags.join(', ')); + } + push('Join code', event.joinCode); + + const w = event.website; + if (w && typeof w === 'object') { + push('Website title', w.title); + push('Website description', w.description || w.seoDescription); + push('Registration URL', w.registrationUrl); + } + + return lines.join('\n'); +} + +module.exports = { + buildEventSnapshotText, + buildEventKnowledgeTextFromEventId, +}; diff --git a/src/utils/conciergeIngest.js b/src/utils/conciergeIngest.js new file mode 100644 index 0000000..1c072cd --- /dev/null +++ b/src/utils/conciergeIngest.js @@ -0,0 +1,111 @@ +const KnowledgeSource = require('../models/KnowledgeSource'); +const ContextChunk = require('../models/ContextChunk'); +const { embedTexts } = require('./bedrockEmbed'); +const { chunkText } = require('./conciergeChunking'); +const { buildEventKnowledgeTextFromEventId } = require('./conciergeEventKnowledge'); +const { getBufferFromS3Key } = require('./s3Media'); +const { extractTextFromPdfBuffer } = require('./conciergeDocumentExtract'); +const { crawlUrlSource, extractTextFromUrl } = require('./conciergeUrlCrawl'); + +async function extractTextFromPdf(sourcePath) { + const buffer = await getBufferFromS3Key(sourcePath); + return extractTextFromPdfBuffer(buffer); +} + +async function extractTextFromEvent(eventId) { + return buildEventKnowledgeTextFromEventId(eventId); +} + +/** + * @param {import('../models/KnowledgeSource')} source + */ +async function extractTextFromUrlSource(source) { + const scopeMode = + source.crawlScope === 'domain' || source.crawlScope === 'path' + ? source.crawlScope + : 'auto'; + const { text, stats, scope } = await crawlUrlSource(source.sourcePath, { + scopeMode, + crawlScope: source.crawlScope, + pathPrefix: source.crawlPathPrefix, + }); + return { text, stats, scope }; +} + +/** + * @param {string} sourceId + */ +async function runConciergeIngest(sourceId) { + const mongoose = require('mongoose'); + if (mongoose.connection.readyState !== 1) { + const { connectDB } = require('../config/database'); + await connectDB(); + } + + const source = await KnowledgeSource.findById(sourceId); + if (!source) throw new Error('KnowledgeSource not found'); + + source.status = 'PROCESSING'; + source.errorMessage = null; + await source.save(); + + try { + let text = ''; + let crawlStats = null; + + if (source.type === 'url') { + const result = await extractTextFromUrlSource(source); + text = result.text; + crawlStats = result.stats; + if (result.scope) { + source.crawlScope = result.scope.mode; + source.crawlPathPrefix = result.scope.pathPrefix || ''; + } + } else if (source.type === 'pdf') { + text = await extractTextFromPdf(source.sourcePath); + } else if (source.type === 'event') { + text = await extractTextFromEvent(source.sourcePath || source.eventId); + } else { + throw new Error(`Unknown source type: ${source.type}`); + } + + if (!text || text.length < 10) { + throw new Error('No extractable text content'); + } + + await ContextChunk.deleteMany({ sourceId: source._id }); + const pieces = chunkText(text); + const { embeddings, modelId } = await embedTexts(pieces); + + const docs = pieces.map((rawText, i) => ({ + eventId: source.eventId, + sourceId: source._id, + rawText, + embedding: embeddings[i], + embeddingModel: modelId, + isActive: false, + })); + if (docs.length) await ContextChunk.insertMany(docs); + + source.status = 'READY'; + source.lastSyncedAt = new Date(); + if (crawlStats) { + source.lastCrawlStats = crawlStats; + } + await source.save(); + return { chunkCount: docs.length, crawlStats }; + } catch (e) { + source.status = 'FAILED'; + source.errorMessage = e.message || 'Ingest failed'; + await source.save(); + throw e; + } +} + +module.exports = { + runConciergeIngest, + extractTextFromUrl, + extractTextFromUrlSource, + extractTextFromPdf, + extractTextFromEvent, +}; diff --git a/src/utils/conciergeUrlCrawl.js b/src/utils/conciergeUrlCrawl.js new file mode 100644 index 0000000..8c90841 --- /dev/null +++ b/src/utils/conciergeUrlCrawl.js @@ -0,0 +1,422 @@ +const dns = require('dns').promises; +const net = require('net'); +const cheerio = require('cheerio'); +const { + extractTextFromDocumentBuffer, +} = require('./conciergeDocumentExtract'); + +const STRIP_SELECTORS = 'script, style, nav, footer, form, noscript, iframe'; +const USER_AGENT = 'EventBuzz-Concierge/1.0'; + +const SKIP_PAGE_EXTENSIONS = new Set([ + '.jpg', + '.jpeg', + '.png', + '.gif', + '.webp', + '.svg', + '.ico', + '.css', + '.js', + '.mjs', + '.map', + '.woff', + '.woff2', + '.ttf', + '.eot', + '.mp3', + '.mp4', + '.avi', + '.mov', + '.zip', + '.rar', + '.gz', + '.xml', + '.json', +]); + +const DOCUMENT_EXTENSIONS = ['.pdf', '.docx', '.pptx']; + +function crawlConfig() { + return { + maxPages: Number(process.env.CONCIERGE_CRAWL_MAX_PAGES) || 50, + maxAssets: Number(process.env.CONCIERGE_CRAWL_MAX_ASSETS) || 20, + maxDepth: Number(process.env.CONCIERGE_CRAWL_MAX_DEPTH) || 5, + timeoutMs: Number(process.env.CONCIERGE_CRAWL_TIMEOUT_MS) || 30000, + delayMs: Number(process.env.CONCIERGE_CRAWL_DELAY_MS) || 200, + maxAssetBytes: Number(process.env.CONCIERGE_CRAWL_MAX_ASSET_BYTES) || 15 * 1024 * 1024, + }; +} + +function isPrivateIp(ip) { + if (net.isIPv4(ip)) { + const [a, b] = ip.split('.').map(Number); + if (a === 10 || a === 127 || a === 0) return true; + if (a === 169 && b === 254) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + return false; + } + if (net.isIPv6(ip)) { + const lower = ip.toLowerCase(); + if (lower === '::1' || lower === '::') return true; + if (lower.startsWith('fc') || lower.startsWith('fd')) return true; + if (lower.startsWith('fe80')) return true; + } + return false; +} + +async function assertPublicUrl(urlStr) { + let u; + try { + u = new URL(urlStr); + } catch { + throw new Error('Invalid URL'); + } + if (!['http:', 'https:'].includes(u.protocol)) { + throw new Error('Blocked URL scheme'); + } + const host = u.hostname.toLowerCase(); + if (host === 'localhost' || host.endsWith('.local') || host.endsWith('.internal')) { + throw new Error('Blocked host'); + } + if (net.isIP(host)) { + if (isPrivateIp(host)) throw new Error('Blocked private IP'); + return u; + } + const { address } = await dns.lookup(host, { verbatim: true }); + if (isPrivateIp(address)) throw new Error('Blocked private IP'); + return u; +} + +function pathnameExtension(pathname) { + const lower = (pathname || '').toLowerCase(); + const idx = lower.lastIndexOf('.'); + if (idx === -1) return ''; + const ext = lower.slice(idx); + if (ext.length > 6) return ''; + return ext; +} + +/** + * @param {string} seedUrl + * @param {'auto'|'domain'|'path'} [requestedScope] + * @returns {{ hostname: string, pathPrefix: string, mode: 'domain'|'path', seedUrl: string }} + */ +function resolveCrawlScope(seedUrl, requestedScope = 'auto') { + const u = new URL(seedUrl); + const hostname = u.hostname.toLowerCase(); + const pathname = u.pathname || '/'; + const hasFileExtension = /\.[a-z0-9]{2,5}$/i.test(pathname); + const isDirectoryPath = pathname.endsWith('/') || pathname === '/'; + + let mode = 'domain'; + let pathPrefix = ''; + + if (requestedScope === 'domain') { + mode = 'domain'; + pathPrefix = ''; + } else if (requestedScope === 'path') { + mode = 'path'; + if (isDirectoryPath) { + pathPrefix = pathname === '/' ? '/' : pathname.replace(/\/$/, ''); + } else if (hasFileExtension) { + const dir = pathname.substring(0, pathname.lastIndexOf('/')); + pathPrefix = dir || '/'; + } else { + pathPrefix = pathname; + } + } else if (pathname === '/' || pathname === '') { + mode = 'domain'; + pathPrefix = ''; + } else if (isDirectoryPath) { + mode = 'path'; + pathPrefix = pathname === '/' ? '/' : pathname.replace(/\/$/, ''); + } else if (hasFileExtension) { + mode = 'path'; + const dir = pathname.substring(0, pathname.lastIndexOf('/')); + pathPrefix = dir || '/'; + } else { + mode = 'path'; + pathPrefix = pathname; + } + + return { hostname, pathPrefix, mode, seedUrl: u.href }; +} + +/** + * @param {string} href + * @param {string} [baseUrl] + * @returns {string|null} + */ +function normalizeUrl(href, baseUrl) { + if (!href || typeof href !== 'string') return null; + const trimmed = href.trim(); + if (!trimmed || trimmed.startsWith('#')) return null; + const lower = trimmed.toLowerCase(); + if (lower.startsWith('mailto:') || lower.startsWith('tel:') || lower.startsWith('javascript:')) { + return null; + } + try { + const resolved = baseUrl ? new URL(trimmed, baseUrl) : new URL(trimmed); + if (!['http:', 'https:'].includes(resolved.protocol)) return null; + resolved.hash = ''; + return resolved.href; + } catch { + return null; + } +} + +/** + * @param {string} urlStr + * @param {{ hostname: string, pathPrefix: string, mode: string }} scope + */ +function isInScope(urlStr, scope) { + let u; + try { + u = new URL(urlStr); + } catch { + return false; + } + if (u.hostname.toLowerCase() !== scope.hostname) return false; + if (scope.mode === 'domain' || !scope.pathPrefix || scope.pathPrefix === '/') { + return true; + } + const p = u.pathname || '/'; + const prefix = scope.pathPrefix; + return p === prefix || p.startsWith(`${prefix}/`); +} + +/** + * @param {string} urlStr + */ +function isAllowedDocumentUrl(urlStr) { + const ext = pathnameExtension(new URL(urlStr).pathname); + return DOCUMENT_EXTENSIONS.includes(ext); +} + +/** + * @param {string} urlStr + */ +function shouldSkipAsHtmlPage(urlStr) { + const ext = pathnameExtension(new URL(urlStr).pathname); + if (!ext) return false; + if (DOCUMENT_EXTENSIONS.includes(ext)) return true; + return SKIP_PAGE_EXTENSIONS.has(ext); +} + +/** + * @param {string} html + * @returns {string} + */ +function extractHtmlText(html) { + const $ = cheerio.load(html); + $(STRIP_SELECTORS).remove(); + const parts = []; + $('article').each((_, el) => { + const t = $(el).text().trim(); + if (t) parts.push(t); + }); + if (!parts.length) { + $('h1, h2, h3, h4, h5, h6, p, li').each((_, el) => { + const t = $(el).text().trim(); + if (t) parts.push(t); + }); + } + return parts.join('\n').replace(/\s+/g, ' ').trim(); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * @param {string} url + * @param {number} timeoutMs + */ +async function fetchBuffer(url, timeoutMs, maxBytes) { + await assertPublicUrl(url); + const res = await fetch(url, { + headers: { 'User-Agent': USER_AGENT }, + signal: AbortSignal.timeout(timeoutMs), + redirect: 'follow', + }); + if (!res.ok) throw new Error(`Fetch failed ${res.status}: ${url}`); + const len = Number(res.headers.get('content-length') || 0); + if (len > maxBytes) throw new Error(`Asset too large: ${url}`); + const ab = await res.arrayBuffer(); + if (ab.byteLength > maxBytes) throw new Error(`Asset too large: ${url}`); + return Buffer.from(ab); +} + +/** + * @param {string} url + * @param {number} timeoutMs + */ +async function fetchHtml(url, timeoutMs) { + await assertPublicUrl(url); + const res = await fetch(url, { + headers: { 'User-Agent': USER_AGENT, Accept: 'text/html,application/xhtml+xml' }, + signal: AbortSignal.timeout(timeoutMs), + redirect: 'follow', + }); + if (!res.ok) throw new Error(`URL fetch failed ${res.status}: ${url}`); + const ct = (res.headers.get('content-type') || '').toLowerCase(); + if (ct.includes('application/pdf')) { + return { kind: 'pdf', buffer: Buffer.from(await res.arrayBuffer()), finalUrl: res.url || url }; + } + const html = await res.text(); + return { kind: 'html', html, finalUrl: res.url || url }; +} + +/** + * @param {import('cheerio').CheerioAPI} $ + * @param {string} pageUrl + * @param {{ hostname: string, pathPrefix: string, mode: string }} scope + * @returns {{ pages: string[], documents: string[] }} + */ +function discoverLinks($, pageUrl, scope) { + const pages = new Set(); + const documents = new Set(); + $('a[href]').each((_, el) => { + const href = $(el).attr('href'); + const normalized = normalizeUrl(href, pageUrl); + if (!normalized || !isInScope(normalized, scope)) return; + if (isAllowedDocumentUrl(normalized)) { + documents.add(normalized); + } else if (!shouldSkipAsHtmlPage(normalized)) { + pages.add(normalized); + } + }); + return { pages: [...pages], documents: [...documents] }; +} + +/** + * @param {string} seedUrl + * @param {{ scopeMode?: 'auto'|'domain'|'path', pathPrefix?: string, crawlScope?: 'domain'|'path' }} [options] + * @returns {Promise<{ text: string, stats: object, scope: object }>} + */ +async function crawlUrlSource(seedUrl, options = {}) { + const cfg = crawlConfig(); + const requested = options.scopeMode || options.crawlScope || 'auto'; + const scope = resolveCrawlScope(seedUrl, requested); + if (options.crawlScope === 'path' || options.crawlScope === 'domain') { + scope.mode = options.crawlScope; + } + if (options.pathPrefix != null && options.pathPrefix !== '') { + scope.pathPrefix = options.pathPrefix; + scope.mode = 'path'; + } + + const stats = { pages: 0, assets: 0, skipped: 0, failed: 0 }; + const sections = []; + const visitedPages = new Set(); + const visitedAssets = new Set(); + + const seedNormalized = normalizeUrl(seedUrl); + if (!seedNormalized) throw new Error('Invalid seed URL'); + + const pendingDocuments = new Set(); + const queue = []; + + if (isAllowedDocumentUrl(seedNormalized)) { + pendingDocuments.add(seedNormalized); + } else { + queue.push({ url: seedNormalized, depth: 0 }); + } + + while (queue.length > 0 && stats.pages < cfg.maxPages) { + const { url, depth } = queue.shift(); + if (visitedPages.has(url)) continue; + if (depth > cfg.maxDepth) { + stats.skipped += 1; + continue; + } + if (shouldSkipAsHtmlPage(url) || isAllowedDocumentUrl(url)) { + if (isAllowedDocumentUrl(url)) pendingDocuments.add(url); + stats.skipped += 1; + continue; + } + + visitedPages.add(url); + + try { + if (cfg.delayMs > 0) await sleep(cfg.delayMs); + const result = await fetchHtml(url, cfg.timeoutMs); + + if (result.kind === 'pdf') { + pendingDocuments.add(result.finalUrl); + continue; + } + + const text = extractHtmlText(result.html); + if (text.length >= 10) { + sections.push(`=== PAGE: ${result.finalUrl} ===\n${text}`); + stats.pages += 1; + } + + const $ = cheerio.load(result.html); + const { pages, documents } = discoverLinks($, result.finalUrl, scope); + for (const doc of documents) pendingDocuments.add(doc); + for (const next of pages) { + if (!visitedPages.has(next) && isInScope(next, scope)) { + queue.push({ url: next, depth: depth + 1 }); + } + } + } catch { + stats.failed += 1; + } + } + + for (const docUrl of pendingDocuments) { + if (stats.assets >= cfg.maxAssets) break; + if (visitedAssets.has(docUrl)) continue; + visitedAssets.add(docUrl); + if (!isInScope(docUrl, scope)) { + stats.skipped += 1; + continue; + } + try { + if (cfg.delayMs > 0) await sleep(cfg.delayMs); + const buffer = await fetchBuffer(docUrl, cfg.timeoutMs, cfg.maxAssetBytes); + const docText = await extractTextFromDocumentBuffer(buffer, docUrl); + if (docText.length >= 10) { + sections.push(`=== DOCUMENT: ${docUrl} ===\n${docText}`); + stats.assets += 1; + } + } catch { + stats.failed += 1; + } + } + + return { + text: sections.join('\n\n').trim(), + stats, + scope: { mode: scope.mode, pathPrefix: scope.pathPrefix, hostname: scope.hostname }, + }; +} + +/** + * Single-page extract (backward compatible). + * @param {string} url + */ +async function extractTextFromUrl(url) { + const { text } = await crawlUrlSource(url, { scopeMode: 'auto' }); + return text; +} + +module.exports = { + resolveCrawlScope, + normalizeUrl, + isInScope, + isAllowedDocumentUrl, + shouldSkipAsHtmlPage, + discoverLinksFromHtml: (html, pageUrl, scope) => { + const $ = cheerio.load(html); + return discoverLinks($, pageUrl, scope); + }, + extractHtmlText, + crawlUrlSource, + extractTextFromUrl, + assertPublicUrl, +}; diff --git a/src/utils/conciergeUrlCrawl.test.js b/src/utils/conciergeUrlCrawl.test.js new file mode 100644 index 0000000..d26a624 --- /dev/null +++ b/src/utils/conciergeUrlCrawl.test.js @@ -0,0 +1,93 @@ +const { + resolveCrawlScope, + normalizeUrl, + isInScope, + isAllowedDocumentUrl, + discoverLinksFromHtml, +} = require('./conciergeUrlCrawl'); + +describe('resolveCrawlScope', () => { + it('uses domain scope for site root', () => { + const s = resolveCrawlScope('https://example.com/'); + expect(s.mode).toBe('domain'); + expect(s.hostname).toBe('example.com'); + }); + + it('uses path scope for folder URLs', () => { + const s = resolveCrawlScope('https://example.com/blog/'); + expect(s.mode).toBe('path'); + expect(s.pathPrefix).toBe('/blog'); + }); + + it('uses path scope for index.html in folder', () => { + const s = resolveCrawlScope('https://example.com/blog/index.html'); + expect(s.mode).toBe('path'); + expect(s.pathPrefix).toBe('/blog'); + }); + + it('honors explicit domain scope', () => { + const s = resolveCrawlScope('https://example.com/blog/', 'domain'); + expect(s.mode).toBe('domain'); + }); +}); + +describe('isInScope', () => { + const pathScope = resolveCrawlScope('https://example.com/docs/'); + + it('accepts URLs under path prefix', () => { + expect(isInScope('https://example.com/docs/guide', pathScope)).toBe(true); + }); + + it('rejects URLs outside path prefix', () => { + expect(isInScope('https://example.com/other/page', pathScope)).toBe(false); + }); + + it('rejects other hosts', () => { + expect(isInScope('https://other.com/docs/page', pathScope)).toBe(false); + }); +}); + +describe('normalizeUrl', () => { + it('resolves relative links', () => { + expect(normalizeUrl('/about', 'https://example.com/blog/')).toBe('https://example.com/about'); + }); + + it('strips hash fragments', () => { + expect(normalizeUrl('https://example.com/a#section', 'https://example.com')).toBe( + 'https://example.com/a', + ); + }); + + it('ignores mailto links', () => { + expect(normalizeUrl('mailto:test@example.com', 'https://example.com')).toBeNull(); + }); +}); + +describe('discoverLinksFromHtml', () => { + const scope = resolveCrawlScope('https://example.com/'); + + it('discovers in-scope pages and document links', () => { + const html = ` + + About + PDF + Deck + External + + `; + const { pages, documents } = discoverLinksFromHtml(html, 'https://example.com/', scope); + expect(pages).toContain('https://example.com/about'); + expect(documents).toContain('https://example.com/files/guide.pdf'); + expect(documents).toContain('https://example.com/deck.pptx'); + expect(pages.some((u) => u.includes('other.com'))).toBe(false); + }); +}); + +describe('isAllowedDocumentUrl', () => { + it('detects pdf and office extensions', () => { + expect(isAllowedDocumentUrl('https://example.com/a.pdf')).toBe(true); + expect(isAllowedDocumentUrl('https://example.com/b.docx')).toBe(true); + expect(isAllowedDocumentUrl('https://example.com/c.pptx')).toBe(true); + expect(isAllowedDocumentUrl('https://example.com/page.html')).toBe(false); + }); +}); diff --git a/src/utils/csvAttendance.js b/src/utils/csvAttendance.js new file mode 100644 index 0000000..0ccd675 --- /dev/null +++ b/src/utils/csvAttendance.js @@ -0,0 +1,44 @@ +const { escapeCsvField } = require('./csvParticipant'); + +const EXPORT_HEADERS = [ + 'attendanceName', + 'firstName', + 'lastName', + 'email', + 'phone', + 'checkedInAt', + 'source', + 'checkInNumber', +]; + +function buildAttendanceCheckInsCsv(attendanceName, rows) { + const headerLine = EXPORT_HEADERS.join(','); + const dataLines = []; + + for (const row of rows) { + const checkIns = row.checkIns || []; + if (!checkIns.length) continue; + checkIns.forEach((ci, idx) => { + const line = [ + attendanceName, + row.firstName, + row.lastName, + row.email, + row.phone, + ci.checkedInAt ? new Date(ci.checkedInAt).toISOString() : '', + ci.source || '', + String(idx + 1), + ] + .map(escapeCsvField) + .join(','); + dataLines.push(line); + }); + } + + return `\uFEFF${[headerLine, ...dataLines].join('\n')}`; +} + +module.exports = { + EXPORT_HEADERS, + buildAttendanceCheckInsCsv, +}; diff --git a/src/utils/eventAgendaData.js b/src/utils/eventAgendaData.js new file mode 100644 index 0000000..01c5685 --- /dev/null +++ b/src/utils/eventAgendaData.js @@ -0,0 +1,129 @@ +const Session = require('../models/Session'); +const Speaker = require('../models/Speaker'); +const Track = require('../models/Track'); + +function formatSessionTimeRange(start, end) { + if (!start) return ''; + const a = new Date(start).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); + if (!end) return a; + const b = new Date(end).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); + return `${a} – ${b}`; +} + +function speakerDisplayName(sp) { + if (!sp || typeof sp !== 'object' || !('firstName' in sp)) return ''; + return [sp.firstName, sp.lastName].filter(Boolean).join(' '); +} + +/** + * @param {object} session lean Session with populated track, speakers, moderator + * @param {{ fullDetail?: boolean }} opts + */ +function mapSessionToAgendaItem(session, opts = {}) { + const populatedSpeakers = (session.speakers || []).filter( + (sp) => sp && typeof sp === 'object' && 'firstName' in sp, + ); + const speakerNames = populatedSpeakers.map(speakerDisplayName).filter(Boolean); + const firstSp = populatedSpeakers[0] || null; + const trackLabel = + session.location || + (session.track && typeof session.track === 'object' ? session.track.name : '') || + ''; + + const item = { + title: session.title, + time: formatSessionTimeRange(session.startTime, session.endTime), + track: trackLabel, + description: session.description || '', + speakerName: speakerNames[0] || '', + speakerPhotoUrl: firstSp?.photo ? String(firstSp.photo) : '', + }; + + if (opts.fullDetail) { + const mod = + session.moderator && typeof session.moderator === 'object' && 'firstName' in session.moderator + ? session.moderator + : null; + item.speakerNames = speakerNames; + item.moderatorName = speakerDisplayName(mod); + item.sessionType = session.type || ''; + item.status = session.status || ''; + item.startTime = session.startTime; + item.endTime = session.endTime; + item.location = session.location || ''; + item.trackName = + session.track && typeof session.track === 'object' ? session.track.name || '' : ''; + item.joinCode = session.joinCode || ''; + item.resources = (session.resources || []) + .filter((r) => r && (r.name || r.url)) + .map((r) => ({ name: r.name || '', url: r.url || '' })); + } + + return item; +} + +/** + * @param {import('mongoose').Types.ObjectId | string} eventId + * @returns {Promise} + */ +async function loadTracks(eventId) { + return Track.find({ event: eventId }).sort({ sortOrder: 1, name: 1 }).lean(); +} + +/** + * @param {import('mongoose').Types.ObjectId | string} eventId + * @param {{ speakerVisibility?: 'public' | 'all', fullDetail?: boolean }} [options] + * @returns {Promise<{ agendaItems: object[], speakers: object[], speakerRecords: object[] }>} + */ +async function loadAgendaAndSpeakers(eventId, options = {}) { + const speakerVisibility = options.speakerVisibility === 'all' ? 'all' : 'public'; + const fullDetail = !!options.fullDetail; + + const sessions = await Session.find({ event: eventId }) + .populate('track') + .populate('speakers') + .populate('moderator') + .sort({ startTime: 1 }) + .lean(); + + const speakerIds = [ + ...new Set( + sessions.flatMap((s) => + (s.speakers || []) + .map((sp) => { + if (!sp) return ''; + if (typeof sp === 'object' && sp._id) return String(sp._id); + return String(sp); + }) + .filter(Boolean), + ), + ), + ]; + + let speakerList = []; + if (speakerIds.length) { + const speakerFilter = { _id: { $in: speakerIds } }; + if (speakerVisibility === 'public') { + speakerFilter.profileVisibility = 'public'; + } + speakerList = await Speaker.find(speakerFilter).lean(); + } + + const agendaItems = sessions.map((s) => mapSessionToAgendaItem(s, { fullDetail })); + + const speakers = speakerList.map((s) => ({ + name: speakerDisplayName(s), + designation: s.title || s.organizationName || '', + photoUrl: s.photo || '', + })); + + return { agendaItems, speakers, speakerRecords: speakerList }; +} + +module.exports = { + formatSessionTimeRange, + speakerDisplayName, + mapSessionToAgendaItem, + loadTracks, + loadAgendaAndSpeakers, +}; diff --git a/src/utils/eventRatingsSummary.js b/src/utils/eventRatingsSummary.js new file mode 100644 index 0000000..9ec8286 --- /dev/null +++ b/src/utils/eventRatingsSummary.js @@ -0,0 +1,101 @@ +const mongoose = require('mongoose'); +const Form = require('../models/Form'); +const FormSubmission = require('../models/FormSubmission'); + +function isStarRatingField(field) { + if (!field || !field.type) return false; + const t = String(field.type).toLowerCase(); + if (t === 'stars') return true; + if (t === 'select' && Array.isArray(field.options) && field.options.length === 5) { + const opts = field.options.map((x) => String(x).trim()); + return opts.every((v, i) => v === String(i + 1)); + } + return false; +} + +function submissionDataToObject(data) { + if (!data) return {}; + if (data instanceof Map) return Object.fromEntries(data); + return typeof data === 'object' && !Array.isArray(data) ? { ...data } : {}; +} + +function parseStarValue(raw) { + if (raw == null) return null; + if (typeof raw === 'number' && Number.isFinite(raw)) { + const n = Math.round(raw); + if (n >= 1 && n <= 10) return n; + return null; + } + const s = String(raw).trim(); + if (!s) return null; + const n = parseInt(s, 10); + if (Number.isNaN(n) || n < 1 || n > 10) return null; + return n; +} + +/** + * Average star rating for one event from feedback/survey form submissions only. + * @returns {{ average: number, count: number }} count = submissions with at least one star answer + */ +async function computeEventRatingsSummary(eventId) { + const empty = { average: 0, count: 0 }; + if (!eventId || !mongoose.Types.ObjectId.isValid(String(eventId))) return empty; + + const eventOid = new mongoose.Types.ObjectId(String(eventId)); + const forms = await Form.find({ + event: eventOid, + type: { $in: ['feedback', 'survey', 'custom', 'other'] }, + }) + .select('_id fields') + .lean(); + + if (!forms.length) return empty; + + const ratingLabelsByFormId = new Map(); + for (const form of forms) { + const labels = (form.fields || []) + .filter(isStarRatingField) + .map((f) => String(f.label || '').trim()) + .filter(Boolean); + if (labels.length) ratingLabelsByFormId.set(String(form._id), labels); + } + + const formIds = [...ratingLabelsByFormId.keys()].map((id) => new mongoose.Types.ObjectId(id)); + if (!formIds.length) return empty; + + const submissions = await FormSubmission.find({ + form: { $in: formIds }, + $or: [{ event: eventOid }, { event: null }, { event: { $exists: false } }], + }) + .select('form data event') + .lean(); + + const submissionAverages = []; + for (const sub of submissions) { + if (sub.event && String(sub.event) !== String(eventId)) continue; + const labels = ratingLabelsByFormId.get(String(sub.form)); + if (!labels?.length) continue; + const data = submissionDataToObject(sub.data); + const scores = []; + for (const label of labels) { + const v = parseStarValue(data[label]); + if (v != null) scores.push(v); + } + if (scores.length) { + submissionAverages.push(scores.reduce((a, b) => a + b, 0) / scores.length); + } + } + + if (!submissionAverages.length) return empty; + const average = submissionAverages.reduce((a, b) => a + b, 0) / submissionAverages.length; + return { + average: Math.round(average * 10) / 10, + count: submissionAverages.length, + }; +} + +module.exports = { + computeEventRatingsSummary, + isStarRatingField, + parseStarValue, +}; diff --git a/src/utils/eventTicketPricing.js b/src/utils/eventTicketPricing.js new file mode 100644 index 0000000..866339a --- /dev/null +++ b/src/utils/eventTicketPricing.js @@ -0,0 +1,44 @@ +/** + * @param {object} ticket - EventTicket lean/doc with taxItems[] + * @param {number} quantity + * @param {string} [billingStateCode] - uppercase state code, e.g. KA + * @returns {{ baseMinor: number, taxMinor: number, totalMinor: number, taxLines: Array<{label: string, rateBps: number, amountMinor: number}> }} + */ +function computeTicketQuote(ticket, quantity, billingStateCode) { + const qty = Math.max(1, Math.floor(Number(quantity) || 1)); + const baseMinor = Math.round(Number(ticket.basePriceMinor) || 0) * qty; + const state = billingStateCode ? String(billingStateCode).trim().toUpperCase() : ''; + + const taxLines = []; + let taxMinor = 0; + for (const item of ticket.taxItems || []) { + const states = Array.isArray(item.appliesToStates) ? item.appliesToStates : []; + const appliesAll = states.length === 0; + const appliesHere = appliesAll || (state && states.map((s) => String(s).toUpperCase()).includes(state)); + if (!appliesHere) continue; + const rateBps = Math.max(0, Math.min(100000, Number(item.rateBps) || 0)); + const amount = Math.round((baseMinor * rateBps) / 10000); + taxMinor += amount; + taxLines.push({ + label: item.label, + rateBps, + amountMinor: amount, + }); + } + + return { + baseMinor, + taxMinor, + totalMinor: baseMinor + taxMinor, + taxLines, + }; +} + +/** + * True if any tax row is restricted to specific states (billing state then required for quote/checkout). + */ +function ticketRequiresBillingState(ticket) { + return (ticket.taxItems || []).some((t) => Array.isArray(t.appliesToStates) && t.appliesToStates.length > 0); +} + +module.exports = { computeTicketQuote, ticketRequiresBillingState }; diff --git a/src/utils/eventTicketingGateway.js b/src/utils/eventTicketingGateway.js new file mode 100644 index 0000000..a4cd883 --- /dev/null +++ b/src/utils/eventTicketingGateway.js @@ -0,0 +1,20 @@ +const { getOrCreateProfile } = require('../services/payments/organizationPaymentProfileService'); +const { resolveTicketPurchaseCheckout } = require('../services/payments/resolver'); + +/** + * True when the organiser can accept ticket payments online via their own Razorpay account. + * Platform Razorpay is never used for ticket checkout. + * @param {import('mongoose').Types.ObjectId|string} organizationId + */ +async function organizationHasOnlineTicketCheckout(organizationId) { + if (!organizationId) return false; + try { + const profile = await getOrCreateProfile(organizationId); + await resolveTicketPurchaseCheckout(profile); + return true; + } catch { + return false; + } +} + +module.exports = { organizationHasOnlineTicketCheckout }; diff --git a/src/utils/mail.js b/src/utils/mail.js index 483b017..961d6df 100644 --- a/src/utils/mail.js +++ b/src/utils/mail.js @@ -1,14 +1,16 @@ const nodemailer = require('nodemailer'); +const Organization = require('../models/Organization'); +const { decrypt } = require('./smtpCrypto'); -let transporter; +let defaultTransporter; +const orgTransportCache = new Map(); /** MAIL_* (SendGrid / Laravel-style) with SMTP_* fallback */ function resolveSmtp() { const host = process.env.MAIL_HOST || process.env.SMTP_HOST; const port = parseInt(process.env.MAIL_PORT || process.env.SMTP_PORT || '587', 10); const enc = String(process.env.MAIL_ENCRYPTION || '').toLowerCase(); - const secure = - enc === 'ssl' || port === 465 || process.env.SMTP_SECURE === 'true'; + const secure = enc === 'ssl' || port === 465 || process.env.SMTP_SECURE === 'true'; const user = process.env.MAIL_USERNAME || process.env.SMTP_USER; const pass = process.env.MAIL_PASSWORD || process.env.SMTP_PASS; return { host, port, secure, user, pass }; @@ -30,22 +32,111 @@ function getMailFrom() { return addr; } -function getTransporter() { - if (transporter) return transporter; +function formatFrom(name, addr) { + const a = String(addr || '').trim(); + if (!a) return getMailFrom(); + const n = String(name || '').replace(/^["']|["']$/g, '').trim(); + if (n) { + const safe = n.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return `"${safe}" <${a}>`; + } + return a; +} + +function getDefaultTransporter() { + if (defaultTransporter !== undefined) return defaultTransporter; const { host, port, secure, user, pass } = resolveSmtp(); - if (!host) return null; - transporter = nodemailer.createTransport({ + if (!host) { + defaultTransporter = null; + return null; + } + defaultTransporter = nodemailer.createTransport({ host, port, secure, auth: user ? { user, pass: pass || '' } : undefined, }); - return transporter; + return defaultTransporter; +} + +/** @deprecated use getDefaultTransporter */ +function getTransporter() { + return getDefaultTransporter(); +} + +function orgFromAddress(org) { + const s = org?.smtp; + if (s?.enabled && s.fromAddress) return formatFrom(s.fromName, s.fromAddress); + return getMailFrom(); } /** - * @returns {Promise} + * @param {import('mongoose').Types.ObjectId|string} orgId + * @returns {Promise} */ +async function getTransporterForOrganization(orgId) { + if (!orgId) return getDefaultTransporter(); + const key = String(orgId); + if (orgTransportCache.has(key)) return orgTransportCache.get(key); + + const org = await Organization.findById(orgId).select('smtp').lean(); + const s = org?.smtp; + if (!s?.enabled || !s.host) { + const d = getDefaultTransporter(); + orgTransportCache.set(key, d); + return d; + } + const port = s.port || 587; + const pass = s.passwordEnc ? decrypt(s.passwordEnc) : ''; + const t = nodemailer.createTransport({ + host: s.host, + port, + secure: Boolean(s.secure), + auth: s.user ? { user: s.user, pass: pass || '' } : undefined, + }); + orgTransportCache.set(key, t); + return t; +} + +function clearOrgTransportCache(orgId) { + if (orgId) orgTransportCache.delete(String(orgId)); + else orgTransportCache.clear(); +} + +/** + * Campaign / queue worker send + * @param {{ organizationId: string, to: string, subject: string, html: string, text?: string }} opts + */ +async function sendOrgMail({ organizationId, to, subject, html, text }) { + const from = organizationId ? orgFromAddress(await Organization.findById(organizationId).lean()) : getMailFrom(); + const t = organizationId ? await getTransporterForOrganization(organizationId) : getDefaultTransporter(); + if (!t) { + console.log('[sendOrgMail skipped — no SMTP] to=', to, 'subject=', subject); + return { ok: false, skipped: true }; + } + try { + await t.sendMail({ + from, + to, + subject, + text: text || undefined, + html, + }); + return { ok: true }; + } catch (e) { + console.error('sendOrgMail', e.message); + return { ok: false, error: e.message }; + } +} + +function escapeHtml(s) { + return String(s || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + async function sendEventInviteEmail({ to, acceptUrl, eventName, orgName, role }) { const from = getMailFrom(); const subject = `You're invited to help with "${eventName}"`; @@ -67,7 +158,7 @@ This link expires in 14 days.

This link expires in 14 days.

— EventBuzz

`; - const t = getTransporter(); + const t = getDefaultTransporter(); if (!t) { console.log('[EventInvite email skipped — set MAIL_HOST or SMTP_HOST]\nTo:', to, '\nLink:', acceptUrl); return true; @@ -81,14 +172,6 @@ This link expires in 14 days. } } -function escapeHtml(s) { - return String(s || '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - async function sendVisitorOtpEmail({ to, otp, eventName }) { const from = getMailFrom(); const name = eventName || 'your event'; @@ -104,7 +187,7 @@ It expires in 10 minutes.

This code expires in 10 minutes.

— EventBuzz

`; - const t = getTransporter(); + const t = getDefaultTransporter(); if (!t) { console.log(`[visitor OTP — no MAIL_HOST/SMTP_HOST] to=${to} code=${otp}`); return true; @@ -134,7 +217,7 @@ ${magicLinkUrl}

This link expires in 15 minutes.

— EventBuzz

`; - const t = getTransporter(); + const t = getDefaultTransporter(); if (!t) { console.log(`[visitor magic link — no MAIL_HOST/SMTP_HOST] to=${to}\n${magicLinkUrl}`); return true; @@ -148,19 +231,7 @@ ${magicLinkUrl} } } -/** - * Microsite "Contact us" form: notify organizer-defined email (published static sites). - * @param {{ to: string, eventName?: string, visitorEmail: string, firstName?: string, lastName?: string, message?: string }} opts - * @returns {Promise} - */ -async function sendMicrositeContactEmail({ - to, - eventName, - visitorEmail, - firstName, - lastName, - message, -}) { +async function sendMicrositeContactEmail({ to, eventName, visitorEmail, firstName, lastName, message }) { const from = getMailFrom(); const who = [firstName, lastName].filter(Boolean).join(' ').trim() || visitorEmail; const subject = `Microsite contact: ${eventName || 'Event'}`; @@ -175,7 +246,7 @@ ${message || '(no message)'}

Event: ${escapeHtml(eventName || '—')}

— EventBuzz microsite

`; - const t = getTransporter(); + const t = getDefaultTransporter(); if (!t) { console.log(`[microsite contact — no MAIL_HOST/SMTP_HOST] to=${to}\n${text}`); return true; @@ -189,9 +260,66 @@ ${message || '(no message)'} } } +async function sendContactSalesLeadEmail({ + to, + requesterEmail, + requesterName, + organizationName, + planId, + planName, + requirements, +}) { + const from = getMailFrom(); + const subject = `[EventBuzz] Contact sales — ${planName || planId || 'Enterprise'} (${organizationName || 'Organisation'})`; + const who = [requesterName, requesterEmail].filter(Boolean).join(' — ') || requesterEmail; + const text = `A team requested a contact-sales / enterprise conversation. + +Organisation: ${organizationName || '—'} +Plan interest: ${planName || planId || '—'} (${planId || '—'}) +From: ${who} + +Requirements / message: +${requirements} + +— EventBuzz billing`; + + const html = `

Contact sales lead

+

Organisation: ${escapeHtml(organizationName || '—')}
+Plan interest: ${escapeHtml(planName || planId || '—')} ${escapeHtml(planId || '')}
+From: ${escapeHtml(requesterName || '')} <${escapeHtml(requesterEmail)}>

+

${escapeHtml(requirements)}

+

— EventBuzz billing

`; + + const t = getDefaultTransporter(); + const toStr = Array.isArray(to) ? to.filter(Boolean).join(', ') : String(to || ''); + if (!toStr) { + console.log('[Contact sales — no recipients]\n', text); + return false; + } + if (!t) { + console.log(`[Contact sales — no MAIL_HOST/SMTP_HOST] to=${toStr}\n${text}`); + return true; + } + try { + await t.sendMail({ from, to: toStr, subject, text, html }); + return true; + } catch (e) { + console.error('sendContactSalesLeadEmail', e.message); + return false; + } +} + module.exports = { + getMailFrom, + getDefaultTransporter, + getTransporter, + getTransporterForOrganization, + orgFromAddress, + sendOrgMail, + clearOrgTransportCache, sendEventInviteEmail, sendVisitorOtpEmail, sendVisitorMagicLinkEmail, sendMicrositeContactEmail, + sendContactSalesLeadEmail, }; diff --git a/src/utils/manualTicketingFee.js b/src/utils/manualTicketingFee.js new file mode 100644 index 0000000..78a9602 --- /dev/null +++ b/src/utils/manualTicketingFee.js @@ -0,0 +1,9 @@ +/** One-time org fee (INR major) to unlock manual UPI ticketing per event — recorded in billing. */ +function defaultManualEnablementFeeInrFromEnv() { + const n = parseInt(process.env.EVENT_TICKETING_MANUAL_ENABLEMENT_FEE_INR || '500', 10); + return Number.isFinite(n) && n >= 0 ? n : 500; +} + +module.exports = { + defaultManualEnablementFeeInrFromEnv, +}; diff --git a/src/utils/metaWhatsApp.js b/src/utils/metaWhatsApp.js new file mode 100644 index 0000000..7acff23 --- /dev/null +++ b/src/utils/metaWhatsApp.js @@ -0,0 +1,660 @@ +const { decrypt } = require('./smtpCrypto'); + +function normalizeVersion(v) { + const raw = v != null && String(v).trim() !== '' ? String(v).trim() : 'v21.0'; + let s = raw; + if (!/^v\d/i.test(s)) s = `v${s}`; + return s.toLowerCase(); +} + +function graphBaseUrl() { + return `https://graph.facebook.com/${normalizeVersion(process.env.META_GRAPH_API_VERSION)}`; +} + +/** + * @param {string} organizationId + * @returns {Promise<{ wabaId: string, accessToken: string, phoneNumberId: string } | null>} + */ +async function getOrgWhatsAppCredentials(organizationId) { + const Organization = require('../models/Organization'); + const org = await Organization.findById(organizationId).lean(); + if (!org) return null; + const wc = org.whatsappCloud || {}; + if (wc.enabled && wc.wabaId) { + const token = decrypt(wc.accessTokenEnc || ''); + if (token) { + return { + wabaId: String(wc.wabaId).trim(), + accessToken: token, + phoneNumberId: wc.phoneNumberId ? String(wc.phoneNumberId).trim() : '', + }; + } + } + const dw = (process.env.META_WHATSAPP_DEFAULT_WABA_ID || '').trim(); + const dt = (process.env.META_WHATSAPP_ACCESS_TOKEN || '').trim(); + if (dw && dt) { + return { + wabaId: dw, + accessToken: dt, + phoneNumberId: (process.env.META_WHATSAPP_DEFAULT_PHONE_NUMBER_ID || '').trim(), + }; + } + return null; +} + +function graphErrorMessage(data, fallback) { + if (!data || typeof data !== 'object') return fallback; + const e = data.error; + if (e && typeof e === 'object') { + return e.error_user_msg || e.message || e.error_user_title || fallback; + } + return fallback; +} + +/** + * Parse Graph JSON with numeric `id` fields coerced to strings so large WABA / phone ids are not + * corrupted by JavaScript Number (e.g. 9163703890405324 > Number.MAX_SAFE_INTEGER). + * @param {string} text + */ +function parseGraphJsonSafeIds(text) { + const sanitized = String(text || '').replace(/"id"\s*:\s*(\d+)/g, '"id":"$1"'); + return JSON.parse(sanitized); +} + +function normalizeMetaId(v) { + if (v == null) return ''; + return String(v).trim(); +} + +function skipPhoneWabaGraphCheck() { + const v = String(process.env.META_WHATSAPP_SKIP_PHONE_WABA_CHECK || '').trim().toLowerCase(); + return v === '1' || v === 'true'; +} + +function metaDebugEnabled() { + const d = String(process.env.META_WHATSAPP_DEBUG || '').trim(); + const l = String(process.env.META_WHATSAPP_LOG || '').trim().toLowerCase(); + return d === '1' || d === 'true' || l === 'true' || l === '1'; +} + +/** Max JSON chars per log line block (avoid huge stdout). Override with META_WHATSAPP_LOG_MAX_CHARS. */ +function metaLogMaxChars() { + const n = parseInt(process.env.META_WHATSAPP_LOG_MAX_CHARS || '250000', 10); + return Number.isFinite(n) && n > 1000 ? n : 250000; +} + +function stringifyForMetaLog(value) { + try { + if (typeof value === 'string') return value; + return JSON.stringify(value, null, 0); + } catch { + return String(value); + } +} + +function truncateMetaLog(str) { + const max = metaLogMaxChars(); + if (str.length <= max) return str; + return `${str.slice(0, max)}\n… [metaWhatsApp log truncated: ${str.length - max} more chars; raise META_WHATSAPP_LOG_MAX_CHARS]`; +} + +function logMeta(label, payload) { + if (!metaDebugEnabled()) return; + try { + const line = typeof payload === 'string' ? payload : stringifyForMetaLog(payload); + console.log(`[metaWhatsApp] ${label}`, truncateMetaLog(line)); + } catch { + console.log(`[metaWhatsApp] ${label}`, payload); + } +} + +/** + * One logical Graph call: request + response (never logs tokens). + * @param {string} operation e.g. "POST message_templates" + * @param {object} request { method, url, headers?, body? } — body is JSON-serializable or Buffer length only + * @param {object} response { httpStatus, ok, body } + */ +function logMetaGraphExchange(operation, request, response) { + if (!metaDebugEnabled()) return; + const reqOut = { + method: request.method, + url: request.url, + ...(request.headers && Object.keys(request.headers).length ? { headers: request.headers } : {}), + ...(request.body !== undefined + ? { body: request.body } + : request.bodyBytes != null + ? { body: `[binary ${request.bodyBytes} bytes]` } + : {}), + }; + logMeta(`${operation} request`, reqOut); + logMeta(`${operation} response`, { + httpStatus: response.httpStatus, + ok: response.ok, + body: response.body, + }); +} + +/** + * Meta: parameters cannot be leading or trailing in BODY / HEADER (text) / button label. + * Trailing/leading punctuation and spaces do NOT count—e.g. "... for {{2}}." is still rejected + * because after stripping ". " the string ends with }}. + * Not applied to URL button `url` (dynamic suffix allowed). + */ +const PARAM_AT_START = /^\{\{\s*\d+\s*\}\}/; +const PARAM_AT_END = /\{\{\s*\d+\s*\}\}$/; +const HAS_PARAM = /\{\{\s*\d+\s*\}\}/; +/** Strip chars Meta ignores when deciding if a param is “at the edge”. */ +const LEAD_EDGE_NOISE = /^[.,;:!?…'"«»""''()\[\]\s\u2013\u2014\-]+/u; +const TRAIL_EDGE_NOISE = /[.,;:!?…'"«»""''()\[\]\s\u2013\u2014\-]+$/u; + +function validationError(message) { + const err = new Error(message); + err.status = 400; + err.code = 'WA_TEMPLATE_RULE'; + return err; +} + +function assertParamNotAtTextEdges(text, fieldLabel) { + const t = String(text || '').trim(); + if (!t || !HAS_PARAM.test(t)) return; + const forStart = t.replace(LEAD_EDGE_NOISE, ''); + if (PARAM_AT_START.test(forStart)) { + throw validationError( + `${fieldLabel}: WhatsApp does not allow a variable at the very start. Put real words before the first {{1}} (not only punctuation).`, + ); + } + const forEnd = t.replace(TRAIL_EDGE_NOISE, ''); + if (PARAM_AT_END.test(forEnd)) { + throw validationError( + `${fieldLabel}: WhatsApp does not allow a variable at the end with only punctuation after it. Example: change "…for {{2}}." to "…for {{2}} We look forward to seeing you." (add words after the last variable).`, + ); + } +} + +/** + * Run before POST message_templates to match common Meta rejections (clearer than API error). + * @param {unknown[]} components + */ +function validateTemplateComponentsForMeta(components) { + if (!Array.isArray(components)) return; + for (const c of components) { + if (!c || typeof c !== 'object') continue; + const type = String(c.type || '').toUpperCase(); + const format = String(c.format || '').toUpperCase(); + + if (type === 'BODY' && c.text != null) { + assertParamNotAtTextEdges(c.text, 'BODY'); + } + if (type === 'HEADER' && format === 'TEXT' && c.text != null) { + assertParamNotAtTextEdges(c.text, 'HEADER (text)'); + } + if (type === 'FOOTER' && c.text != null) { + const ft = String(c.text || ''); + if (HAS_PARAM.test(ft)) { + throw validationError( + 'FOOTER: WhatsApp does not allow variables in the footer. Use static text only or leave the footer empty.', + ); + } + } + if (type === 'BUTTONS' && Array.isArray(c.buttons)) { + for (let i = 0; i < c.buttons.length; i += 1) { + const b = c.buttons[i]; + if (b && typeof b === 'object' && b.text != null) { + assertParamNotAtTextEdges(b.text, `BUTTON #${i + 1} label`); + } + } + } + } +} + +function isHttpUrl(s) { + return /^https?:\/\//i.test(String(s || '').trim()); +} + +function metaAppId() { + return String(process.env.META_WHATSAPP_APP_ID || process.env.META_APP_ID || '').trim(); +} + +function templateHeaderUploadMaxBytes() { + const n = Number(process.env.META_WHATSAPP_HEADER_UPLOAD_MAX_BYTES); + if (Number.isFinite(n) && n > 0) return n; + return 5 * 1024 * 1024; +} + +function safeFileNameFromUrl(url) { + try { + const u = new URL(url); + const base = u.pathname.split('/').pop() || 'header'; + const cleaned = base.replace(/[^a-zA-Z0-9._-]+/g, '_'); + return (cleaned || 'header').slice(0, 120); + } catch { + return 'header.bin'; + } +} + +/** @param {string} contentType @param {string} fileName @param {string} url */ +function mimeForTemplateUpload(contentType, fileName, url) { + const c = String(contentType || '').toLowerCase(); + if (c.includes('png')) return 'image/png'; + if (c.includes('jpeg') || c.includes('jpg')) return 'image/jpeg'; + const lower = `${fileName || ''} ${url || ''}`.toLowerCase(); + if (lower.includes('.png')) return 'image/png'; + if (lower.includes('.jpg') || lower.includes('.jpeg')) return 'image/jpeg'; + return 'image/png'; +} + +async function fetchBinaryFromUrl(url) { + const res = await fetch(url, { redirect: 'follow' }); + if (!res.ok) { + const err = new Error(`Could not download header image (${res.status})`); + err.status = 400; + err.code = 'WA_TEMPLATE_RULE'; + throw err; + } + const buf = Buffer.from(await res.arrayBuffer()); + const max = templateHeaderUploadMaxBytes(); + if (buf.length > max) { + throw validationError( + `HEADER (image): file is too large for Meta upload (max ${Math.round(max / 1024 / 1024)}MB).`, + ); + } + const ct = res.headers.get('content-type') || ''; + return { buffer: buf, contentType: ct.split(';')[0].trim() }; +} + +/** + * Meta template IMAGE examples need a resumable-upload handle, not a public URL. + * @see https://developers.facebook.com/docs/graph-api/guides/upload/ + */ +async function metaResumableUploadForTemplate(accessToken, appId, buffer, fileName, fileType) { + const base = graphBaseUrl(); + const sessionUrl = `${base}/${encodeURIComponent(appId)}/uploads`; + const sessionPayload = { + file_length: buffer.length, + file_name: fileName, + file_type: fileType, + }; + const sRes = await fetch(sessionUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(sessionPayload), + }); + const sData = await sRes.json().catch(() => ({})); + logMetaGraphExchange('POST /{app-id}/uploads (session)', { + method: 'POST', + url: sessionUrl, + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ***' }, + body: sessionPayload, + }, { httpStatus: sRes.status, ok: sRes.ok, body: sData }); + if (!sRes.ok || !sData.id) { + const msg = graphErrorMessage(sData, 'Meta upload session failed'); + const err = new Error(msg); + err.status = sRes.status; + err.raw = sData; + throw err; + } + const uploadSessionId = String(sData.id); + const uploadUrl = `${base}/${uploadSessionId}`; + const uRes = await fetch(uploadUrl, { + method: 'POST', + headers: { + // Graph resumable upload step 2 expects OAuth-scheme token for some apps; matches working integrations. + Authorization: `OAuth ${accessToken}`, + file_offset: '0', + }, + body: buffer, + }); + const uData = await uRes.json().catch(() => ({})); + logMetaGraphExchange('POST /{upload-session-id} (binary)', { + method: 'POST', + url: uploadUrl, + headers: { Authorization: 'OAuth ***', file_offset: '0' }, + bodyBytes: buffer.length, + }, { httpStatus: uRes.status, ok: uRes.ok, body: uData }); + if (!uRes.ok || !uData.h) { + const msg = graphErrorMessage(uData, 'Meta media upload failed'); + const err = new Error(msg); + err.status = uRes.status; + err.raw = uData; + throw err; + } + return String(uData.h); +} + +/** + * Replace any https URLs in HEADER IMAGE `example.header_handle` with Meta upload handles. + * Leaves existing handles unchanged. Mutates `components` in place. + * @param {unknown[]} components + * @param {string} accessToken + */ +async function resolveTemplateImageHeaderHandles(components, accessToken) { + if (!Array.isArray(components)) return; + const appId = metaAppId(); + for (const c of components) { + if (!c || typeof c !== 'object') continue; + const type = String(c.type || '').toUpperCase(); + const format = String(c.format || '').toUpperCase(); + if (type !== 'HEADER' || format !== 'IMAGE') continue; + const ex = c.example; + if (!ex || typeof ex !== 'object' || !Array.isArray(ex.header_handle)) continue; + const out = []; + for (const raw of ex.header_handle) { + const s = String(raw || '').trim(); + if (!s) continue; + if (!isHttpUrl(s)) { + out.push(s); + continue; + } + if (!appId) { + throw validationError( + 'HEADER (image): Meta needs a resumable upload for the sample image. Set META_WHATSAPP_APP_ID (Meta app ID) in server environment, then try again.', + ); + } + const allowed = new Set(['image/png', 'image/jpeg', 'image/jpg']); + const { buffer, contentType } = await fetchBinaryFromUrl(s); + const fileName = safeFileNameFromUrl(s); + let fileType = mimeForTemplateUpload(contentType, fileName, s); + if (fileType === 'image/jpg') fileType = 'image/jpeg'; + if (!allowed.has(fileType)) { + throw validationError( + 'HEADER (image): use a PNG or JPEG sample. Other types are not supported for WhatsApp template upload.', + ); + } + const h = await metaResumableUploadForTemplate(accessToken, appId, buffer, fileName, fileType); + out.push(h); + } + ex.header_handle = out; + } +} + +/** + * @param {string} wabaId + * @param {string} accessToken + * @param {object} body — Meta message_templates create body + */ +async function createMessageTemplate(wabaId, accessToken, body) { + const url = `${graphBaseUrl()}/${encodeURIComponent(wabaId)}/message_templates`; + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + logMetaGraphExchange('POST /{waba-id}/message_templates', { + method: 'POST', + url, + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ***' }, + body, + }, { httpStatus: res.status, ok: res.ok, body: data }); + if (!res.ok) { + const msg = graphErrorMessage(data, res.statusText || 'Meta API error'); + const err = new Error(msg); + err.status = res.status; + err.raw = data; + throw err; + } + return data; +} + +/** + * @param {string} wabaId + * @param {string} accessToken + * @param {Record} [query] + */ +/** + * Phone numbers registered on this WABA (for validating send `phone_number_id`). + * @returns {Promise<{ ok: true, phones: Array<{ id: string, display_phone_number?: string, verified_name?: string }> } | { ok: false, httpStatus: number, graphError: string, body: object }>} + */ +async function listWabaPhoneNumbers(wabaId, accessToken) { + const qs = new URLSearchParams({ + fields: 'id,display_phone_number,verified_name', + }); + const url = `${graphBaseUrl()}/${encodeURIComponent(wabaId)}/phone_numbers?${qs.toString()}`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + let data = {}; + try { + const text = await res.text(); + data = text ? parseGraphJsonSafeIds(text) : {}; + } catch { + data = {}; + } + logMetaGraphExchange('GET /{waba-id}/phone_numbers', { + method: 'GET', + url, + headers: { Authorization: 'Bearer ***' }, + }, { httpStatus: res.status, ok: res.ok, body: data }); + if (!res.ok) { + return { + ok: false, + httpStatus: res.status, + graphError: graphErrorMessage(data, res.statusText || 'Meta API error'), + body: data, + }; + } + if (!Array.isArray(data.data)) { + return { + ok: false, + httpStatus: res.status, + graphError: 'Unexpected response: missing data array from phone_numbers', + body: data, + }; + } + const phones = data.data.map((r) => ({ + ...r, + id: normalizeMetaId(r.id), + })); + return { ok: true, phones }; +} + +/** + * Fail fast when send phone is not on the same WABA as templates (Meta error #132001). + * @returns {Promise<{ ok: true } | { ok: false, message: string }>} + */ +async function assertPhoneNumberOnWaba(wabaId, phoneNumberId, accessToken) { + const pid = normalizeMetaId(phoneNumberId); + if (!pid) { + return { ok: false, message: 'WhatsApp phone number ID is missing. Set it in Settings or META_WHATSAPP_DEFAULT_PHONE_NUMBER_ID.' }; + } + const listed = await listWabaPhoneNumbers(wabaId, accessToken); + if (!listed.ok) { + if (skipPhoneWabaGraphCheck()) { + logMeta('assertPhoneNumberOnWaba', { + skipped: true, + reason: 'META_WHATSAPP_SKIP_PHONE_WABA_CHECK', + wabaId, + phoneNumberId: pid, + graphError: listed.graphError, + }); + return { ok: true }; + } + return { + ok: false, + message: `Could not verify WhatsApp phone number against WABA ${wabaId}: ${listed.graphError}. Fix token permissions or set META_WHATSAPP_SKIP_PHONE_WABA_CHECK=1 only after you confirmed the phone id belongs to this WABA (wrong phone causes Meta #132001).`, + }; + } + const rows = listed.phones; + const ids = rows.map((r) => normalizeMetaId(r.id)); + if (ids.includes(pid)) return { ok: true }; + const lines = rows.map((r) => `${r.id}${r.display_phone_number ? ` (${r.display_phone_number})` : ''}`); + const hint = lines.length + ? ` Phone number id(s) on this WABA (${wabaId}): ${lines.join('; ')}.` + : ' Meta returned no phone numbers for this WABA.'; + return { + ok: false, + message: `WhatsApp phone number id ${pid} is not registered on this WhatsApp Business Account (${wabaId}).${hint} Use one of the ids above for META_WHATSAPP_DEFAULT_PHONE_NUMBER_ID / Settings (Meta #132001).`, + }; +} + +async function listMessageTemplates(wabaId, accessToken, query = {}) { + const qs = new URLSearchParams({ + limit: '500', + fields: 'name,status,language,id,category,reason,rejected_reason', + ...query, + }); + const url = `${graphBaseUrl()}/${encodeURIComponent(wabaId)}/message_templates?${qs.toString()}`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + const data = await res.json().catch(() => ({})); + logMetaGraphExchange('GET /{waba-id}/message_templates', { + method: 'GET', + url, + headers: { Authorization: 'Bearer ***' }, + }, { httpStatus: res.status, ok: res.ok, body: data }); + if (!res.ok) { + const msg = graphErrorMessage(data, res.statusText || 'Meta API error'); + const err = new Error(msg); + err.status = res.status; + err.raw = data; + throw err; + } + return data; +} + +/** + * Parse successful POST /{phone-number-id}/messages JSON (template or other types). + * @param {object} data + * @returns {{ whatsappMessageId: string, recipientWaId: string }} + */ +function parseWhatsAppCloudMessagesSendResponse(data) { + const empty = { whatsappMessageId: '', recipientWaId: '' }; + if (!data || typeof data !== 'object') return empty; + const messages = data.messages; + const m0 = + Array.isArray(messages) && messages[0] && typeof messages[0] === 'object' ? messages[0] : null; + const whatsappMessageId = m0 && m0.id != null ? String(m0.id).trim() : ''; + const contacts = data.contacts; + const c0 = + Array.isArray(contacts) && contacts[0] && typeof contacts[0] === 'object' ? contacts[0] : null; + const rawWa = c0 && c0.wa_id != null ? String(c0.wa_id) : ''; + const recipientWaId = rawWa.replace(/\D/g, ''); + return { whatsappMessageId, recipientWaId }; +} + +/** + * Send an approved template message (Cloud API). + * @param {string} phoneNumberId + * @param {string} accessToken + * @param {string} toDigits — recipient, digits only, country code, no + + * @param {object} template — { name, language: { code }, components } + * @see https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages + */ +async function postWhatsAppMessage(phoneNumberId, accessToken, toDigits, body) { + const url = `${graphBaseUrl()}/${encodeURIComponent(phoneNumberId)}/messages`; + const payload = { + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: String(toDigits).replace(/\D/g, ''), + ...body, + }; + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(payload), + }); + let data = {}; + try { + const text = await res.text(); + data = text ? parseGraphJsonSafeIds(text) : {}; + } catch { + data = {}; + } + logMetaGraphExchange(`POST /{phone-number-id}/messages (${body.type || 'unknown'})`, { + method: 'POST', + url, + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ***' }, + body: payload, + }, { httpStatus: res.status, ok: res.ok, body: data }); + if (!res.ok) { + const msg = graphErrorMessage(data, res.statusText || 'Meta send failed'); + const err = new Error(msg); + err.status = res.status; + err.raw = data; + throw err; + } + return data; +} + +/** + * @param {string} phoneNumberId + * @param {string} accessToken + * @param {string} toDigits + * @param {string} text + */ +async function sendTextMessage(phoneNumberId, accessToken, toDigits, text) { + return postWhatsAppMessage(phoneNumberId, accessToken, toDigits, { + type: 'text', + text: { body: String(text) }, + }); +} + +/** + * @param {string} phoneNumberId + * @param {string} accessToken + * @param {string} toDigits + * @param {string} bodyText + * @param {{ id: string, title: string }[]} buttons — max 3 + */ +async function sendInteractiveButtons(phoneNumberId, accessToken, toDigits, bodyText, buttons) { + const rows = (buttons || []).slice(0, 3).map((b) => ({ + type: 'reply', + reply: { id: String(b.id), title: String(b.title).slice(0, 20) }, + })); + return postWhatsAppMessage(phoneNumberId, accessToken, toDigits, { + type: 'interactive', + interactive: { + type: 'button', + body: { text: String(bodyText) }, + action: { buttons: rows }, + }, + }); +} + +async function sendTemplateMessage(phoneNumberId, accessToken, toDigits, template) { + try { + return await postWhatsAppMessage(phoneNumberId, accessToken, toDigits, { + type: 'template', + template, + }); + } catch (err) { + const code = + err.raw && err.raw.error && typeof err.raw.error.code !== 'undefined' + ? Number(err.raw.error.code) + : NaN; + if (code === 132001) { + err.message += + ' If templates list for your WABA but send fails, the phone_number_id is usually not on that same WABA—call GET /{waba-id}/phone_numbers and use that id, or fix META_WHATSAPP_DEFAULT_PHONE_NUMBER_ID / org Settings.'; + } + throw err; + } +} + +module.exports = { + getGraphVersion: () => normalizeVersion(process.env.META_GRAPH_API_VERSION), + graphBaseUrl, + getOrgWhatsAppCredentials, + createMessageTemplate, + listMessageTemplates, + listWabaPhoneNumbers, + assertPhoneNumberOnWaba, + graphErrorMessage, + validateTemplateComponentsForMeta, + resolveTemplateImageHeaderHandles, + parseWhatsAppCloudMessagesSendResponse, + sendTemplateMessage, + sendTextMessage, + sendInteractiveButtons, + postWhatsAppMessage, + metaDebugEnabled, +}; diff --git a/src/utils/micrositeBuild.js b/src/utils/micrositeBuild.js index 94d1ab2..d63a336 100644 --- a/src/utils/micrositeBuild.js +++ b/src/utils/micrositeBuild.js @@ -1,8 +1,7 @@ const fs = require('fs/promises'); const path = require('path'); -const Session = require('../models/Session'); -const Speaker = require('../models/Speaker'); const EventPage = require('../models/EventPage'); +const { loadAgendaAndSpeakers } = require('./eventAgendaData'); const { buildSectionsHtml, escapeHtml } = require('./websiteHtml'); const { buildConferenceThemeStyleTag, buildConferenceFontLink } = require('./conferenceTheme'); const { WEBSITE_SECTION_TYPES } = require('../config/constants'); @@ -388,45 +387,6 @@ function buildSyntheticPage(micrositeSections, event, website) { }; } -function formatSessionTimeRange(start, end) { - if (!start) return ''; - const a = new Date(start).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); - if (!end) return a; - const b = new Date(end).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); - return `${a} – ${b}`; -} - -async function loadAgendaAndSpeakers(eventId) { - const [sessions, speakerList] = await Promise.all([ - Session.find({ event: eventId }).populate('track').populate('speakers').sort({ startTime: 1 }).lean(), - (async () => { - const eventSessions = await Session.find({ event: eventId }).select('speakers').lean(); - const speakerIds = [...new Set(eventSessions.flatMap((s) => (s.speakers || []).map((id) => id.toString())))]; - if (speakerIds.length === 0) return []; - return Speaker.find({ _id: { $in: speakerIds }, profileVisibility: 'public' }).lean(); - })(), - ]); - const agendaItems = sessions.map((s) => { - const rawSp = Array.isArray(s.speakers) && s.speakers[0] ? s.speakers[0] : null; - const sp = rawSp && typeof rawSp === 'object' && 'firstName' in rawSp ? rawSp : null; - const speakerName = sp ? [sp.firstName, sp.lastName].filter(Boolean).join(' ') : ''; - return { - title: s.title, - time: formatSessionTimeRange(s.startTime, s.endTime), - track: s.location || s.track?.name || s.track?.title || '', - description: s.description || '', - speakerName, - speakerPhotoUrl: sp?.photo ? String(sp.photo) : '', - }; - }); - const speakers = speakerList.map((s) => ({ - name: [s.firstName, s.lastName].filter(Boolean).join(' '), - designation: s.title || s.organizationName || '', - photoUrl: s.photo || '', - })); - return { agendaItems, speakers }; -} - /** * Compose the same HTML document the static publish step writes (conference shell + theme + main inject), * without touching the filesystem. @@ -463,7 +423,9 @@ async function buildMicrositeDocumentHtml(event, opts = {}) { 'No sections to publish. Add sections on the Website Builder Home page, or select sections under Microsite settings.', ); } - const { agendaItems, speakers } = await loadAgendaAndSpeakers(event._id); + const { agendaItems, speakers } = await loadAgendaAndSpeakers(event._id, { + speakerVisibility: 'public', + }); let registrationUrl = typeof opts.registrationUrl === 'string' && opts.registrationUrl.trim() ? opts.registrationUrl.trim() @@ -631,5 +593,6 @@ module.exports = { buildSyntheticPage, defaultConfigFor, LAYOUT_TO_FOLDER, + registrationHref, registrationUrlForForm, }; diff --git a/src/utils/parseParticipantQrPayload.js b/src/utils/parseParticipantQrPayload.js new file mode 100644 index 0000000..6b1a852 --- /dev/null +++ b/src/utils/parseParticipantQrPayload.js @@ -0,0 +1,38 @@ +const mongoose = require('mongoose'); + +const EVENTBUZZ_PREFIX = 'eventbuzz:'; + +/** + * Parse QR payload from badge or raw ObjectId. + * @param {string} data + * @returns {{ participantId: string } | { error: string }} + */ +function parseParticipantIdFromQr(data) { + const raw = String(data || '').trim(); + if (!raw) return { error: 'Empty QR code' }; + + if (raw.toLowerCase().startsWith(EVENTBUZZ_PREFIX)) { + const parts = raw.split(':'); + if (parts.length < 3) return { error: 'Invalid EventBuzz QR format' }; + const kind = parts[1].toLowerCase(); + const id = parts.slice(2).join(':').trim(); + if (kind === 'faculty') { + return { error: 'This QR is for faculty, not a participant check-in' }; + } + if (kind !== 'participant') { + return { error: `Unsupported QR type: ${kind}` }; + } + if (!mongoose.Types.ObjectId.isValid(id)) { + return { error: 'Invalid participant id in QR code' }; + } + return { participantId: String(id) }; + } + + if (/^[a-fA-F0-9]{24}$/.test(raw)) { + return { participantId: raw }; + } + + return { error: 'Unrecognized QR code format' }; +} + +module.exports = { parseParticipantIdFromQr }; diff --git a/src/utils/participantListQuery.js b/src/utils/participantListQuery.js new file mode 100644 index 0000000..0282b1b --- /dev/null +++ b/src/utils/participantListQuery.js @@ -0,0 +1,106 @@ +const mongoose = require('mongoose'); + +function mapUiStatusToApi(val) { + const s = String(val || '').toLowerCase().replace(/ /g, '_'); + if (s === 'checked_in' || s === 'checkedin') return 'checked_in'; + if (s === 'confirmed') return 'confirmed'; + if (s === 'cancelled' || s === 'canceled') return 'cancelled'; + if (s === 'pending') return 'pending'; + return String(val || '').toLowerCase(); +} + +/** + * @param {Array} rules + * @param {string} conjunction + * @param {import('mongoose').Types.ObjectId[]} allowedEventIds + * @param {import('mongoose').Types.ObjectId|string} listTenantOrg + */ +function rulesToQuery(rules, conjunction, allowedEventIds, listTenantOrg) { + if (!allowedEventIds.length) return { _id: { $in: [] } }; + const oidList = allowedEventIds.map((id) => + id instanceof mongoose.Types.ObjectId ? id : new mongoose.Types.ObjectId(String(id)) + ); + + const clauses = (Array.isArray(rules) ? rules : []) + .map((rule) => { + const field = String(rule.field || ''); + const op = String(rule.operator || 'is'); + const val = String(rule.value || '').trim(); + + if (field === 'Status') { + const st = mapUiStatusToApi(val); + return { + eventMemberships: { + $elemMatch: { + event: { $in: oidList }, + status: op === 'is not' ? { $ne: st } : st, + }, + }, + }; + } + if (field === 'Tag') { + const inAllowedEvents = { + eventMemberships: { $elemMatch: { event: { $in: oidList } } }, + }; + const legacyMembershipTag = { + eventMemberships: { + $elemMatch: { event: { $in: oidList }, tags: val }, + }, + }; + if (op === 'is not') { + return { + $and: [ + inAllowedEvents, + { tags: { $nin: [val] } }, + { + eventMemberships: { + $not: { + $elemMatch: { event: { $in: oidList }, tags: val }, + }, + }, + }, + ], + }; + } + return { + $and: [inAllowedEvents, { $or: [{ tags: val }, legacyMembershipTag] }], + }; + } + if (field === 'Event') { + if (!mongoose.Types.ObjectId.isValid(val)) return null; + const eid = new mongoose.Types.ObjectId(val); + if (!oidList.some((x) => String(x) === String(eid))) return null; + return { + eventMemberships: { + $elemMatch: { + event: op === 'is not' ? { $nin: [eid] } : eid, + }, + }, + }; + } + if (field === 'Role' || field === 'Registration Date') { + return null; + } + return null; + }) + .filter(Boolean); + + const base = { + tenantOrganization: + listTenantOrg instanceof mongoose.Types.ObjectId + ? listTenantOrg + : new mongoose.Types.ObjectId(String(listTenantOrg)), + }; + + if (!clauses.length) { + return { + ...base, + eventMemberships: { $elemMatch: { event: { $in: oidList } } }, + }; + } + + const combined = conjunction === 'OR' ? { $or: clauses } : { $and: clauses }; + return { $and: [base, combined] }; +} + +module.exports = { rulesToQuery, mapUiStatusToApi }; diff --git a/src/utils/participantMembership.js b/src/utils/participantMembership.js index 25fc6be..5308150 100644 --- a/src/utils/participantMembership.js +++ b/src/utils/participantMembership.js @@ -40,6 +40,25 @@ function flattenParticipantForEvent(participant, eventId) { }; } +/** + * For campaigns / fixed lists: prefer per-event membership slice when the contact is registered; + * otherwise use root-level fields so org contacts on a list still receive mail for that event context. + */ +function flattenParticipantForCampaignList(participant, eventId) { + const withEvent = flattenParticipantForEvent(participant, eventId); + if (withEvent) return withEvent; + const email = String(participant?.email || '').trim(); + if (!email) return null; + const p = { ...participant }; + delete p.eventMemberships; + const rootTags = Array.isArray(participant.tags) ? participant.tags : []; + return { + ...p, + event: String(eventId), + tags: rootTags, + }; +} + /** Capacity & auth: guest rows may have no email. */ function membershipEmailQuery(tenantOrgId, eventId, email) { const em = String(email || '').trim().toLowerCase(); @@ -73,6 +92,7 @@ module.exports = { getMembership, participantInEvent, flattenParticipantForEvent, + flattenParticipantForCampaignList, membershipEmailQuery, membershipGuestQuery, baseQueryForEvent, diff --git a/src/utils/phone.js b/src/utils/phone.js new file mode 100644 index 0000000..1ba32b8 --- /dev/null +++ b/src/utils/phone.js @@ -0,0 +1,25 @@ +/** + * WhatsApp Cloud API: `to` is digits with country code, no + prefix. + * Strips spaces, letters, and punctuation; only digits are kept. + * Indian mobiles: 10-digit values get country code 91 (12 digits). A leading 0 + * (e.g. 09823488138) is removed before applying the 10-digit rule. + * @param {string} raw + * @returns {string} digits only, or empty if unusable + */ +function normalizeWhatsAppTo(raw) { + let digits = String(raw || '').replace(/\D/g, ''); + if (!digits) return ''; + + if (digits.length === 11 && digits.startsWith('0')) { + digits = digits.slice(1); + } + + if (digits.length === 10) { + digits = `91${digits}`; + } + + if (digits.length < 8 || digits.length > 15) return ''; + return digits; +} + +module.exports = { normalizeWhatsAppTo }; diff --git a/src/utils/publicTicketCheckoutShared.js b/src/utils/publicTicketCheckoutShared.js new file mode 100644 index 0000000..d2bde72 --- /dev/null +++ b/src/utils/publicTicketCheckoutShared.js @@ -0,0 +1,113 @@ +const mongoose = require('mongoose'); +const Event = require('../models/Event'); +const EventTicket = require('../models/EventTicket'); +const Form = require('../models/Form'); +const { computeTicketQuote, ticketRequiresBillingState } = require('./eventTicketPricing'); +const { validateAttendeeAgainstForm } = require('./ticketFormValidation'); + +/** + * Shared validation for public ticket checkout (Razorpay) and manual UPI submit. + * @returns {Promise<{ ok: true, event: object, ticket: object, qty: number, state: string, quoteResult: object, registrationContext: object, currency: string } | { ok: false, code: string, message: string }>} + */ +async function validateAndBuildTicketPurchaseContext(eventId, p) { + if (!mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(p.ticketId)) { + return { ok: false, code: 'VALIDATION_ERROR', message: 'Invalid id' }; + } + + const event = await Event.findById(eventId).lean(); + if (!event) return { ok: false, code: 'NOT_FOUND', message: 'Event not found' }; + + const ticket = await EventTicket.findOne({ _id: p.ticketId, event: eventId, isActive: true }).lean(); + if (!ticket) return { ok: false, code: 'NOT_FOUND', message: 'Ticket not found' }; + + const qty = Math.min(100, Math.max(1, parseInt(p.quantity, 10) || 1)); + if (ticket.maxPerOrder && qty > ticket.maxPerOrder) { + return { ok: false, code: 'VALIDATION_ERROR', message: `Maximum ${ticket.maxPerOrder} per order` }; + } + + const state = p.billingStateCode ? String(p.billingStateCode).trim().toUpperCase() : ''; + if (ticketRequiresBillingState(ticket) && !state) { + return { ok: false, code: 'VALIDATION_ERROR', message: 'Billing state is required' }; + } + + const attendees = Array.isArray(p.attendees) ? p.attendees : []; + if (attendees.length !== qty) { + return { ok: false, code: 'VALIDATION_ERROR', message: `Provide exactly ${qty} attendee(s)` }; + } + + const emails = attendees.map((a) => String(a.email || '').trim().toLowerCase()).filter(Boolean); + if (emails.length !== qty) { + return { ok: false, code: 'VALIDATION_ERROR', message: 'Each attendee must have an email' }; + } + if (new Set(emails).size !== emails.length) { + return { ok: false, code: 'VALIDATION_ERROR', message: 'Duplicate attendee emails' }; + } + + let registrationForm = null; + if (ticket.registrationForm) { + registrationForm = await Form.findById(ticket.registrationForm).lean(); + if (!registrationForm || !registrationForm.settings?.isActive) { + return { ok: false, code: 'VALIDATION_ERROR', message: 'Registration form is not available' }; + } + } + + for (const a of attendees) { + if (!String(a.firstName || '').trim() || !String(a.lastName || '').trim()) { + return { ok: false, code: 'VALIDATION_ERROR', message: 'Each attendee needs first and last name' }; + } + if (registrationForm) { + const v = validateAttendeeAgainstForm(registrationForm, { + firstName: a.firstName, + lastName: a.lastName, + email: a.email, + phone: a.phone, + organization: a.organization, + designation: a.designation, + displayName: a.displayName, + formData: a.formData && typeof a.formData === 'object' ? a.formData : {}, + }); + if (!v.ok) return { ok: false, code: 'VALIDATION_ERROR', message: v.message }; + } + } + + const quoteResult = computeTicketQuote(ticket, qty, state); + const currency = (ticket.currency || 'INR').toUpperCase(); + + const registrationContext = { + kind: 'ticket_purchase', + ticketId: String(ticket._id), + eventId: String(eventId), + quantity: qty, + billingStateCode: state || null, + purchaserEmail: String(p.purchaserEmail || emails[0] || '').trim().toLowerCase(), + attendees: attendees.map((a) => ({ + firstName: String(a.firstName).trim(), + lastName: String(a.lastName).trim(), + email: String(a.email).trim().toLowerCase(), + phone: a.phone != null ? String(a.phone).trim() : '', + organization: a.organization != null ? String(a.organization).trim() : '', + designation: a.designation != null ? String(a.designation).trim() : '', + displayName: a.displayName != null ? String(a.displayName).trim() : '', + formData: a.formData && typeof a.formData === 'object' ? a.formData : {}, + })), + quote: { + baseMinor: quoteResult.baseMinor, + taxMinor: quoteResult.taxMinor, + totalMinor: quoteResult.totalMinor, + taxLines: quoteResult.taxLines, + }, + }; + + return { + ok: true, + event, + ticket, + qty, + state, + quoteResult, + registrationContext, + currency, + }; +} + +module.exports = { validateAndBuildTicketPurchaseContext }; diff --git a/src/utils/s3Media.js b/src/utils/s3Media.js index 0bc7257..1f4d364 100644 --- a/src/utils/s3Media.js +++ b/src/utils/s3Media.js @@ -1,4 +1,4 @@ -const { S3Client, PutObjectCommand, DeleteObjectCommand } = require("@aws-sdk/client-s3"); +const { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } = require("@aws-sdk/client-s3"); function getEnv(name, fallback) { const v = process.env[name]; @@ -74,6 +74,24 @@ async function uploadBufferToS3AndGetCloudFrontUrl({ return { s3Key: key, url: cloudfrontUrlForKey(key) }; } +async function getBufferFromS3Key(s3Key) { + const bucket = getEnv("AWS_BUCKET"); + if (!bucket) throw new Error("AWS_BUCKET is not configured"); + if (!s3Key) throw new Error("s3Key required"); + + const response = await getS3Client().send( + new GetObjectCommand({ + Bucket: bucket, + Key: s3Key, + }), + ); + const chunks = []; + for await (const chunk of response.Body) { + chunks.push(chunk); + } + return Buffer.concat(chunks); +} + async function deleteS3ObjectByKey({ s3Key }) { const bucket = getEnv("AWS_BUCKET"); if (!bucket) return; @@ -89,6 +107,7 @@ async function deleteS3ObjectByKey({ s3Key }) { module.exports = { uploadBufferToS3AndGetCloudFrontUrl, + getBufferFromS3Key, deleteS3ObjectByKey, }; diff --git a/src/utils/smtpCrypto.js b/src/utils/smtpCrypto.js new file mode 100644 index 0000000..8f84381 --- /dev/null +++ b/src/utils/smtpCrypto.js @@ -0,0 +1,59 @@ +const crypto = require('crypto'); + +const ALG = 'aes-256-gcm'; +const IV_LEN = 12; + +function getKeyBuf() { + const raw = process.env.SMTP_ENCRYPTION_KEY || process.env.ORG_SECRETS_KEY || ''; + if (raw.length >= 64 && /^[0-9a-fA-F]+$/.test(raw)) { + const b = Buffer.from(raw, 'hex'); + if (b.length === 32) return b; + } + if (raw.length >= 44) { + try { + const b = Buffer.from(raw, 'base64'); + if (b.length === 32) return b; + } catch { + /* ignore */ + } + } + return crypto.createHash('sha256').update(raw || 'eventbuzz-dev-insecure').digest(); +} + +/** + * @param {string} plaintext + * @returns {string} enc:iv:tag (all base64) + */ +function encrypt(plaintext) { + if (plaintext == null || plaintext === '') return ''; + const key = getKeyBuf(); + const iv = crypto.randomBytes(IV_LEN); + const cipher = crypto.createCipheriv(ALG, key, iv); + const enc = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return [enc.toString('base64'), iv.toString('base64'), tag.toString('base64')].join(':'); +} + +/** + * @param {string} payload enc:iv:tag + * @returns {string} + */ +function decrypt(payload) { + if (!payload) return ''; + const parts = String(payload).split(':'); + if (parts.length !== 3) return ''; + const [encB64, ivB64, tagB64] = parts; + const key = getKeyBuf(); + try { + const enc = Buffer.from(encB64, 'base64'); + const iv = Buffer.from(ivB64, 'base64'); + const tag = Buffer.from(tagB64, 'base64'); + const decipher = crypto.createDecipheriv(ALG, key, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8'); + } catch { + return ''; + } +} + +module.exports = { encrypt, decrypt }; diff --git a/src/utils/ticketFormValidation.js b/src/utils/ticketFormValidation.js new file mode 100644 index 0000000..5f25da4 --- /dev/null +++ b/src/utils/ticketFormValidation.js @@ -0,0 +1,41 @@ +/** + * Merge attendee identity + formData object keyed by form field labels. + * @param {object} form - Form lean + * @param {{ firstName: string, lastName: string, email: string, phone?: string, formData?: object }} attendee + */ +function buildMergedFieldData(form, attendee) { + const data = { ...(attendee.formData && typeof attendee.formData === 'object' ? attendee.formData : {}) }; + const fields = form.fields || []; + for (const f of fields) { + if (!f.label) continue; + const key = f.label.trim(); + const map = f.mapsTo; + if (map === 'email') data[key] = attendee.email; + else if (map === 'firstName') data[key] = attendee.firstName; + else if (map === 'lastName') data[key] = attendee.lastName; + else if (map === 'phone') data[key] = attendee.phone != null ? attendee.phone : data[key]; + else if (map === 'organization') data[key] = attendee.organization != null ? attendee.organization : data[key]; + else if (map === 'designation') data[key] = attendee.designation != null ? attendee.designation : data[key]; + else if (map === 'displayName') data[key] = attendee.displayName != null ? attendee.displayName : data[key]; + } + return data; +} + +/** + * @returns {{ ok: true } | { ok: false, message: string }} + */ +function validateAttendeeAgainstForm(form, attendee) { + const data = buildMergedFieldData(form, attendee); + const fields = form.fields || []; + for (const f of fields) { + if (!f.required || !f.label) continue; + const key = f.label.trim(); + const v = data[key]; + if (v === undefined || v === null || String(v).trim() === '') { + return { ok: false, message: `Missing required field: ${key}` }; + } + } + return { ok: true, data }; +} + +module.exports = { buildMergedFieldData, validateAttendeeAgainstForm }; diff --git a/src/utils/ticketingManualPayment.js b/src/utils/ticketingManualPayment.js new file mode 100644 index 0000000..304b880 --- /dev/null +++ b/src/utils/ticketingManualPayment.js @@ -0,0 +1,20 @@ +/** + * Upload vs dynamic UPI fields satisfied (does not check enabled or enablement fee). + * @param {object} mp - event.ticketingManualPayment + */ +function manualQrFieldsSatisfied(mp) { + if (!mp) return false; + const mode = mp.manualQrMode === 'dynamic' ? 'dynamic' : 'upload'; + if (mode === 'dynamic') return Boolean(String(mp.upiId || '').trim()); + return Boolean(String(mp.upiQrImageUrl || '').trim()); +} + +/** + * Manual UPI is fully active for buyers: enabled, enablement fee paid, QR config ok. + * @param {object} mp - event.ticketingManualPayment + */ +function isManualPaymentConfigured(mp) { + return Boolean(mp && mp.enabled && mp.enablementFeePaidAt && manualQrFieldsSatisfied(mp)); +} + +module.exports = { isManualPaymentConfigured, manualQrFieldsSatisfied }; diff --git a/src/utils/waTemplateSendParams.js b/src/utils/waTemplateSendParams.js new file mode 100644 index 0000000..d4900fc --- /dev/null +++ b/src/utils/waTemplateSendParams.js @@ -0,0 +1,174 @@ +/** + * Build Meta Cloud API template `components` for /messages (template type). + * Uses WhatsAppTemplate.components + variableBindings; applies campaign content.waOverrides and waHeaderImageUrl. + */ +const { buildVars, mergeText } = require('./campaignMerge'); + +/** + * Match Meta template locale format (e.g. en_US, en_GB, en). + * @param {string} raw + */ +function normalizeTemplateLanguageCode(raw) { + const s = String(raw || 'en').trim().replace(/-/g, '_'); + if (!s) return 'en'; + const i = s.indexOf('_'); + if (i === -1) return s.toLowerCase(); + const left = s.slice(0, i).toLowerCase(); + const right = s.slice(i + 1).toUpperCase(); + return `${left}_${right}`; +} + +/** + * @param {string} text + * @returns {number} highest {{n}} index in text, or 0 if none + */ +function countMetaVarSlots(text) { + const s = String(text || ''); + const re = /\{\{\s*(\d+)\s*\}\}/g; + let m; + let max = 0; + while ((m = re.exec(s)) !== null) max = Math.max(max, parseInt(m[1], 10)); + return max; +} + +/** + * @param {string} section header|body|url + * @param {number} slot1 + * @param {object} waTemplate + * @param {object} content campaign.content + * @param {ReturnType} vars + */ +function valueForSlot(section, slot1, waTemplate, content, vars) { + const overrides = (content && content.waOverrides) || {}; + const o = overrides[section] && overrides[section][String(slot1)]; + const bindings = (waTemplate.variableBindings && waTemplate.variableBindings[section]) || []; + if (o && o.kind === 'fixed') return String(o.value ?? ''); + const key = + o && o.kind === 'merge' && o.key + ? String(o.key) + : String(bindings[slot1 - 1] || '').trim(); + if (!key) return ''; + return mergeText(`{{${key}}}`, vars); +} + +/** + * @param {object} waTemplate — lean WhatsAppTemplate + * @param {object} content — campaign.content + * @param {object} participant — flat or doc for buildVars + * @param {object} event — lean Event + * @returns {{ components: object[], languageCode: string, name: string }} + */ +function buildWhatsAppTemplatePayload(waTemplate, content, participant, event) { + const vars = buildVars(participant, event); + const name = String(waTemplate.name || '').trim(); + const languageCode = normalizeTemplateLanguageCode(waTemplate.language || 'en_US'); + const templateComps = Array.isArray(waTemplate.components) ? waTemplate.components : []; + const out = []; + + let urlParamCursor = 1; + + for (const comp of templateComps) { + if (!comp || typeof comp !== 'object') continue; + const t = String(comp.type || '').toUpperCase(); + if (t === 'HEADER') { + const fmt = String(comp.format || '').toUpperCase(); + if (fmt === 'IMAGE') { + const link = String((content && content.waHeaderImageUrl) || '').trim(); + if (!link) { + throw new Error('This template has an image header. Set a public image URL (waHeaderImageUrl) for the campaign.'); + } + out.push({ + type: 'header', + parameters: [{ type: 'image', image: { link } }], + }); + } else if (fmt === 'TEXT' && comp.text) { + const n = countMetaVarSlots(comp.text); + const params = []; + for (let i = 1; i <= n; i += 1) { + const text = valueForSlot('header', i, waTemplate, content, vars); + params.push({ type: 'text', text: text.slice(0, 1024) }); + } + if (params.length) out.push({ type: 'header', parameters: params }); + } + } + if (t === 'BODY' && comp.text) { + const n = countMetaVarSlots(comp.text); + const params = []; + for (let i = 1; i <= n; i += 1) { + const text = valueForSlot('body', i, waTemplate, content, vars); + params.push({ type: 'text', text: text.slice(0, 1024) }); + } + if (params.length) out.push({ type: 'body', parameters: params }); + } + if (t === 'BUTTONS' && Array.isArray(comp.buttons)) { + const buttons = comp.buttons; + for (let bi = 0; bi < buttons.length; bi += 1) { + const b = buttons[bi]; + if (!b || typeof b !== 'object') continue; + const bt = String(b.type || '').toUpperCase(); + if (bt !== 'URL') continue; + const u = String(b.url || ''); + const n = countMetaVarSlots(u); + if (n === 0) continue; + const params = []; + for (let j = 0; j < n; j += 1) { + const text = valueForSlot('url', urlParamCursor, waTemplate, content, vars); + urlParamCursor += 1; + params.push({ type: 'text', text: text.slice(0, 1024) }); + } + out.push({ + type: 'button', + sub_type: 'url', + index: String(bi), + parameters: params, + }); + } + } + } + + return { + name, + languageCode, + /** Inner object for `type: template` messages */ + template: { + name, + language: { code: languageCode }, + components: out, + }, + }; +} + +/** + * @param {object} waTemplate + * @param {object} content + */ +function assertWhatsAppCampaignContentReady(waTemplate, content) { + const c = content || {}; + if (!c.whatsappTemplateId) { + return { ok: false, message: 'Select a WhatsApp template for this campaign.' }; + } + const st = String(waTemplate.status || '').toUpperCase(); + if (st !== 'APPROVED') { + return { ok: false, message: 'WhatsApp template must be APPROVED in Meta before sending.' }; + } + const templateComps = Array.isArray(waTemplate.components) ? waTemplate.components : []; + for (const comp of templateComps) { + if (!comp || typeof comp !== 'object') continue; + if (String(comp.type).toUpperCase() === 'HEADER' && String(comp.format).toUpperCase() === 'IMAGE') { + if (!String(c.waHeaderImageUrl || '').trim()) { + return { + ok: false, + message: 'This template uses an image header. Add a public https image URL for this campaign (waHeaderImageUrl).', + }; + } + } + } + return { ok: true }; +} + +module.exports = { + countMetaVarSlots, + normalizeTemplateLanguageCode, + buildWhatsAppTemplatePayload, + assertWhatsAppCampaignContentReady, +}; diff --git a/src/workers/badgeBatchWorker.js b/src/workers/badgeBatchWorker.js new file mode 100644 index 0000000..134e38b --- /dev/null +++ b/src/workers/badgeBatchWorker.js @@ -0,0 +1,106 @@ +require('dotenv').config({ path: require('path').join(__dirname, '../../.env') }); +const mongoose = require('mongoose'); +const { connectDB } = require('../config/database'); +const BadgeBatch = require('../models/BadgeBatch'); +const Event = require('../models/Event'); +const { resolveBadgeRecipients } = require('../utils/badgeRecipients'); +const { buildBadgeZip } = require('../utils/badgeRender'); +const { uploadBufferToS3AndGetCloudFrontUrl } = require('../utils/s3Media'); + +const POLL_MS = Math.max(1000, parseInt(process.env.BADGE_BATCH_WORKER_POLL_MS || '3000', 10)); +const MAX_BADGE_RECIPIENTS = parseInt(process.env.BADGE_BATCH_MAX_RECIPIENTS || '1500', 10); + +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +async function allowedEventIdsForOrganization(organizationId) { + const evs = await Event.find({ organization: organizationId }).select('_id').lean(); + return evs.map((e) => e._id); +} + +async function processOne() { + const now = new Date(); + const batch = await BadgeBatch.findOneAndUpdate( + { status: 'pending' }, + { $set: { status: 'processing', startedAt: now } }, + { sort: { createdAt: 1 }, new: true }, + ); + + if (!batch) return false; + + try { + const event = await Event.findById(batch.event).select('organization').lean(); + if (!event) { + throw new Error('Event not found'); + } + if (String(event.organization) !== String(batch.organization)) { + throw new Error('Event organisation mismatch'); + } + + const allowed = await allowedEventIdsForOrganization(event.organization); + const resolved = await resolveBadgeRecipients( + batch.config, + batch.event, + event.organization, + allowed, + ); + if (resolved.error) { + throw new Error(resolved.error.message || 'Recipient resolution failed'); + } + const rows = resolved.rows || []; + if (!rows.length) { + throw new Error('No recipients to render'); + } + if (rows.length > MAX_BADGE_RECIPIENTS) { + throw new Error(`Recipient count ${rows.length} exceeds limit ${MAX_BADGE_RECIPIENTS}`); + } + + const { zipBuffer, itemCount } = await buildBadgeZip(rows, batch.config, batch.config.outputFormat); + + const filename = `batch-${batch._id}-${Date.now()}.zip`; + const uploaded = await uploadBufferToS3AndGetCloudFrontUrl({ + buffer: zipBuffer, + contentType: 'application/zip', + orgId: String(batch.organization), + eventId: String(batch.event), + module: 'badges/batch-exports', + filename, + }); + + batch.status = 'ready'; + batch.zipUrl = uploaded.url; + batch.zipS3Key = uploaded.s3Key; + batch.itemCount = itemCount; + batch.completedAt = new Date(); + batch.lastError = undefined; + await batch.save(); + } catch (e) { + batch.status = 'failed'; + batch.lastError = e.message || String(e); + batch.completedAt = new Date(); + await batch.save(); + } + + return true; +} + +async function run() { + await connectDB(); + console.log('[badgeBatchWorker] started, poll', POLL_MS, 'ms'); + for (;;) { + try { + const did = await processOne(); + if (!did) await sleep(POLL_MS); + } catch (e) { + console.error('[badgeBatchWorker]', e); + await sleep(POLL_MS); + } + } +} + +if (require.main === module) { + run(); +} + +module.exports = { processOne, run }; diff --git a/src/workers/emailQueueWorker.js b/src/workers/emailQueueWorker.js new file mode 100644 index 0000000..a2d48f2 --- /dev/null +++ b/src/workers/emailQueueWorker.js @@ -0,0 +1,118 @@ +require('dotenv').config({ path: require('path').join(__dirname, '../../.env') }); +const mongoose = require('mongoose'); +const { connectDB } = require('../config/database'); +const EmailQueue = require('../models/EmailQueue'); +const MessagingCampaign = require('../models/MessagingCampaign'); +const { sendOrgMail } = require('../utils/mail'); + +const POLL_MS = Math.max(500, parseInt(process.env.EMAIL_WORKER_POLL_MS || '2000', 10)); +const MAX_ATTEMPTS = Math.max(1, parseInt(process.env.EMAIL_WORKER_MAX_ATTEMPTS || '3', 10)); +const LEASE_SEC = parseInt(process.env.EMAIL_WORKER_LEASE_SEC || '120', 10); + +async function finalizeCampaignIfDone(campaignId) { + const pending = await EmailQueue.countDocuments({ campaign: campaignId, status: { $in: ['pending', 'processing'] } }); + if (pending > 0) return; + const camp = await MessagingCampaign.findById(campaignId); + if (!camp || camp.status === 'stopped') return; + const failed = await EmailQueue.countDocuments({ campaign: campaignId, status: 'failed' }); + const sent = await EmailQueue.countDocuments({ campaign: campaignId, status: 'sent' }); + if (sent === 0 && failed > 0) { + camp.status = 'failed'; + } else { + camp.status = 'sent'; + } + camp.sentAt = new Date(); + await camp.save(); +} + +async function processOne() { + const now = new Date(); + const job = await EmailQueue.findOneAndUpdate( + { + status: 'pending', + processAfter: { $lte: now }, + }, + { + $set: { status: 'processing', lockedAt: now }, + $inc: { attempts: 1 }, + }, + { sort: { createdAt: 1 }, new: true } + ); + + if (!job) return false; + + const campaign = await MessagingCampaign.findById(job.campaign); + if (!campaign) { + job.status = 'cancelled'; + await job.save(); + return true; + } + + if (campaign.status === 'stopped') { + job.status = 'cancelled'; + await job.save(); + return true; + } + + if (campaign.status === 'scheduled') { + campaign.status = 'sending'; + await campaign.save(); + } + + const result = await sendOrgMail({ + organizationId: job.organization, + to: job.to, + subject: job.subject, + html: job.body, + }); + + if (result.ok) { + job.status = 'sent'; + job.sentAt = new Date(); + job.lastError = undefined; + await job.save(); + campaign.stats = campaign.stats || {}; + campaign.stats.sent = (campaign.stats.sent || 0) + 1; + await campaign.save(); + await finalizeCampaignIfDone(campaign._id); + } else { + if (result.skipped) { + job.status = 'failed'; + job.lastError = 'No SMTP configured'; + } else if (job.attempts >= MAX_ATTEMPTS) { + job.status = 'failed'; + job.lastError = result.error || 'send failed'; + } else { + job.status = 'pending'; + job.lastError = result.error || 'send failed'; + job.processAfter = new Date(Date.now() + 60_000 * job.attempts); + } + await job.save(); + if (job.status === 'failed') { + campaign.stats = campaign.stats || {}; + campaign.stats.failed = (campaign.stats.failed || 0) + 1; + await campaign.save(); + } + await finalizeCampaignIfDone(campaign._id); + } + + return true; +} + +async function run() { + await connectDB(); + console.log('[emailQueueWorker] started, poll', POLL_MS, 'ms'); + for (;;) { + try { + const did = await processOne(); + if (!did) await new Promise((r) => setTimeout(r, POLL_MS)); + } catch (e) { + console.error('[emailQueueWorker]', e); + await new Promise((r) => setTimeout(r, POLL_MS)); + } + } +} + +if (require.main === module) { + run(); +} diff --git a/src/workers/whatsappQueueWorker.js b/src/workers/whatsappQueueWorker.js new file mode 100644 index 0000000..92fd03e --- /dev/null +++ b/src/workers/whatsappQueueWorker.js @@ -0,0 +1,210 @@ +require('dotenv').config({ path: require('path').join(__dirname, '../../.env') }); +const mongoose = require('mongoose'); +const { connectDB } = require('../config/database'); +const WhatsAppQueue = require('../models/WhatsAppQueue'); +const MessagingCampaign = require('../models/MessagingCampaign'); +const Event = require('../models/Event'); +const WhatsAppTemplate = require('../models/WhatsAppTemplate'); +const Participant = require('../models/Participant'); +const { + getOrgWhatsAppCredentials, + sendTemplateMessage, + parseWhatsAppCloudMessagesSendResponse, +} = require('../utils/metaWhatsApp'); +const { buildWhatsAppTemplatePayload } = require('../utils/waTemplateSendParams'); +const { getCampaignContentObject } = require('../utils/campaignContent'); +const { flattenParticipantForEvent } = require('../utils/participantMembership'); + +const POLL_MS = Math.max(500, parseInt(process.env.WHATSAPP_WORKER_POLL_MS || '2000', 10)); +const MAX_ATTEMPTS = Math.max(1, parseInt(process.env.WHATSAPP_WORKER_MAX_ATTEMPTS || '3', 10)); +const MIN_INTERVAL_MS = Math.max(0, parseInt(process.env.WHATSAPP_WORKER_MIN_INTERVAL_MS || '400', 10)); +let lastSendAt = 0; + +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +async function throttle() { + if (MIN_INTERVAL_MS <= 0) return; + const now = Date.now(); + const wait = lastSendAt + MIN_INTERVAL_MS - now; + if (wait > 0) await sleep(wait); + lastSendAt = Date.now(); +} + +async function finalizeCampaignIfDone(campaignId) { + const pending = await WhatsAppQueue.countDocuments({ campaign: campaignId, status: { $in: ['pending', 'processing'] } }); + if (pending > 0) return; + const camp = await MessagingCampaign.findById(campaignId); + if (!camp || camp.status === 'stopped') return; + const failed = await WhatsAppQueue.countDocuments({ campaign: campaignId, status: 'failed' }); + const sent = await WhatsAppQueue.countDocuments({ campaign: campaignId, status: 'sent' }); + if (sent === 0 && failed > 0) { + camp.status = 'failed'; + } else { + camp.status = 'sent'; + } + camp.sentAt = new Date(); + await camp.save(); +} + +async function processOne() { + const now = new Date(); + const job = await WhatsAppQueue.findOneAndUpdate( + { + status: 'pending', + processAfter: { $lte: now }, + }, + { + $set: { status: 'processing', lockedAt: now }, + $inc: { attempts: 1 }, + }, + { sort: { createdAt: 1 }, new: true } + ); + + if (!job) return false; + + const campaign = await MessagingCampaign.findById(job.campaign); + if (!campaign) { + job.status = 'cancelled'; + await job.save(); + return true; + } + + if (campaign.status === 'stopped') { + job.status = 'cancelled'; + await job.save(); + return true; + } + + if (campaign.status === 'scheduled') { + campaign.status = 'sending'; + await campaign.save(); + } + + const orgId = job.organization?._id || job.organization; + const creds = orgId && (await getOrgWhatsAppCredentials(String(orgId))); + if (!creds || !creds.accessToken) { + job.status = 'failed'; + job.lastError = 'WhatsApp not configured (token)'; + await job.save(); + await finalizeFailed(campaign, job); + await finalizeCampaignIfDone(campaign._id); + return true; + } + if (!creds.phoneNumberId) { + job.status = 'failed'; + job.lastError = 'WhatsApp phone number ID missing (set in Settings)'; + await job.save(); + await finalizeFailed(campaign, job); + await finalizeCampaignIfDone(campaign._id); + return true; + } + + const eventDoc = await Event.findById(campaign.event).lean(); + const tplId = getCampaignContentObject(campaign).whatsappTemplateId; + const tpl = tplId && mongoose.Types.ObjectId.isValid(String(tplId)) ? await WhatsAppTemplate.findById(tplId).lean() : null; + if (!eventDoc || !tpl) { + job.status = 'failed'; + job.lastError = 'Template or event missing'; + await job.save(); + await finalizeFailed(campaign, job); + await finalizeCampaignIfDone(campaign._id); + return true; + } + + const p = job.participant ? await Participant.findById(job.participant).lean() : null; + if (!p) { + job.status = 'failed'; + job.lastError = 'Participant not found'; + await job.save(); + await finalizeFailed(campaign, job); + await finalizeCampaignIfDone(campaign._id); + return true; + } + const flat = flattenParticipantForEvent(p, campaign.event); + if (!flat) { + job.status = 'failed'; + job.lastError = 'Participant not in event'; + await job.save(); + await finalizeFailed(campaign, job); + await finalizeCampaignIfDone(campaign._id); + return true; + } + + let built; + try { + const content = getCampaignContentObject(campaign); + built = buildWhatsAppTemplatePayload(tpl, content, flat, eventDoc); + } catch (e) { + job.status = 'failed'; + job.lastError = e.message || 'build template failed'; + await job.save(); + await finalizeFailed(campaign, job); + await finalizeCampaignIfDone(campaign._id); + return true; + } + + try { + await throttle(); + const sendBody = await sendTemplateMessage(creds.phoneNumberId, creds.accessToken, job.to, built.template); + const { whatsappMessageId, recipientWaId } = parseWhatsAppCloudMessagesSendResponse(sendBody); + if (whatsappMessageId) job.whatsappMessageId = whatsappMessageId; + if (recipientWaId) job.recipientWaId = recipientWaId; + } catch (e) { + const msg = e.message || 'Meta send failed'; + if (job.attempts >= MAX_ATTEMPTS) { + job.status = 'failed'; + job.lastError = msg; + } else { + job.status = 'pending'; + job.lastError = msg; + job.processAfter = new Date(Date.now() + 60_000 * job.attempts); + } + await job.save(); + if (job.status === 'failed') await finalizeFailed(campaign, job); + await finalizeCampaignIfDone(campaign._id); + return true; + } + + job.status = 'sent'; + job.sentAt = new Date(); + job.lastError = undefined; + await job.save(); + campaign.stats = campaign.stats || {}; + campaign.stats.sent = (campaign.stats.sent || 0) + 1; + await campaign.save(); + await finalizeCampaignIfDone(campaign._id); + return true; +} + +async function finalizeFailed(campaign, job) { + if (job.status !== 'failed') return; + campaign.stats = campaign.stats || {}; + campaign.stats.failed = (campaign.stats.failed || 0) + 1; + await campaign.save(); +} + +async function run() { + await connectDB(); + console.log( + '[whatsappQueueWorker] started, poll', + POLL_MS, + 'ms, min interval', + MIN_INTERVAL_MS, + 'ms', + ); + for (;;) { + try { + const did = await processOne(); + if (!did) await sleep(POLL_MS); + } catch (e) { + console.error('[whatsappQueueWorker]', e); + await sleep(POLL_MS); + } + } +} + +if (require.main === module) { + run(); +}