From d77d6f7cd17bfd6e6fdd955e8580e185d39f65fd Mon Sep 17 00:00:00 2001 From: Ravi Gulhane <> Date: Thu, 13 Aug 2026 10:37:35 +0530 Subject: [PATCH] Changes --- .env.example | 6 + package-lock.json | 132 ++--- package.json | 2 +- src/app.js | 6 + src/config/aclModules.js | 10 +- src/config/systemRolePermissions.js | 19 +- src/controllers/form.controller.js | 73 ++- src/controllers/photoWall.controller.js | 429 ++++++++++++++ .../publicDemoRequest.controller.js | 82 +++ src/controllers/publicPhotoWall.controller.js | 555 ++++++++++++++++++ src/controllers/publicQuizPoll.controller.js | 38 +- src/controllers/quizPoll.controller.js | 40 +- src/controllers/session.controller.js | 17 +- src/models/PhotoWall.js | 88 +++ src/models/PhotoWallPhoto.js | 47 ++ src/models/QuizPoll.js | 2 + src/routes/photoWall.routes.js | 140 +++++ src/routes/publicDemoRequest.routes.js | 28 + src/routes/publicPhotoWall.routes.js | 43 ++ src/routes/publicQuizPoll.routes.js | 5 + src/routes/quizPoll.routes.js | 6 + src/routes/session.routes.js | 5 + src/utils/mail.js | 55 ++ src/utils/photoWallFrameLayout.js | 134 +++++ src/utils/photoWallFrameRender.js | 254 ++++++++ src/utils/quizPollFirebaseSync.js | 1 + src/utils/quizPollLiveControls.js | 5 + src/utils/quizPollResultsSummary.js | 55 ++ src/utils/s3EventPhotos.js | 75 ++- src/utils/sessionPresentationFirebaseSync.js | 29 + 30 files changed, 2291 insertions(+), 90 deletions(-) create mode 100644 src/controllers/photoWall.controller.js create mode 100644 src/controllers/publicDemoRequest.controller.js create mode 100644 src/controllers/publicPhotoWall.controller.js create mode 100644 src/models/PhotoWall.js create mode 100644 src/models/PhotoWallPhoto.js create mode 100644 src/routes/photoWall.routes.js create mode 100644 src/routes/publicDemoRequest.routes.js create mode 100644 src/routes/publicPhotoWall.routes.js create mode 100644 src/utils/photoWallFrameLayout.js create mode 100644 src/utils/photoWallFrameRender.js create mode 100644 src/utils/sessionPresentationFirebaseSync.js diff --git a/.env.example b/.env.example index 929f1a2..15b0ab2 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,9 @@ AWS_SECRET_ACCESS_KEY=ZpIxby1vbCLGB7CPWZsAltZBEqkhwzgBpFOKDGj8 AWS_DEFAULT_REGION=ap-south-1 AWS_BUCKET=rapportapp AWS_URL=https://did94hhhcgq1d.cloudfront.net +# Event photos / photo-wall bucket + CDN (separate from AWS_URL media CDN) +AWS_EVENT_PICTURES_BUCKET=eventbuzz-pictures +AWS_URL_PICTURES=https://d155t9or8i84n8.cloudfront.net # Participant check-in QR PNGs: s3://{AWS_BUCKET}/eventbuzz/check-in-qr/participant-{id}.png # Served via AWS_URL (CloudFront). Created on first use (campaigns, public QR route, concierge). # Amazon Bedrock (AI event quick-create, concierge food/places summaries). Uses Converse API. @@ -110,6 +113,9 @@ AWS_URL=https://did94hhhcgq1d.cloudfront.net # Optional DB override (default: database name from MONGODB_URI) # CONCIERGE_RECOMMENDATION_DB= +# Marketing site "Request a demo" recipient (comma-separated allowed) +# WEBSITE_DEMO_REQUEST_TO=shabareesh@stratawiz.com + # HMAC secret for public form captcha tokens (falls back to JWT_SECRET if unset) # FORM_CAPTCHA_SECRET= diff --git a/package-lock.json b/package-lock.json index 7adf847..3eab297 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "dependencies": { "@aws-sdk/client-bedrock-runtime": "^3.1030.0", - "@aws-sdk/client-rekognition": "^3.1101.0", + "@aws-sdk/client-rekognition": "^3.1104.0", "@aws-sdk/client-s3": "^3.1012.0", "@aws-sdk/lib-storage": "^3.1012.0", "@hapi/hapi": "^21.3.0", @@ -315,13 +315,13 @@ } }, "node_modules/@aws-sdk/client-rekognition": { - "version": "3.1101.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-rekognition/-/client-rekognition-3.1101.0.tgz", - "integrity": "sha512-M+bb4PuyAk4WC5oxn65aFMP5mYcPM9CAVmwu4Fzd3fridCX8ElBVYBE8L4micpihtV6VvN1NQb8PzeT4qm244g==", + "version": "3.1104.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-rekognition/-/client-rekognition-3.1104.0.tgz", + "integrity": "sha512-/hno+AX0KW16ZPxcgNdWrLUyxoJ0XKqLerb8rUGz9DLKNvdL03VIt620TwF05mpen18x6Aybd8kzV3LXYqSSBA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.4", - "@aws-sdk/credential-provider-node": "^3.972.76", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/credential-provider-node": "^3.972.78", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", @@ -400,9 +400,9 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.977.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.4.tgz", - "integrity": "sha512-CEkcQlMOQJCvul60U7wdAOACjtdgFWDsfJI+6wUOGdhGNV2lGbuJpi/R50QLpFG3Tp+sQxa/RmzC3X7KHbhuTA==", + "version": "3.977.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.6.tgz", + "integrity": "sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.974.2", @@ -441,12 +441,12 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.65", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.65.tgz", - "integrity": "sha512-lJT2aRw9wCV8jPHyFJjdZLD4HTydL6/22AnCSOB8e/LqOc55nEJGLHkJQeSxhn8QiqyjFwPKQFtMw0ovjRUY/g==", + "version": "3.972.67", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.67.tgz", + "integrity": "sha512-rcIpk5kxUqDaaNa6Xk23pQ6ViY7jlqzmfFWCahQcBT97ddXaXYYwzCen9Tz1Jvo6aJft6wDl5bN44/Jw5B4oLA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.4", + "@aws-sdk/core": "^3.977.6", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", @@ -457,12 +457,12 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.67", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.67.tgz", - "integrity": "sha512-N7fw/15hSwI/CPxe5ohOyb7O4ge9f5me1gVIn8OIkBRB0squ8OJqQyDyH/HoL+Sb1W5xdC88jVC+bHkw73iu+Q==", + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.69.tgz", + "integrity": "sha512-nggwJtZ4eeNsUw5IeWBMXsi1ryct5idi0K+/SCRF3kybLubOMaNTb3XCihXpWMiVpyzyPeIrl0zTkzhBH9porA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.4", + "@aws-sdk/core": "^3.977.6", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", @@ -475,19 +475,19 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.973.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.10.tgz", - "integrity": "sha512-Zh9XRaPnDN9buO7GfWBubS22R6Nq5D6hbyYEMN05LiOnXugm/8WDjUx6y756bSPbdn3aJB2qG4zFW3bN82QhoQ==", + "version": "3.973.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.12.tgz", + "integrity": "sha512-pNEf/OeyN5X3VmLKlgSO6TqaWmW10CvI3TfwL1XhsuhYjSLT2VDaxFnCPHnOeQXSaFisMX4jNhpETriqN8DOmg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.4", - "@aws-sdk/credential-provider-env": "^3.972.65", - "@aws-sdk/credential-provider-http": "^3.972.67", - "@aws-sdk/credential-provider-login": "^3.972.72", - "@aws-sdk/credential-provider-process": "^3.972.65", - "@aws-sdk/credential-provider-sso": "^3.973.9", - "@aws-sdk/credential-provider-web-identity": "^3.972.71", - "@aws-sdk/nested-clients": "^3.997.39", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/credential-provider-env": "^3.972.67", + "@aws-sdk/credential-provider-http": "^3.972.69", + "@aws-sdk/credential-provider-login": "^3.972.74", + "@aws-sdk/credential-provider-process": "^3.972.67", + "@aws-sdk/credential-provider-sso": "^3.973.11", + "@aws-sdk/credential-provider-web-identity": "^3.972.73", + "@aws-sdk/nested-clients": "^3.997.41", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", @@ -499,13 +499,13 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.72", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.72.tgz", - "integrity": "sha512-zZapIKwaHp7TdTf9hbH1I3CVUdEupmt7FXO/BoTQGC+4h6NkXKWpqF2p5WyfpjurDLHCpSyh+BzMlAg8arqWLA==", + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.74.tgz", + "integrity": "sha512-0AQfDcf99TNmqVKv0owHrw/TQs6i4ZE5t9qmz6NvO53bE/sA/tpXhXL9AAcEP1qHc6Zzjd1UMb69+/9zdhvY3g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.4", - "@aws-sdk/nested-clients": "^3.997.39", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", @@ -516,17 +516,17 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.76", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.76.tgz", - "integrity": "sha512-1yzLmRiYSgGC25v7ZZEwJn/auhHHTIHgFOmzL2f36hf1+7jSLcX+1QrAz4760WEzPiiQl8xmlpFhHfl2OoyVzA==", + "version": "3.972.78", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.78.tgz", + "integrity": "sha512-OgPAnfvbGAMWac6yvxJ1ihslrvDpPVwR68D2csospdNCCyPvHk9JLzYKwz48SNiS1T2znDwHauywRKRFfpyYng==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.65", - "@aws-sdk/credential-provider-http": "^3.972.67", - "@aws-sdk/credential-provider-ini": "^3.973.10", - "@aws-sdk/credential-provider-process": "^3.972.65", - "@aws-sdk/credential-provider-sso": "^3.973.9", - "@aws-sdk/credential-provider-web-identity": "^3.972.71", + "@aws-sdk/credential-provider-env": "^3.972.67", + "@aws-sdk/credential-provider-http": "^3.972.69", + "@aws-sdk/credential-provider-ini": "^3.973.12", + "@aws-sdk/credential-provider-process": "^3.972.67", + "@aws-sdk/credential-provider-sso": "^3.973.11", + "@aws-sdk/credential-provider-web-identity": "^3.972.73", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", @@ -538,12 +538,12 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.65", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.65.tgz", - "integrity": "sha512-e5DbbNteOSalN58U83G6kFa4ECLEuGbGqNBHIXE7zYXA/m4GHblIGjFbSH7wYv6gBV8iNSDcRZBKfQZF5vF9nw==", + "version": "3.972.67", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.67.tgz", + "integrity": "sha512-IlUEejorGTWKb4/Dm7K5Yw4QxUmXLThLhrvBmzVBqZFTbW72cv9LTcITmo1dsnYriALE4h68mOq4LB99x6sQ7Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.4", + "@aws-sdk/core": "^3.977.6", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", @@ -554,14 +554,14 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.973.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.9.tgz", - "integrity": "sha512-0V0u4t+KBku9fbh5CPCaC5hUWwSzDafp8nCuDy817zWbp2gz80jO44rMQkiwnZ+k54B+tjAtzRy00DJRGTKGBg==", + "version": "3.973.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.11.tgz", + "integrity": "sha512-gAQBkBZxUB84d71+pPcI9L+jh2ujhuAVxc/4FgGiWFDjkPBlMKxzd5XDtkSXTFX8Ro7ansnT88+XadasxMeCRw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.4", - "@aws-sdk/nested-clients": "^3.997.39", - "@aws-sdk/token-providers": "3.1100.0", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/token-providers": "3.1103.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", @@ -572,13 +572,13 @@ } }, "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { - "version": "3.1100.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1100.0.tgz", - "integrity": "sha512-THf3MkgY3fNJZ3zdgSenLqR7gSE68KccCj1RCKretlG73Ppszvues02VpCUO9NlB/tZDC483FvGCld+AiPCkvg==", + "version": "3.1103.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1103.0.tgz", + "integrity": "sha512-N4wy26MNn31ItGVHYHPrEuCIFY4MBBjC+C5v1lJKqIUSA7OZBdhleCY53zCCrXn27hsk7YNOaTuhQu807S4AfQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.4", - "@aws-sdk/nested-clients": "^3.997.39", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", @@ -589,13 +589,13 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.71", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.71.tgz", - "integrity": "sha512-e4dwiRltGAaQ+2yxw57Hj0l/BF3BHiG14+QpYE7bGYBlpAq/fkIri2BDhjWon8c0mhhtd2txQBAkQb9BcTStFg==", + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.73.tgz", + "integrity": "sha512-SnlEmQa6SjOgs6iOPLUQl1Eyq4AKiAdPQlkOhFhqNfDtDCwibMGvL6QlkSmf3o6vAUSImzdPCxowT5dfQUZP1A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.4", - "@aws-sdk/nested-clients": "^3.997.39", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", @@ -855,12 +855,12 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.39", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.39.tgz", - "integrity": "sha512-wU5NPnj62Sb7A8xn/Zb+xThe05P3otNtDl37iOIi5DDMeCesNeCckaG+eXWGUs12Z9R34I8CD05TaTe6SIa61g==", + "version": "3.997.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.41.tgz", + "integrity": "sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.4", + "@aws-sdk/core": "^3.977.6", "@aws-sdk/signature-v4-multi-region": "^3.996.43", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", diff --git a/package.json b/package.json index d2674d9..f97f0ad 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ }, "dependencies": { "@aws-sdk/client-bedrock-runtime": "^3.1030.0", - "@aws-sdk/client-rekognition": "^3.1101.0", + "@aws-sdk/client-rekognition": "^3.1104.0", "@aws-sdk/client-s3": "^3.1012.0", "@aws-sdk/lib-storage": "^3.1012.0", "@hapi/hapi": "^21.3.0", diff --git a/src/app.js b/src/app.js index b77de52..9c7a926 100644 --- a/src/app.js +++ b/src/app.js @@ -38,6 +38,7 @@ const eventTicketRoutes = require('./routes/eventTicket.routes'); const publicEventTicketRoutes = require('./routes/publicEventTicket.routes'); const publicMicrositeRoutes = require('./routes/publicMicrosite.routes'); const publicCaptchaRoutes = require('./routes/publicCaptcha.routes'); +const publicDemoRequestRoutes = require('./routes/publicDemoRequest.routes'); const publicAttendanceRoutes = require('./routes/publicAttendance.routes'); const publicParticipantQrRoutes = require('./routes/publicParticipantQr.routes'); const attendanceRoutes = require('./routes/attendance.routes'); @@ -47,8 +48,10 @@ const eventTicketingManualRoutes = require('./routes/eventTicketingManual.routes const conciergeBotRoutes = require('./routes/conciergeBot.routes'); const publicConciergeRoutes = require('./routes/publicConcierge.routes'); const publicQuizPollRoutes = require('./routes/publicQuizPoll.routes'); +const publicPhotoWallRoutes = require('./routes/publicPhotoWall.routes'); const staticMicrositeRoutes = require('./routes/staticMicrosite.routes'); const eventPhotoRoutes = require('./routes/eventPhoto.routes'); +const photoWallRoutes = require('./routes/photoWall.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). */ @@ -278,10 +281,12 @@ const createServer = async () => { server.route(publicEventTicketRoutes); server.route(publicMicrositeRoutes); server.route(publicCaptchaRoutes); + server.route(publicDemoRequestRoutes); server.route(publicAttendanceRoutes); server.route(publicParticipantQrRoutes); server.route(publicConciergeRoutes); server.route(publicQuizPollRoutes); + server.route(publicPhotoWallRoutes); server.route(authRoutes); server.route(eventRoutes); @@ -319,6 +324,7 @@ const createServer = async () => { server.route(ticketManualRoutes); server.route(eventTicketingManualRoutes); server.route(eventPhotoRoutes); + server.route(photoWallRoutes); server.route(conciergeBotRoutes); server.route(attendanceRoutes); server.route(checkinsRoutes); diff --git a/src/config/aclModules.js b/src/config/aclModules.js index ab4a774..9b6ca22 100644 --- a/src/config/aclModules.js +++ b/src/config/aclModules.js @@ -80,6 +80,13 @@ const ACL_MODULE_DEFS = { rowLevel: false, planFeatures: [], }, + photo_wall: { + key: 'photo_wall', + label: 'Photo Wall', + category: 'event', + rowLevel: false, + planFeatures: [], + }, }; for (const key of COMBINED_PLAN_KEYS) { @@ -106,6 +113,7 @@ const ORDERED_ACL_MODULE_KEYS = [ 'speaker_management', 'exhibitors', 'content_management', + 'photo_wall', 'email_outreach_campaigns', 'custom_badge_and_print_file_creation', 'custom_template_for_wa_messaging', @@ -119,7 +127,7 @@ const ORDERED_ACL_MODULE_KEYS = [ 'billing', ...COMBINED_PLAN_KEYS.filter((k) => ![ 'events', 'tracks', 'sessions', 'forms', 'web_check_in', 'attendance', 'participants', 'agenda_builder', - 'multi_track_agenda', 'speaker_management', 'exhibitors', 'content_management', + 'multi_track_agenda', 'speaker_management', 'exhibitors', 'content_management', 'photo_wall', 'email_outreach_campaigns', 'custom_badge_and_print_file_creation', 'custom_template_for_wa_messaging', 'live_polls', 'multiple_quiz_modes', 'qna_with_moderation', 'ticketing_free_and_paid', 'payment_gateway_integration', diff --git a/src/config/systemRolePermissions.js b/src/config/systemRolePermissions.js index dd1424e..4ff3594 100644 --- a/src/config/systemRolePermissions.js +++ b/src/config/systemRolePermissions.js @@ -16,7 +16,20 @@ function allPlanModules(actions, scope = 'all') { return keys.map((module) => perm(module, actions, scope)); } -const CORE_EXPLICIT = ['events', 'tracks', 'sessions', 'forms', 'web_check_in', 'attendance', 'participants', 'exhibitors', 'user_management', 'settings', 'billing']; +const CORE_EXPLICIT = [ + 'events', + 'tracks', + 'sessions', + 'forms', + 'web_check_in', + 'attendance', + 'participants', + 'exhibitors', + 'photo_wall', + 'user_management', + 'settings', + 'billing', +]; const SYSTEM_ROLE_PERMISSIONS = { super_admin: [ @@ -28,6 +41,7 @@ const SYSTEM_ROLE_PERMISSIONS = { perm('attendance', ['read', 'write', 'delete'], 'all'), perm('participants', ['read', 'write', 'delete'], 'all'), perm('exhibitors', ['read', 'write', 'delete'], 'all'), + perm('photo_wall', ['read', 'write', 'delete'], 'all'), perm('user_management', ['read', 'write', 'delete'], 'all'), perm('settings', ['read', 'write', 'delete'], 'all'), perm('billing', ['read', 'write'], 'all'), @@ -44,6 +58,7 @@ const SYSTEM_ROLE_PERMISSIONS = { perm('attendance', ['read', 'write', 'delete'], 'all'), perm('participants', ['read', 'write', 'delete'], 'all'), perm('exhibitors', ['read', 'write', 'delete'], 'all'), + perm('photo_wall', ['read', 'write', 'delete'], 'all'), perm('user_management', ['read', 'write'], 'all'), perm('settings', ['read', 'write'], 'all'), perm('billing', ['read', 'write'], 'all'), @@ -60,6 +75,7 @@ const SYSTEM_ROLE_PERMISSIONS = { perm('attendance', ['read', 'write'], 'assigned'), perm('participants', ['read', 'write'], 'all'), perm('exhibitors', ['read', 'write'], 'assigned'), + perm('photo_wall', ['read', 'write'], 'assigned'), ...allPlanModules(['read', 'write'], 'all').filter( (p) => !CORE_EXPLICIT.includes(p.module), ), @@ -73,6 +89,7 @@ const SYSTEM_ROLE_PERMISSIONS = { perm('attendance', ['read'], 'assigned'), perm('participants', ['read'], 'all'), perm('exhibitors', ['read'], 'assigned'), + perm('photo_wall', ['read'], 'assigned'), ...allPlanModules(['read'], 'all').filter( (p) => !CORE_EXPLICIT.includes(p.module), ), diff --git a/src/controllers/form.controller.js b/src/controllers/form.controller.js index d134977..a3bf683 100644 --- a/src/controllers/form.controller.js +++ b/src/controllers/form.controller.js @@ -40,8 +40,27 @@ const { const { buildParticipantQr } = require('../utils/webCheckInQr'); +/** + * Mongoose Map / MongoDB document paths reject `.` and `$` in keys. + * Feedback forms often use labels like "1. Full Name" or "Session I: …" as keys. + * We store with fullwidth substitutes, then restore on read for UI/export lookups. + */ +function sanitizeMongoMapKey(key) { + return String(key ?? '') + .replace(/\./g, '\uFF0E') // fullwidth . — keeps "1. Name" readable + .replace(/\$/g, '\uFF04') // fullwidth $ + .replace(/\0/g, '') + .trim(); +} + +function unsanitizeMongoMapKey(key) { + return String(key ?? '') + .replace(/\uFF0E/g, '.') + .replace(/\uFF04/g, '$'); +} + function safeFormDataForMongo(val) { - // Ensure we never pass objects with "$..." keys into a Mongoose Map. + // Ensure we never pass objects with "$..." keys or dotted keys into a Mongoose Map. // Some upstream code can accidentally include mongoose internals like "$__deferred". const strip = (x) => { if (x == null) return x; @@ -54,8 +73,16 @@ function safeFormDataForMongo(val) { const out = {}; for (const [k, v] of Object.entries(x)) { - if (k.startsWith('$')) continue; - out[k] = strip(v); + // Drop mongoose / operator internals (ASCII $ prefix only). + if (typeof k === 'string' && k.startsWith('$')) continue; + const key = sanitizeMongoMapKey(k); + if (!key) continue; + let unique = key; + let n = 2; + while (Object.prototype.hasOwnProperty.call(out, unique)) { + unique = `${key}_${n++}`; + } + out[unique] = strip(v); } return out; }; @@ -71,7 +98,7 @@ function mapToPlain(obj) { function safeMergeSubmissionData(submissionDoc, extra) { const current = mapToPlain(submissionDoc?.data); - return { ...current, ...extra }; + return safeFormDataForMongo({ ...current, ...extra }); } function throwRegError(message, code) { @@ -850,8 +877,34 @@ const submit = async (request, h) => { function dataMapToObject(data) { if (!data) return {}; - if (data instanceof Map) return Object.fromEntries(data); - return typeof data === 'object' ? { ...data } : {}; + const raw = + data instanceof Map + ? Object.fromEntries(data) + : typeof data === 'object' + ? { ...data } + : {}; + const out = {}; + for (const [k, v] of Object.entries(raw)) { + // Prefer restored ASCII keys so UI/export can match form field labels/names. + const restored = unsanitizeMongoMapKey(k); + if (out[restored] === undefined) out[restored] = v; + // Keep original too (covers any consumer still using sanitized keys). + if (restored !== k && out[k] === undefined) out[k] = v; + } + return out; +} + +/** Resolve a field value from submission data, tolerating sanitized Map keys. */ +function getSubmissionFieldValue(dataObj, key) { + if (!key) return ''; + if (dataObj[key] != null && dataObj[key] !== '') return dataObj[key]; + const sanitized = sanitizeMongoMapKey(key); + if (dataObj[sanitized] != null && dataObj[sanitized] !== '') return dataObj[sanitized]; + const want = String(key).replace(/\s+/g, ' ').trim(); + for (const [k, v] of Object.entries(dataObj)) { + if (String(k).replace(/\s+/g, ' ').trim() === want && v != null && v !== '') return v; + } + return dataObj[key] ?? dataObj[sanitized] ?? ''; } function submissionSearchBlob(sub) { @@ -924,7 +977,7 @@ function submissionsToCsv(form, rows) { for (const s of rows) { const o = dataMapToObject(s.data); const email = s.participant && typeof s.participant === 'object' ? s.participant.email || '' : ''; - const vals = cols.map((c) => o[c.key] ?? ''); + const vals = cols.map((c) => getSubmissionFieldValue(o, c.key)); const row = [new Date(s.submittedAt).toISOString(), email, ...vals]; lines.push(row.map(escapeCsvCell).join(',')); } @@ -976,7 +1029,11 @@ const listSubmissions = async (request, h) => { 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); + const submissions = all.slice(skip, skip + limit).map((s) => ({ + ...s, + // Restore ASCII `.` / `$` in keys so the console can match field labels. + data: dataMapToObject(s.data), + })); return h.response(success({ submissions, total, page, limit })); }; diff --git a/src/controllers/photoWall.controller.js b/src/controllers/photoWall.controller.js new file mode 100644 index 0000000..6c07d8c --- /dev/null +++ b/src/controllers/photoWall.controller.js @@ -0,0 +1,429 @@ +const mongoose = require('mongoose'); +const PhotoWall = require('../models/PhotoWall'); +const PhotoWallPhoto = require('../models/PhotoWallPhoto'); +const Event = require('../models/Event'); +const Form = require('../models/Form'); +const { success, error } = require('../utils/response'); +const { deleteEventPhotoFromS3, resolvePhotoPublicUrl } = require('../utils/s3EventPhotos'); +const { normalizeEventHeaderDisplay } = require('../utils/formPublicHelpers'); +const { + normalizeFrameImageLayout, + normalizeFrameLayout, + defaultFrameImageLayout, +} = require('../utils/photoWallFrameLayout'); +const { renderAndUploadFramedPhoto } = require('../utils/photoWallFrameRender'); + +function toOid(v) { + if (!v) return null; + if (v instanceof mongoose.Types.ObjectId) return v; + if (!mongoose.Types.ObjectId.isValid(String(v))) return null; + return new mongoose.Types.ObjectId(String(v)); +} + +async function assertEventOrgAccess(eventId, user) { + if (user.role === 'super_admin') return true; + const oid = user.organization?._id || user.organization; + const event = await Event.findById(eventId).select('organization').lean(); + if (!event) return false; + return event.organization?.toString() === oid?.toString(); +} + +function ensureTokens(wall) { + let changed = false; + if (!wall.uploadToken) { + wall.uploadToken = PhotoWall.generateToken(); + changed = true; + } + if (!wall.presentToken) { + wall.presentToken = PhotoWall.generateToken(); + changed = true; + } + return changed; +} + +async function getOrCreateWall(eventId) { + const event = await Event.findById(eventId).select('organization').lean(); + if (!event) return null; + + let wall = await PhotoWall.findOne({ event: eventId }); + if (!wall) { + wall = await PhotoWall.create({ + event: eventId, + organization: event.organization, + enabled: false, + uploadToken: PhotoWall.generateToken(), + presentToken: PhotoWall.generateToken(), + }); + } else if (ensureTokens(wall)) { + await wall.save(); + } + return wall; +} + +function serializeWall(wall, counts = {}) { + return { + _id: wall._id, + event: wall.event, + organization: wall.organization, + enabled: Boolean(wall.enabled), + accessMode: wall.accessMode || 'open', + collectDetails: Boolean(wall.collectDetails), + formId: wall.formId || null, + createParticipant: Boolean(wall.createParticipant), + moderationMode: wall.moderationMode || 'auto', + photosPerRow: wall.photosPerRow ?? 4, + maxPhotoWidth: wall.maxPhotoWidth ?? 320, + maxPhotoHeight: wall.maxPhotoHeight ?? 320, + gapPx: wall.gapPx ?? 8, + showUploaderNameOnWall: Boolean(wall.showUploaderNameOnWall), + requireUploadCrop: Boolean(wall.requireUploadCrop), + frameEnabled: Boolean(wall.frameEnabled), + frameImageUrl: wall.frameImageUrl || null, + frameImageLayout: normalizeFrameImageLayout( + wall.frameImageLayout || defaultFrameImageLayout(), + ), + frameCanvasWidth: wall.frameCanvasWidth ?? 1080, + frameCanvasHeight: wall.frameCanvasHeight ?? 1080, + frameLayout: normalizeFrameLayout( + wall.frameLayout, + wall.frameCanvasWidth ?? 1080, + wall.frameCanvasHeight ?? 1080, + ), + allowFramedDownload: wall.allowFramedDownload !== false, + sharePlatforms: { + linkedin: Boolean(wall.sharePlatforms?.linkedin), + facebook: Boolean(wall.sharePlatforms?.facebook), + instagram: Boolean(wall.sharePlatforms?.instagram), + snapchat: Boolean(wall.sharePlatforms?.snapchat), + x: Boolean(wall.sharePlatforms?.x), + whatsapp: Boolean(wall.sharePlatforms?.whatsapp), + }, + eventHeader: normalizeEventHeaderDisplay(wall.eventHeader), + uploadToken: wall.uploadToken || null, + presentToken: wall.presentToken || null, + photoCounts: { + total: counts.total ?? 0, + approved: counts.approved ?? 0, + pending: counts.pending ?? 0, + removed: counts.removed ?? 0, + }, + createdAt: wall.createdAt, + updatedAt: wall.updatedAt, + }; +} + +async function photoCountsForWall(photoWallId) { + const rows = await PhotoWallPhoto.aggregate([ + { $match: { photoWall: toOid(photoWallId) } }, + { $group: { _id: '$status', count: { $sum: 1 } } }, + ]); + const byStatus = Object.fromEntries(rows.map((r) => [r._id, r.count])); + const approved = byStatus.approved || 0; + const pending = byStatus.pending || 0; + const removed = byStatus.removed || 0; + return { + total: approved + pending + removed, + approved, + pending, + removed, + }; +} + +function serializePhoto(doc) { + const formData = + doc.formData instanceof Map ? Object.fromEntries(doc.formData) : doc.formData || null; + return { + _id: doc._id, + photoWall: doc.photoWall, + event: doc.event, + organization: doc.organization, + url: resolvePhotoPublicUrl(doc.url) || doc.url, + s3Key: doc.s3Key, + framedUrl: resolvePhotoPublicUrl(doc.framedUrl), + framedS3Key: doc.framedS3Key || null, + mimeType: doc.mimeType, + size: doc.size, + originalFileName: doc.originalFileName, + displayName: doc.displayName || null, + status: doc.status, + formData, + formSubmission: doc.formSubmission || null, + participant: doc.participant || null, + uploadedByParticipant: doc.uploadedByParticipant || null, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + }; +} + +const getPhotoWall = async (request, h) => { + const user = request.auth.credentials?.user; + const { eventId } = request.params; + if (!user) return h.response(error('UNAUTHORIZED', 'Not authenticated')).code(401); + if (!mongoose.Types.ObjectId.isValid(eventId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400); + } + if (!(await assertEventOrgAccess(eventId, user))) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + + const wall = await getOrCreateWall(eventId); + if (!wall) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + + const counts = await photoCountsForWall(wall._id); + return h.response(success(serializeWall(wall, counts))).code(200); +}; + +const updatePhotoWall = async (request, h) => { + const user = request.auth.credentials?.user; + const { eventId } = request.params; + if (!user) return h.response(error('UNAUTHORIZED', 'Not authenticated')).code(401); + if (!mongoose.Types.ObjectId.isValid(eventId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400); + } + if (!(await assertEventOrgAccess(eventId, user))) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + + const wall = await getOrCreateWall(eventId); + if (!wall) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + + const p = request.payload || {}; + + if (typeof p.enabled === 'boolean') { + wall.enabled = p.enabled; + if (p.enabled) ensureTokens(wall); + } + if (p.accessMode === 'open' || p.accessMode === 'login') wall.accessMode = p.accessMode; + if (typeof p.collectDetails === 'boolean') wall.collectDetails = p.collectDetails; + if (typeof p.createParticipant === 'boolean') wall.createParticipant = p.createParticipant; + if (p.moderationMode === 'auto' || p.moderationMode === 'approval') { + wall.moderationMode = p.moderationMode; + } + if (p.photosPerRow != null) { + const n = Number(p.photosPerRow); + if (Number.isFinite(n)) wall.photosPerRow = Math.min(12, Math.max(1, Math.round(n))); + } + if (p.maxPhotoWidth != null) { + const n = Number(p.maxPhotoWidth); + if (Number.isFinite(n)) wall.maxPhotoWidth = Math.min(1200, Math.max(80, Math.round(n))); + } + if (p.maxPhotoHeight != null) { + const n = Number(p.maxPhotoHeight); + if (Number.isFinite(n)) wall.maxPhotoHeight = Math.min(1200, Math.max(80, Math.round(n))); + } + if (p.gapPx != null) { + const n = Number(p.gapPx); + if (Number.isFinite(n)) wall.gapPx = Math.min(64, Math.max(0, Math.round(n))); + } + if (typeof p.showUploaderNameOnWall === 'boolean') { + wall.showUploaderNameOnWall = p.showUploaderNameOnWall; + } + if (typeof p.requireUploadCrop === 'boolean') { + wall.requireUploadCrop = p.requireUploadCrop; + } + if (typeof p.frameEnabled === 'boolean') { + wall.frameEnabled = p.frameEnabled; + } + if (p.frameImageUrl === null || p.frameImageUrl === '') { + wall.frameImageUrl = null; + } else if (typeof p.frameImageUrl === 'string') { + wall.frameImageUrl = p.frameImageUrl.trim() || null; + } + if (p.frameImageLayout != null && typeof p.frameImageLayout === 'object') { + wall.frameImageLayout = normalizeFrameImageLayout(p.frameImageLayout); + } + if (p.frameCanvasWidth != null) { + const n = Number(p.frameCanvasWidth); + if (Number.isFinite(n)) wall.frameCanvasWidth = Math.min(4096, Math.max(200, Math.round(n))); + } + if (p.frameCanvasHeight != null) { + const n = Number(p.frameCanvasHeight); + if (Number.isFinite(n)) wall.frameCanvasHeight = Math.min(4096, Math.max(200, Math.round(n))); + } + if (Array.isArray(p.frameLayout)) { + const cw = p.frameCanvasWidth != null + ? Math.min(4096, Math.max(200, Math.round(Number(p.frameCanvasWidth)))) + : wall.frameCanvasWidth ?? 1080; + const ch = p.frameCanvasHeight != null + ? Math.min(4096, Math.max(200, Math.round(Number(p.frameCanvasHeight)))) + : wall.frameCanvasHeight ?? 1080; + wall.frameLayout = normalizeFrameLayout(p.frameLayout, cw, ch); + } + if (typeof p.allowFramedDownload === 'boolean') { + wall.allowFramedDownload = p.allowFramedDownload; + } + if (p.sharePlatforms != null && typeof p.sharePlatforms === 'object') { + const sp = p.sharePlatforms; + wall.sharePlatforms = { + linkedin: Boolean(sp.linkedin), + facebook: Boolean(sp.facebook), + instagram: Boolean(sp.instagram), + snapchat: Boolean(sp.snapchat), + x: Boolean(sp.x), + whatsapp: Boolean(sp.whatsapp), + }; + } + + if (p.eventHeader != null && typeof p.eventHeader === 'object') { + wall.eventHeader = normalizeEventHeaderDisplay({ + ...(wall.eventHeader?.toObject?.() || wall.eventHeader || {}), + ...p.eventHeader, + }); + } + + if (p.formId === null || p.formId === '') { + wall.formId = null; + } else if (p.formId != null) { + const formOid = toOid(p.formId); + if (!formOid) { + return h.response(error('VALIDATION_ERROR', 'Invalid formId')).code(400); + } + const form = await Form.findById(formOid).select('event tenantOrganization').lean(); + if (!form) return h.response(error('NOT_FOUND', 'Form not found')).code(404); + const formEvent = form.event ? String(form.event) : null; + if (formEvent && formEvent !== String(eventId)) { + return h.response(error('VALIDATION_ERROR', 'Form does not belong to this event')).code(400); + } + wall.formId = formOid; + } + + if (p.regenerateTokens === true) { + wall.uploadToken = PhotoWall.generateToken(); + wall.presentToken = PhotoWall.generateToken(); + } + + await wall.save(); + const counts = await photoCountsForWall(wall._id); + return h.response(success(serializeWall(wall, counts), 'Photo wall updated')).code(200); +}; + +const ensureTokensHandler = async (request, h) => { + const user = request.auth.credentials?.user; + const { eventId } = request.params; + if (!user) return h.response(error('UNAUTHORIZED', 'Not authenticated')).code(401); + if (!mongoose.Types.ObjectId.isValid(eventId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400); + } + if (!(await assertEventOrgAccess(eventId, user))) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + + const wall = await getOrCreateWall(eventId); + if (!wall) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + if (ensureTokens(wall)) await wall.save(); + + const counts = await photoCountsForWall(wall._id); + return h.response(success(serializeWall(wall, counts))).code(200); +}; + +const listPhotos = async (request, h) => { + const user = request.auth.credentials?.user; + const { eventId } = request.params; + if (!user) return h.response(error('UNAUTHORIZED', 'Not authenticated')).code(401); + if (!mongoose.Types.ObjectId.isValid(eventId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400); + } + if (!(await assertEventOrgAccess(eventId, user))) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + + const wall = await PhotoWall.findOne({ event: eventId }).lean(); + if (!wall) { + return h.response(success({ photos: [] })).code(200); + } + + const status = request.query?.status; + const filter = { photoWall: wall._id }; + if (status && ['pending', 'approved', 'removed'].includes(String(status))) { + filter.status = String(status); + } + + const photos = await PhotoWallPhoto.find(filter).sort({ createdAt: -1 }).lean(); + return h.response(success({ photos: photos.map(serializePhoto) })).code(200); +}; + +const patchPhoto = async (request, h) => { + const user = request.auth.credentials?.user; + const { eventId, photoId } = request.params; + if (!user) return h.response(error('UNAUTHORIZED', 'Not authenticated')).code(401); + if (!mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(photoId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid id')).code(400); + } + if (!(await assertEventOrgAccess(eventId, user))) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + + const status = request.payload?.status; + if (!['pending', 'approved', 'removed'].includes(status)) { + return h.response(error('VALIDATION_ERROR', 'Invalid status')).code(400); + } + + const photo = await PhotoWallPhoto.findOne({ _id: photoId, event: eventId }); + if (!photo) return h.response(error('NOT_FOUND', 'Photo not found')).code(404); + + const wall = await PhotoWall.findOne({ event: eventId }); + photo.status = status; + + if (status === 'approved' && wall?.frameEnabled && wall.frameImageUrl) { + try { + const framed = await renderAndUploadFramedPhoto({ wall, photo }); + if (photo.framedS3Key && photo.framedS3Key !== framed.framedS3Key) { + try { + await deleteEventPhotoFromS3({ s3Key: photo.framedS3Key }); + } catch { + /* ignore */ + } + } + photo.framedUrl = framed.framedUrl; + photo.framedS3Key = framed.framedS3Key; + } catch (err) { + console.error('[photoWall] frame composite on approve failed', err?.message); + } + } + + await photo.save(); + return h.response(success(serializePhoto(photo), 'Photo updated')).code(200); +}; + +const deletePhoto = async (request, h) => { + const user = request.auth.credentials?.user; + const { eventId, photoId } = request.params; + if (!user) return h.response(error('UNAUTHORIZED', 'Not authenticated')).code(401); + if (!mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(photoId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid id')).code(400); + } + if (!(await assertEventOrgAccess(eventId, user))) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + + const photo = await PhotoWallPhoto.findOne({ _id: photoId, event: eventId }); + if (!photo) return h.response(error('NOT_FOUND', 'Photo not found')).code(404); + + try { + await deleteEventPhotoFromS3({ s3Key: photo.s3Key }); + } catch (err) { + console.error('[photoWall] S3 delete failed', photo.s3Key, err?.message); + } + if (photo.framedS3Key) { + try { + await deleteEventPhotoFromS3({ s3Key: photo.framedS3Key }); + } catch (err) { + console.error('[photoWall] framed S3 delete failed', photo.framedS3Key, err?.message); + } + } + + await PhotoWallPhoto.deleteOne({ _id: photo._id }); + return h.response(success({ deleted: true }, 'Photo deleted')).code(200); +}; + +module.exports = { + getPhotoWall, + updatePhotoWall, + ensureTokensHandler, + listPhotos, + patchPhoto, + deletePhoto, + getOrCreateWall, + serializeWall, + serializePhoto, +}; diff --git a/src/controllers/publicDemoRequest.controller.js b/src/controllers/publicDemoRequest.controller.js new file mode 100644 index 0000000..f78cbaa --- /dev/null +++ b/src/controllers/publicDemoRequest.controller.js @@ -0,0 +1,82 @@ +const { success, error } = require('../utils/response'); +const { verifyCaptchaChallenge } = require('../utils/publicCaptcha'); +const { sendWebsiteDemoRequestEmail } = require('../utils/mail'); + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const MOBILE_RE = /^\d{10,15}$/; + +function demoRequestRecipients() { + const raw = + process.env.WEBSITE_DEMO_REQUEST_TO || + process.env.DEMO_REQUEST_TO_EMAIL || + 'shabareesh@stratawiz.com'; + return String(raw) + .split(',') + .map((s) => s.trim()) + .filter(Boolean); +} + +const submit = async (request, h) => { + const { + name: rawName, + mobile: rawMobile, + email: rawEmail, + message: rawMessage, + captchaToken, + captchaAnswer, + } = request.payload || {}; + + const name = String(rawName || '').trim(); + const mobile = String(rawMobile || '').trim(); + const email = String(rawEmail || '').trim(); + const message = String(rawMessage || '').trim(); + const mobileDigits = mobile.replace(/[\s\-().]/g, ''); + + if (!name) { + return h.response(error('VALIDATION_ERROR', 'Full name is required.')).code(400); + } + if (!mobileDigits || !MOBILE_RE.test(mobileDigits)) { + return h + .response(error('VALIDATION_ERROR', 'Enter a valid mobile number (10-15 digits).')) + .code(400); + } + if (!email || !EMAIL_RE.test(email)) { + return h.response(error('VALIDATION_ERROR', 'Enter a valid email address.')).code(400); + } + if (!message) { + return h.response(error('VALIDATION_ERROR', 'Message is required.')).code(400); + } + + if (!verifyCaptchaChallenge(captchaToken, captchaAnswer)) { + return h + .response(error('VALIDATION_ERROR', 'Captcha verification failed. Please try again.')) + .code(400); + } + + const to = demoRequestRecipients(); + if (to.length === 0) { + return h + .response(error('CONFIG_ERROR', 'Demo request recipient is not configured.')) + .code(503); + } + + const ok = await sendWebsiteDemoRequestEmail({ + to, + name, + mobile: mobileDigits, + email, + message, + }); + + if (!ok) { + return h + .response(error('MAIL_FAILED', 'Could not send your request. Please try again shortly.')) + .code(502); + } + + return h.response(success({ sent: true }, 'Demo request sent')).code(200); +}; + +module.exports = { + submit, +}; diff --git a/src/controllers/publicPhotoWall.controller.js b/src/controllers/publicPhotoWall.controller.js new file mode 100644 index 0000000..23c37f6 --- /dev/null +++ b/src/controllers/publicPhotoWall.controller.js @@ -0,0 +1,555 @@ +const mongoose = require('mongoose'); +const PhotoWall = require('../models/PhotoWall'); +const PhotoWallPhoto = require('../models/PhotoWallPhoto'); +const Event = require('../models/Event'); +const EventMicrosite = require('../models/EventMicrosite'); +const Form = require('../models/Form'); +const FormSubmission = require('../models/FormSubmission'); +const Participant = require('../models/Participant'); +const { success, error } = require('../utils/response'); +const { uploadPhotoWallPhotoToS3, getEventPhotoBufferFromS3, resolvePhotoPublicUrl } = require('../utils/s3EventPhotos'); +const { getMembership } = require('../utils/participantMembership'); +const { serializePhoto } = require('./photoWall.controller'); +const { renderAndUploadFramedPhoto } = require('../utils/photoWallFrameRender'); +const { + EVENT_PUBLIC_SELECT, + buildPublicEventPayload, + normalizeEventHeaderDisplay, +} = require('../utils/formPublicHelpers'); + +const ALLOWED_MIME = new Set([ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/bmp', +]); + +async function streamToBuffer(stream) { + const chunks = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + return Buffer.concat(chunks); +} + +async function extractSingleFile(payload) { + const raw = payload?.file || payload?.files || payload?.photo; + if (!raw) return null; + const file = Array.isArray(raw) ? raw[0] : raw; + if (!file) return null; + + let buffer; + if (Buffer.isBuffer(file)) { + buffer = file; + } else if (typeof file.pipe === 'function' || typeof file[Symbol.asyncIterator] === 'function') { + buffer = await streamToBuffer(file); + } else if (file._data && Buffer.isBuffer(file._data)) { + buffer = file._data; + } else { + return null; + } + + const originalName = file.hapi?.filename || file.filename || 'selfie.jpg'; + const mimeType = + file.hapi?.headers?.['content-type'] || + file.headers?.['content-type'] || + payload?.mimeType || + 'image/jpeg'; + + return { buffer, originalName, mimeType }; +} + +async function parseFormDataPayload(payload) { + let raw = payload?.formData ?? payload?.data; + if (raw == null || raw === '') return {}; + if (typeof raw === 'object' && !Buffer.isBuffer(raw) && typeof raw.pipe !== 'function' && typeof raw[Symbol.asyncIterator] !== 'function') { + return { ...raw }; + } + if (typeof raw.pipe === 'function' || typeof raw[Symbol.asyncIterator] === 'function') { + const buf = await streamToBuffer(raw); + raw = buf.toString('utf8'); + } else if (Buffer.isBuffer(raw)) { + raw = raw.toString('utf8'); + } + try { + const parsed = JSON.parse(String(raw)); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} + +async function parseStringField(payload, key) { + let raw = payload?.[key]; + if (raw == null || raw === '') return ''; + if (typeof raw === 'string') return raw.trim(); + if (Buffer.isBuffer(raw)) return raw.toString('utf8').trim(); + if (typeof raw.pipe === 'function' || typeof raw[Symbol.asyncIterator] === 'function') { + const buf = await streamToBuffer(raw); + return buf.toString('utf8').trim(); + } + return String(raw).trim(); +} + +function participantDisplayName(participant) { + if (!participant || typeof participant !== 'object') return ''; + if (participant.displayName != null && String(participant.displayName).trim() !== '') { + return String(participant.displayName).trim(); + } + const parts = [participant.firstName, participant.lastName] + .filter((x) => x != null && String(x).trim() !== '') + .map((x) => String(x).trim()); + if (parts.length) return parts.join(' '); + if (participant.name != null && String(participant.name).trim() !== '') { + return String(participant.name).trim(); + } + return ''; +} + +function fieldDataKey(f) { + return String(f?.name || f?.label || '').trim(); +} + +function valueFromFormData(formData, form, mapsTo, typeHint, nameHints = []) { + const fields = form?.fields || []; + const byMaps = fields.find((f) => f.mapsTo === mapsTo); + if (byMaps) { + const key = fieldDataKey(byMaps); + if (key && formData[key] != null && String(formData[key]).trim() !== '') { + return String(formData[key]).trim(); + } + } + if (typeHint) { + const byType = fields.find((f) => f.type === typeHint); + if (byType) { + const key = fieldDataKey(byType); + if (key && formData[key] != null && String(formData[key]).trim() !== '') { + return String(formData[key]).trim(); + } + } + } + for (const hint of nameHints) { + for (const [k, v] of Object.entries(formData)) { + if (String(k).toLowerCase().includes(hint) && v != null && String(v).trim() !== '') { + return String(v).trim(); + } + } + } + return ''; +} + +async function upsertParticipantFromPhotoWall({ organizationId, eventId, form, formData }) { + const emailRaw = valueFromFormData(formData, form, 'email', 'email', ['email']); + const email = emailRaw.toLowerCase(); + if (!email || !email.includes('@')) { + const err = new Error('Email is required to create a participant'); + err.code = 'EMAIL_REQUIRED'; + throw err; + } + + let firstName = valueFromFormData(formData, form, 'firstName', null, ['firstname', 'first_name', 'first name']); + let lastName = valueFromFormData(formData, form, 'lastName', null, ['lastname', 'last_name', 'last name']); + const displayName = valueFromFormData(formData, form, 'displayName', null, ['displayname', 'display_name', 'full name']); + const name = valueFromFormData(formData, form, null, null, ['name']) || displayName; + + if ((!firstName || !lastName) && name) { + const parts = name.trim().split(/\s+/); + if (!firstName) firstName = parts[0] || 'Guest'; + if (!lastName) lastName = parts.slice(1).join(' ') || 'Visitor'; + } + if (!firstName) firstName = 'Guest'; + if (!lastName) lastName = 'Visitor'; + + const phone = valueFromFormData(formData, form, 'phone', 'phone', ['phone', 'mobile']); + const organization = valueFromFormData(formData, form, 'organization', null, ['organization', 'company']); + const designation = valueFromFormData(formData, form, 'designation', null, ['designation', 'title']); + + const orgOid = new mongoose.Types.ObjectId(String(organizationId)); + let p = await Participant.findOne({ tenantOrganization: orgOid, email }); + if (!p) { + p = await Participant.create({ + tenantOrganization: orgOid, + email, + firstName, + lastName, + phone: phone || undefined, + organization: organization || undefined, + designation: designation || undefined, + displayName: displayName || undefined, + }); + } else { + if (firstName) p.firstName = firstName; + if (lastName) p.lastName = lastName; + if (phone) p.phone = phone; + if (organization) p.organization = organization; + if (designation) p.designation = designation; + if (displayName) p.displayName = displayName; + await p.save(); + } + + if (!getMembership(p, eventId)) { + p.eventMemberships = p.eventMemberships || []; + p.eventMemberships.push({ + event: eventId, + status: 'confirmed', + registrationDate: new Date(), + }); + await p.save(); + } + + return p._id; +} + +function publicFormFields(form) { + if (!form) return null; + return { + _id: form._id, + title: form.name || form.title, + description: form.description, + type: form.type, + fields: (form.fields || []).map((f) => ({ + label: f.label, + name: f.name, + type: f.type, + required: Boolean(f.required), + options: f.options || [], + placeholder: f.placeholder, + mapsTo: f.mapsTo, + allowOther: f.allowOther, + otherLabel: f.otherLabel, + uiType: f.uiType, + })), + settings: { + thankYouMessage: form.settings?.thankYouMessage, + isActive: form.settings?.isActive !== false, + }, + }; +} + +const getUploadMeta = async (request, h) => { + const { uploadToken } = request.params; + const wall = await PhotoWall.findOne({ uploadToken: String(uploadToken || '').trim() }).lean(); + if (!wall || !wall.enabled) { + return h.response(error('NOT_FOUND', 'Photo wall not available')).code(404); + } + + const event = await Event.findById(wall.event).select(EVENT_PUBLIC_SELECT).lean(); + const ms = event + ? await EventMicrosite.findOne({ event: wall.event }).select('logo').lean() + : null; + const eventPublic = event ? buildPublicEventPayload(event, ms?.logo) : null; + + let form = null; + if (wall.collectDetails && wall.formId) { + form = await Form.findById(wall.formId).lean(); + if (form && form.settings?.isActive === false) form = null; + } + + const accessMode = wall.accessMode || 'open'; + const showUploaderNameOnWall = Boolean(wall.showUploaderNameOnWall); + return h + .response( + success({ + eventId: wall.event, + eventName: eventPublic?.name || event?.name || 'Event', + event: eventPublic, + eventHeader: normalizeEventHeaderDisplay(wall.eventHeader), + accessMode, + collectDetails: Boolean(wall.collectDetails), + createParticipant: Boolean(wall.createParticipant), + moderationMode: wall.moderationMode || 'auto', + showUploaderNameOnWall, + requireUploadCrop: Boolean(wall.requireUploadCrop), + askUploaderName: showUploaderNameOnWall && accessMode !== 'login', + maxPhotoWidth: wall.maxPhotoWidth ?? 320, + maxPhotoHeight: wall.maxPhotoHeight ?? 320, + frameEnabled: Boolean(wall.frameEnabled), + allowFramedDownload: wall.allowFramedDownload !== false, + sharePlatforms: { + linkedin: Boolean(wall.sharePlatforms?.linkedin), + facebook: Boolean(wall.sharePlatforms?.facebook), + instagram: Boolean(wall.sharePlatforms?.instagram), + snapchat: Boolean(wall.sharePlatforms?.snapchat), + x: Boolean(wall.sharePlatforms?.x), + whatsapp: Boolean(wall.sharePlatforms?.whatsapp), + }, + form: publicFormFields(form), + }), + ) + .code(200); +}; + +const uploadPhoto = async (request, h) => { + const { uploadToken } = request.params; + const wall = await PhotoWall.findOne({ uploadToken: String(uploadToken || '').trim() }); + if (!wall || !wall.enabled) { + return h.response(error('NOT_FOUND', 'Photo wall not available')).code(404); + } + + const participantCred = request.auth?.credentials?.participant; + if (wall.accessMode === 'login' && !participantCred?._id) { + return h.response(error('UNAUTHORIZED', 'Login required to upload')).code(401); + } + + const file = await extractSingleFile(request.payload || {}); + if (!file?.buffer?.length) { + return h.response(error('VALIDATION_ERROR', 'Photo file is required')).code(400); + } + if (!ALLOWED_MIME.has(String(file.mimeType || '').toLowerCase())) { + return h.response(error('VALIDATION_ERROR', 'Unsupported image type')).code(400); + } + if (file.buffer.length > 15 * 1024 * 1024) { + return h.response(error('VALIDATION_ERROR', 'Image must be under 15MB')).code(400); + } + + const formData = await parseFormDataPayload(request.payload || {}); + let form = null; + if (wall.collectDetails && wall.formId) { + form = await Form.findById(wall.formId).lean(); + if (form) { + for (const f of form.fields || []) { + if (f.type === 'section' || !f.required) continue; + const key = fieldDataKey(f); + if (!key) continue; + const val = formData[key]; + if (val == null || String(val).trim() === '') { + return h + .response(error('VALIDATION_ERROR', `${f.label || key} is required`)) + .code(400); + } + } + } + } + + let photoDisplayName = null; + if (wall.showUploaderNameOnWall) { + if (wall.accessMode === 'login') { + let pDoc = participantCred; + if (participantCred?._id) { + const fromDb = await Participant.findById(participantCred._id) + .select('displayName firstName lastName') + .lean(); + if (fromDb) pDoc = fromDb; + } + photoDisplayName = participantDisplayName(pDoc); + if (!photoDisplayName) { + return h + .response(error('VALIDATION_ERROR', 'Your profile needs a name to appear on the wall')) + .code(400); + } + } else { + photoDisplayName = await parseStringField(request.payload || {}, 'displayName'); + if (!photoDisplayName) { + return h.response(error('VALIDATION_ERROR', 'Name is required')).code(400); + } + if (photoDisplayName.length > 80) photoDisplayName = photoDisplayName.slice(0, 80); + } + } + + let participantId = participantCred?._id || null; + if (wall.createParticipant && Object.keys(formData).length) { + try { + participantId = await upsertParticipantFromPhotoWall({ + organizationId: wall.organization, + eventId: wall.event, + form, + formData, + }); + } catch (err) { + if (err.code === 'EMAIL_REQUIRED') { + return h.response(error('VALIDATION_ERROR', err.message)).code(400); + } + throw err; + } + } + + let formSubmissionId = null; + if (form && Object.keys(formData).length) { + const submission = await FormSubmission.create({ + form: form._id, + tenantOrganization: wall.organization, + participant: participantId || undefined, + event: wall.event, + data: formData, + status: 'submitted', + }); + formSubmissionId = submission._id; + } + + const safeName = String(file.originalName || 'selfie.jpg') + .replace(/[^a-zA-Z0-9._-]/g, '_') + .slice(0, 80); + const storedFilename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}-${safeName}`; + + let uploaded; + try { + uploaded = await uploadPhotoWallPhotoToS3({ + buffer: file.buffer, + contentType: file.mimeType, + eventId: String(wall.event), + filename: storedFilename, + framed: false, + }); + } catch (err) { + console.error('[photoWall] S3 upload failed', err?.message); + return h.response(error('UPLOAD_FAILED', 'Failed to upload photo')).code(500); + } + + const status = wall.moderationMode === 'approval' ? 'pending' : 'approved'; + const photo = await PhotoWallPhoto.create({ + photoWall: wall._id, + event: wall.event, + organization: wall.organization, + url: uploaded.url, + s3Key: uploaded.s3Key, + mimeType: file.mimeType, + size: file.buffer.length, + originalFileName: file.originalName, + displayName: photoDisplayName || undefined, + status, + formData: Object.keys(formData).length ? formData : undefined, + formSubmission: formSubmissionId || undefined, + participant: participantId || undefined, + uploadedByParticipant: participantCred?._id || undefined, + }); + + if (wall.frameEnabled && wall.frameImageUrl) { + try { + const framed = await renderAndUploadFramedPhoto({ wall, photo }); + photo.framedUrl = framed.framedUrl; + photo.framedS3Key = framed.framedS3Key; + await photo.save(); + } catch (err) { + console.error('[photoWall] frame composite on upload failed', err?.message); + } + } + + const fileUrl = `/api/public/photo-wall/upload/${encodeURIComponent(String(uploadToken).trim())}/photos/${photo._id}/file`; + const previewUrl = resolvePhotoPublicUrl(photo.framedUrl || photo.url); + + return h + .response( + success( + { + photo: serializePhoto(photo), + awaitingApproval: status === 'pending', + previewUrl, + /** Same-origin API URL for download/share (avoids CloudFront CORS on fetch). */ + fileUrl, + framed: Boolean(photo.framedUrl), + }, + status === 'pending' ? 'Photo submitted for approval' : 'Photo uploaded', + ), + ) + .code(201); +}; + +function safeDownloadFilename(name, fallback = 'photo-wall.jpg') { + const raw = String(name || fallback).trim() || fallback; + return raw.replace(/[^\w.\-()+ ]+/g, '_').slice(0, 120) || fallback; +} + +/** + * Stream photo bytes through the API so the visitor app can download/share + * without fetching CloudFront directly (no CORS on pictures CDN). + */ +const downloadPhotoFile = async (request, h) => { + const { uploadToken, photoId } = request.params; + const wall = await PhotoWall.findOne({ uploadToken: String(uploadToken || '').trim() }).lean(); + if (!wall || !wall.enabled) { + return h.response(error('NOT_FOUND', 'Photo wall not available')).code(404); + } + if (!mongoose.Types.ObjectId.isValid(photoId)) { + return h.response(error('VALIDATION_ERROR', 'Invalid photo id')).code(400); + } + + const photo = await PhotoWallPhoto.findOne({ _id: photoId, photoWall: wall._id }).lean(); + if (!photo) return h.response(error('NOT_FOUND', 'Photo not found')).code(404); + + const s3Key = photo.framedS3Key || photo.s3Key; + if (!s3Key) return h.response(error('NOT_FOUND', 'Photo file not available')).code(404); + + try { + const buf = await getEventPhotoBufferFromS3(s3Key); + const mime = photo.framedS3Key ? 'image/jpeg' : photo.mimeType || 'image/jpeg'; + const filename = safeDownloadFilename( + photo.framedS3Key ? 'photo-wall-framed.jpg' : photo.originalFileName || 'photo-wall.jpg', + ); + const asAttachment = + String(request.query?.download || '').toLowerCase() === '1' || + String(request.query?.download || '').toLowerCase() === 'true'; + + return h + .response(buf) + .type(mime) + .header('Cache-Control', 'private, max-age=300') + .header( + 'Content-Disposition', + `${asAttachment ? 'attachment' : 'inline'}; filename="${filename}"`, + ); + } catch (err) { + console.error('[photoWall] file proxy failed', err?.message || err); + return h.response(error('INTERNAL', 'Could not load photo')).code(500); + } +}; + +const getPresentWall = async (request, h) => { + const { presentToken } = request.params; + const wall = await PhotoWall.findOne({ presentToken: String(presentToken || '').trim() }).lean(); + if (!wall || !wall.enabled) { + return h.response(error('NOT_FOUND', 'Photo wall not available')).code(404); + } + + const event = await Event.findById(wall.event).select('name').lean(); + const showUploaderNameOnWall = Boolean(wall.showUploaderNameOnWall); + const frameEnabled = Boolean(wall.frameEnabled); + const photos = await PhotoWallPhoto.find({ + photoWall: wall._id, + status: 'approved', + }) + .sort({ createdAt: -1 }) + .select('_id url framedUrl createdAt displayName') + .lean(); + + const maxPhotoWidth = wall.maxPhotoWidth ?? 320; + const maxPhotoHeight = wall.maxPhotoHeight ?? 320; + const frameCanvasWidth = wall.frameCanvasWidth ?? 1080; + const frameCanvasHeight = wall.frameCanvasHeight ?? 1080; + + return h + .response( + success({ + eventId: wall.event, + eventName: event?.name || 'Event', + photosPerRow: wall.photosPerRow ?? 4, + maxPhotoWidth, + maxPhotoHeight, + gapPx: wall.gapPx ?? 8, + showUploaderNameOnWall, + frameEnabled, + frameCanvasWidth, + frameCanvasHeight, + photos: photos.map((p) => { + const raw = frameEnabled && p.framedUrl ? p.framedUrl : p.url; + return { + _id: p._id, + url: resolvePhotoPublicUrl(raw) || raw, + createdAt: p.createdAt, + ...(showUploaderNameOnWall + ? { displayName: p.displayName ? String(p.displayName) : null } + : {}), + }; + }), + }), + ) + .code(200); +}; + +module.exports = { + getUploadMeta, + uploadPhoto, + downloadPhotoFile, + getPresentWall, +}; diff --git a/src/controllers/publicQuizPoll.controller.js b/src/controllers/publicQuizPoll.controller.js index 84c6965..e73f188 100644 --- a/src/controllers/publicQuizPoll.controller.js +++ b/src/controllers/publicQuizPoll.controller.js @@ -1,7 +1,10 @@ const QuizPoll = require('../models/QuizPoll'); const QuizPollResponse = require('../models/QuizPollResponse'); const { success, error } = require('../utils/response'); -const { buildQuestionPresentationStats } = require('../utils/quizPollResultsSummary'); +const { + buildQuestionPresentationStats, + buildQuizPresentationLeaderboard, +} = require('../utils/quizPollResultsSummary'); const { applyPublish, applyClose, @@ -9,7 +12,18 @@ const { } = require('../utils/quizPollLiveControls'); function presentationPublicBase() { - return (process.env.EVENTBUZZ_PUBLIC_URL || 'http://localhost:5174').replace(/\/$/, ''); + const raw = (process.env.EVENTBUZZ_PUBLIC_URL || 'http://localhost:5174').replace(/\/$/, ''); + try { + const u = new URL(raw); + // Marketing apex is not the participant SPA — rewrite to join host. + if (u.hostname === 'eventbuzz.live' || u.hostname === 'www.eventbuzz.live') { + u.hostname = 'join.eventbuzz.live'; + return u.origin; + } + } catch { + /* keep raw */ + } + return raw; } function mapQuestionForPresentation(q) { @@ -37,6 +51,7 @@ function mapSettingsForPresentation(settings) { ? String(s.liveQuestionId).trim() : null, showCorrectAnswer: Boolean(s.showCorrectAnswer), + showLeaderboard: Boolean(s.showLeaderboard), showResults: s.showResults !== false, }; } @@ -104,6 +119,24 @@ async function getQuestionStats(request, h) { return h.response(success(stats)); } +/** GET /api/public/quiz-polls/present/{token}/leaderboard */ +async function getLeaderboard(request, h) { + const quizPoll = await loadQuizPollByPresentationToken(request.params.token); + if (!quizPoll) return h.response(error('NOT_FOUND', 'Presentation not found')).code(404); + + const quizType = String(quizPoll.type || '').toLowerCase(); + if (quizType !== 'quiz') { + return h.response(error('VALIDATION_ERROR', 'Leaderboard is only available for quizzes')).code(400); + } + + const allResponses = await QuizPollResponse.find({ quizPoll: quizPoll._id }) + .populate('participant', 'displayName firstName lastName') + .lean(); + + const leaderboard = buildQuizPresentationLeaderboard(quizPoll, allResponses); + return h.response(success(leaderboard)); +} + /** POST /api/public/quiz-polls/present/{token}/publish */ async function publishPresentation(request, h) { const item = await loadQuizPollDocByPresentationToken(request.params.token); @@ -174,6 +207,7 @@ async function getSessionActivities(request, h) { module.exports = { getPresentation, getQuestionStats, + getLeaderboard, publishPresentation, closePresentation, setPresentationLiveQuestion, diff --git a/src/controllers/quizPoll.controller.js b/src/controllers/quizPoll.controller.js index 5609f74..56f07c4 100644 --- a/src/controllers/quizPoll.controller.js +++ b/src/controllers/quizPoll.controller.js @@ -11,7 +11,18 @@ const { } = require('../utils/quizPollLiveControls'); function presentationPublicBase() { - return (process.env.EVENTBUZZ_PUBLIC_URL || 'http://localhost:5174').replace(/\/$/, ''); + const raw = (process.env.EVENTBUZZ_PUBLIC_URL || 'http://localhost:5174').replace(/\/$/, ''); + try { + const u = new URL(raw); + // Marketing apex is not the participant SPA — rewrite to join host. + if (u.hostname === 'eventbuzz.live' || u.hostname === 'www.eventbuzz.live') { + u.hostname = 'join.eventbuzz.live'; + return u.origin; + } + } catch { + /* keep raw */ + } + return raw; } async function assertQuizPollAccess(request, h, item) { @@ -136,6 +147,29 @@ const setShowCorrect = async (request, h) => { return h.response(success(item, item.settings.showCorrectAnswer ? 'Correct answer shown' : 'Correct answer hidden')); }; +const setShowLeaderboard = async (request, h) => { + const item = await QuizPoll.findOne({ _id: request.params.id, deletedAt: null }); + if (!item) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404); + const denied = await assertQuizPollAccess(request, h, item); + if (denied) return denied; + if (displayModeOf(item) !== 'quiz_master') { + return h + .response(error('VALIDATION_ERROR', 'Show leaderboard is only available in Quiz Master mode')) + .code(400); + } + if (String(item.type || '').toLowerCase() !== 'quiz') { + return h.response(error('VALIDATION_ERROR', 'Leaderboard is only available for quizzes')).code(400); + } + + item.settings = item.settings || {}; + item.settings.showLeaderboard = Boolean(request.payload?.show); + await item.save(); + await syncQuizPollToFirebase(item); + return h.response( + success(item, item.settings.showLeaderboard ? 'Leaderboard shown' : 'Leaderboard hidden') + ); +}; + const ensurePresentationToken = async (request, h) => { const item = await QuizPoll.findOne({ _id: request.params.id, deletedAt: null }); if (!item) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404); @@ -197,6 +231,6 @@ const sessionQuizPolls = async (request, h) => { }; module.exports = { - list, create, get, update, remove, publish, close, setLiveQuestion, setShowCorrect, ensurePresentationToken, results, - submitResponse, listResponses, sessionQuizPolls, + list, create, get, update, remove, publish, close, setLiveQuestion, setShowCorrect, setShowLeaderboard, + ensurePresentationToken, results, submitResponse, listResponses, sessionQuizPolls, }; diff --git a/src/controllers/session.controller.js b/src/controllers/session.controller.js index bae91fb..2ba27aa 100644 --- a/src/controllers/session.controller.js +++ b/src/controllers/session.controller.js @@ -14,6 +14,7 @@ const { KEY_PERSON_ROLES, DEFAULT_KEY_PERSON_ROLE, } = require('../config/constants'); +const { setSessionPresentFocus } = require('../utils/sessionPresentationFirebaseSync'); const KEY_PERSON_ROLE_SET = new Set(KEY_PERSON_ROLES); @@ -212,4 +213,18 @@ const updateStatus = async (request, h) => { return h.response(success(session)); }; -module.exports = { list, create, get, update, remove, updateStatus }; +/** POST /api/sessions/{id}/present-questions — projector switches to Questions for this session. */ +const presentQuestions = async (request, h) => { + const session = await Session.findById(request.params.id); + if (!session) return h.response(error('NOT_FOUND', 'Session not found')).code(404); + const user = request.auth.credentials.user; + const orgId = userOrgId(user); + await assertModulePermission(user, orgId, 'sessions', 'write'); + if (!(await canAccessSession(user, orgId, session._id, 'write'))) { + return h.response(error('FORBIDDEN', 'Access denied')).code(403); + } + await setSessionPresentFocus(session._id, 'questions'); + return h.response(success({ focus: 'questions' }, 'Present screen switched to Questions')); +}; + +module.exports = { list, create, get, update, remove, updateStatus, presentQuestions }; diff --git a/src/models/PhotoWall.js b/src/models/PhotoWall.js new file mode 100644 index 0000000..d19b7f4 --- /dev/null +++ b/src/models/PhotoWall.js @@ -0,0 +1,88 @@ +const mongoose = require('mongoose'); +const crypto = require('crypto'); + +const photoWallSchema = new mongoose.Schema( + { + event: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + required: true, + }, + organization: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Organization', + required: true, + }, + enabled: { type: Boolean, default: false }, + /** open = anyone with link; login = requires visitor JWT */ + accessMode: { + type: String, + enum: ['open', 'login'], + default: 'open', + }, + collectDetails: { type: Boolean, default: false }, + formId: { type: mongoose.Schema.Types.ObjectId, ref: 'Form', default: null }, + /** When true, form submit upserts a Participant + event membership */ + createParticipant: { type: Boolean, default: false }, + /** auto = show on wall immediately; approval = pending until admin approves */ + moderationMode: { + type: String, + enum: ['auto', 'approval'], + default: 'auto', + }, + photosPerRow: { type: Number, default: 4, min: 1, max: 12 }, + maxPhotoWidth: { type: Number, default: 320, min: 80, max: 1200 }, + maxPhotoHeight: { type: Number, default: 320, min: 80, max: 1200 }, + gapPx: { type: Number, default: 8, min: 0, max: 64 }, + /** Show uploader name under photos on the presenter wall */ + showUploaderNameOnWall: { type: Boolean, default: false }, + /** Require crop on upload when image aspect ratio ≠ maxWidth/maxHeight */ + requireUploadCrop: { type: Boolean, default: false }, + /** Digital photo frame: composite selfie + text onto a template image */ + frameEnabled: { type: Boolean, default: false }, + frameImageUrl: { type: String, trim: true, default: null }, + frameImageLayout: { + x: { type: Number, default: 0 }, + y: { type: Number, default: 0 }, + w: { type: Number, default: 100 }, + h: { type: Number, default: 100 }, + }, + frameCanvasWidth: { type: Number, default: 1080, min: 200, max: 4096 }, + frameCanvasHeight: { type: Number, default: 1080, min: 200, max: 4096 }, + frameLayout: { type: [mongoose.Schema.Types.Mixed], default: [] }, + /** After upload: allow visitor to download the framed (or raw) image */ + allowFramedDownload: { type: Boolean, default: true }, + /** Social share buttons shown on the visitor success screen */ + sharePlatforms: { + linkedin: { type: Boolean, default: false }, + facebook: { type: Boolean, default: false }, + instagram: { type: Boolean, default: false }, + snapchat: { type: Boolean, default: false }, + x: { type: Boolean, default: false }, + whatsapp: { type: Boolean, default: false }, + }, + /** Visitor upload page event header visibility (same shape as Form.settings.eventHeader). */ + eventHeader: { + showLogo: { type: Boolean, default: true }, + showBanner: { type: Boolean, default: false }, + showTitle: { type: Boolean, default: true }, + showDateRange: { type: Boolean, default: true }, + showEventType: { type: Boolean, default: true }, + showLocationType: { type: Boolean, default: true }, + showVenue: { type: Boolean, default: true }, + }, + uploadToken: { type: String, trim: true }, + presentToken: { type: String, trim: true }, + }, + { timestamps: true }, +); + +photoWallSchema.index({ event: 1 }, { unique: true }); +photoWallSchema.index({ uploadToken: 1 }, { unique: true, sparse: true }); +photoWallSchema.index({ presentToken: 1 }, { unique: true, sparse: true }); + +photoWallSchema.statics.generateToken = function generateToken() { + return crypto.randomBytes(32).toString('hex'); +}; + +module.exports = mongoose.model('PhotoWall', photoWallSchema); diff --git a/src/models/PhotoWallPhoto.js b/src/models/PhotoWallPhoto.js new file mode 100644 index 0000000..8e5d23c --- /dev/null +++ b/src/models/PhotoWallPhoto.js @@ -0,0 +1,47 @@ +const mongoose = require('mongoose'); + +const photoWallPhotoSchema = new mongoose.Schema( + { + photoWall: { + type: mongoose.Schema.Types.ObjectId, + ref: 'PhotoWall', + required: true, + }, + event: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Event', + required: true, + }, + organization: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Organization', + required: true, + }, + /** Raw uploaded selfie */ + url: { type: String, required: true }, + s3Key: { type: String, required: true }, + /** Server-composited framed image (when digital frame is enabled) */ + framedUrl: { type: String, default: null }, + framedS3Key: { type: String, default: null }, + mimeType: { type: String }, + size: { type: Number }, + originalFileName: { type: String }, + /** Denormalized caption for presenter wall */ + displayName: { type: String, trim: true, default: null }, + status: { + type: String, + enum: ['pending', 'approved', 'removed'], + default: 'pending', + }, + formData: { type: Map, of: mongoose.Schema.Types.Mixed }, + formSubmission: { type: mongoose.Schema.Types.ObjectId, ref: 'FormSubmission' }, + participant: { type: mongoose.Schema.Types.ObjectId, ref: 'Participant' }, + uploadedByParticipant: { type: mongoose.Schema.Types.ObjectId, ref: 'Participant' }, + }, + { timestamps: true }, +); + +photoWallPhotoSchema.index({ photoWall: 1, status: 1, createdAt: -1 }); +photoWallPhotoSchema.index({ event: 1, createdAt: -1 }); + +module.exports = mongoose.model('PhotoWallPhoto', photoWallPhotoSchema); diff --git a/src/models/QuizPoll.js b/src/models/QuizPoll.js index 786d3c9..aecea7a 100644 --- a/src/models/QuizPoll.js +++ b/src/models/QuizPoll.js @@ -56,6 +56,8 @@ const quizPollSchema = new mongoose.Schema( liveQuestionId: { type: String, default: null }, /** quiz_master: projector page highlights correct answer(s) for the live question */ showCorrectAnswer: { type: Boolean, default: false }, + /** quiz_master: projector page switches to the Leaderboard tab */ + showLeaderboard: { type: Boolean, default: false }, /** Unguessable token for login-free presentation / projector page */ presentationToken: { type: String, trim: true }, rewardRules: rewardRulesSubSchema, diff --git a/src/routes/photoWall.routes.js b/src/routes/photoWall.routes.js new file mode 100644 index 0000000..b993b29 --- /dev/null +++ b/src/routes/photoWall.routes.js @@ -0,0 +1,140 @@ +const Joi = require('joi'); +const controller = require('../controllers/photoWall.controller'); + +const eventHeaderSchema = Joi.object({ + showLogo: Joi.boolean(), + showBanner: Joi.boolean(), + showTitle: Joi.boolean(), + showDateRange: Joi.boolean(), + showEventType: Joi.boolean(), + showLocationType: Joi.boolean(), + showVenue: Joi.boolean(), +}); + +const frameImageLayoutSchema = Joi.object({ + x: Joi.number().min(-400).max(400), + y: Joi.number().min(-400).max(400), + w: Joi.number().min(10).max(500), + h: Joi.number().min(10).max(500), +}); + +const frameLayoutElementSchema = Joi.object({ + id: Joi.string().required(), + kind: Joi.string().valid('field', 'custom_text', 'custom_image').required(), + field: Joi.string().valid('photo', 'name', 'form').optional(), + fieldKey: Joi.string().allow('').max(120).optional(), + text: Joi.string().allow('').max(500).optional(), + imageUrl: Joi.string().allow('').max(2000).optional(), + imageAspectRatio: Joi.number().positive().optional(), + sizeUnit: Joi.string().valid('px', 'percent').optional(), + x: Joi.number().min(0).max(4096).required(), + y: Joi.number().min(0).max(4096).required(), + w: Joi.number().min(1).max(4096).optional(), + h: Joi.number().min(1).max(4096).optional(), + fontSizePx: Joi.number().min(8).max(400).optional(), + fontSizePct: Joi.number().min(1).max(40).optional(), + photoShape: Joi.string().valid('rect', 'rounded', 'circle').optional(), + borderRadiusPx: Joi.number().min(0).max(512).optional(), + fontFamily: Joi.string().allow('').max(80).optional(), + color: Joi.string().allow('').max(40).optional(), + fontWeight: Joi.string().valid('normal', 'bold').optional(), + textAlign: Joi.string().valid('left', 'center', 'right').optional(), + fullWidthCenter: Joi.boolean().optional(), + shrinkToFit: Joi.boolean().optional(), + prefix: Joi.string().allow('').max(80).optional(), + postfix: Joi.string().allow('').max(80).optional(), +}).unknown(true); + +const updatePayload = Joi.object({ + enabled: Joi.boolean(), + accessMode: Joi.string().valid('open', 'login'), + collectDetails: Joi.boolean(), + formId: Joi.alternatives().try(Joi.string().hex().length(24), Joi.valid(null, '')), + createParticipant: Joi.boolean(), + moderationMode: Joi.string().valid('auto', 'approval'), + photosPerRow: Joi.number().integer().min(1).max(12), + maxPhotoWidth: Joi.number().integer().min(80).max(1200), + maxPhotoHeight: Joi.number().integer().min(80).max(1200), + gapPx: Joi.number().integer().min(0).max(64), + showUploaderNameOnWall: Joi.boolean(), + requireUploadCrop: Joi.boolean(), + frameEnabled: Joi.boolean(), + frameImageUrl: Joi.alternatives().try(Joi.string().allow('').max(2000), Joi.valid(null)), + frameImageLayout: frameImageLayoutSchema, + frameCanvasWidth: Joi.number().integer().min(200).max(4096), + frameCanvasHeight: Joi.number().integer().min(200).max(4096), + frameLayout: Joi.array().items(frameLayoutElementSchema).max(40), + allowFramedDownload: Joi.boolean(), + sharePlatforms: Joi.object({ + linkedin: Joi.boolean(), + facebook: Joi.boolean(), + instagram: Joi.boolean(), + snapchat: Joi.boolean(), + x: Joi.boolean(), + whatsapp: Joi.boolean(), + }), + eventHeader: eventHeaderSchema, + regenerateTokens: Joi.boolean(), +}).min(1); + +const patchPhotoPayload = Joi.object({ + status: Joi.string().valid('pending', 'approved', 'removed').required(), +}); + +module.exports = [ + { + method: 'GET', + path: '/api/events/{eventId}/photo-wall', + options: { + auth: 'jwt', + handler: controller.getPhotoWall, + }, + }, + { + method: 'PATCH', + path: '/api/events/{eventId}/photo-wall', + options: { + auth: 'jwt', + validate: { payload: updatePayload }, + handler: controller.updatePhotoWall, + }, + }, + { + method: 'POST', + path: '/api/events/{eventId}/photo-wall/ensure-tokens', + options: { + auth: 'jwt', + handler: controller.ensureTokensHandler, + }, + }, + { + method: 'GET', + path: '/api/events/{eventId}/photo-wall/photos', + options: { + auth: 'jwt', + validate: { + query: Joi.object({ + status: Joi.string().valid('pending', 'approved', 'removed').optional(), + }), + }, + handler: controller.listPhotos, + }, + }, + { + method: 'PATCH', + path: '/api/events/{eventId}/photo-wall/photos/{photoId}', + options: { + auth: 'jwt', + validate: { payload: patchPhotoPayload }, + handler: controller.patchPhoto, + }, + }, + { + method: 'DELETE', + path: '/api/events/{eventId}/photo-wall/photos/{photoId}', + options: { + auth: 'jwt', + handler: controller.deletePhoto, + }, + }, +]; diff --git a/src/routes/publicDemoRequest.routes.js b/src/routes/publicDemoRequest.routes.js new file mode 100644 index 0000000..3159ea9 --- /dev/null +++ b/src/routes/publicDemoRequest.routes.js @@ -0,0 +1,28 @@ +const Joi = require('joi'); +const controller = require('../controllers/publicDemoRequest.controller'); + +const demoRequestPayload = Joi.object({ + name: Joi.string().trim().min(1).max(120).required(), + mobile: Joi.string().trim().min(7).max(40).required(), + email: Joi.string().trim().email({ tlds: { allow: false } }).max(200).required(), + message: Joi.string().trim().min(1).max(4000).required(), + captchaToken: Joi.string().required(), + captchaAnswer: Joi.alternatives().try(Joi.string(), Joi.number()).required(), +}); + +module.exports = [ + { + method: 'POST', + path: '/api/public/demo-request', + options: { + auth: false, + validate: { + payload: demoRequestPayload, + failAction: (request, h, err) => { + throw err; + }, + }, + handler: controller.submit, + }, + }, +]; diff --git a/src/routes/publicPhotoWall.routes.js b/src/routes/publicPhotoWall.routes.js new file mode 100644 index 0000000..ac5246a --- /dev/null +++ b/src/routes/publicPhotoWall.routes.js @@ -0,0 +1,43 @@ +const controller = require('../controllers/publicPhotoWall.controller'); + +module.exports = [ + { + method: 'GET', + path: '/api/public/photo-wall/upload/{uploadToken}', + options: { + auth: false, + handler: controller.getUploadMeta, + }, + }, + { + method: 'POST', + path: '/api/public/photo-wall/upload/{uploadToken}', + options: { + auth: { mode: 'optional', strategy: 'jwt-attendee' }, + payload: { + maxBytes: 20 * 1024 * 1024, + output: 'stream', + parse: true, + multipart: true, + allow: 'multipart/form-data', + }, + handler: controller.uploadPhoto, + }, + }, + { + method: 'GET', + path: '/api/public/photo-wall/upload/{uploadToken}/photos/{photoId}/file', + options: { + auth: false, + handler: controller.downloadPhotoFile, + }, + }, + { + method: 'GET', + path: '/api/public/photo-wall/present/{presentToken}', + options: { + auth: false, + handler: controller.getPresentWall, + }, + }, +]; diff --git a/src/routes/publicQuizPoll.routes.js b/src/routes/publicQuizPoll.routes.js index 998d6c5..f6013aa 100644 --- a/src/routes/publicQuizPoll.routes.js +++ b/src/routes/publicQuizPoll.routes.js @@ -16,6 +16,11 @@ module.exports = [ path: '/api/public/quiz-polls/present/{token}/questions/{questionIndex}/stats', options: { auth: false, handler: controller.getQuestionStats }, }, + { + method: 'GET', + path: '/api/public/quiz-polls/present/{token}/leaderboard', + options: { auth: false, handler: controller.getLeaderboard }, + }, { method: 'GET', path: '/api/public/quiz-polls/present/{token}/session-activities', diff --git a/src/routes/quizPoll.routes.js b/src/routes/quizPoll.routes.js index 3a65ef0..783bd97 100644 --- a/src/routes/quizPoll.routes.js +++ b/src/routes/quizPoll.routes.js @@ -48,6 +48,7 @@ const quizPollPayload = Joi.object({ timeLimit: Joi.number().integer().min(0), liveQuestionId: Joi.string().allow('', null), showCorrectAnswer: Joi.boolean(), + showLeaderboard: Joi.boolean(), presentationToken: Joi.string().allow('', null), rewardRules: rewardRulesPayload, }), @@ -73,6 +74,10 @@ const showCorrectPayload = Joi.object({ show: Joi.boolean().required(), }); +const showLeaderboardPayload = Joi.object({ + show: Joi.boolean().required(), +}); + module.exports = [ { method: 'GET', path: '/api/events/{eventId}/quiz-polls', options: { handler: controller.list } }, { method: 'POST', path: '/api/events/{eventId}/quiz-polls', options: { validate: { payload: quizPollPayload }, handler: controller.create } }, @@ -83,6 +88,7 @@ module.exports = [ { method: 'POST', path: '/api/quiz-polls/{id}/close', options: { handler: controller.close } }, { method: 'POST', path: '/api/quiz-polls/{id}/live-question', options: { validate: { payload: liveQuestionPayload }, handler: controller.setLiveQuestion } }, { method: 'POST', path: '/api/quiz-polls/{id}/show-correct', options: { validate: { payload: showCorrectPayload }, handler: controller.setShowCorrect } }, + { method: 'POST', path: '/api/quiz-polls/{id}/show-leaderboard', options: { validate: { payload: showLeaderboardPayload }, handler: controller.setShowLeaderboard } }, { method: 'POST', path: '/api/quiz-polls/{id}/presentation-token', options: { handler: controller.ensurePresentationToken } }, { method: 'GET', path: '/api/quiz-polls/{id}/results', options: { handler: controller.results } }, { method: 'POST', path: '/api/quiz-polls/{quizPollId}/responses', options: { validate: { payload: responsePayload }, handler: controller.submitResponse } }, diff --git a/src/routes/session.routes.js b/src/routes/session.routes.js index 743fc2a..4ea854c 100644 --- a/src/routes/session.routes.js +++ b/src/routes/session.routes.js @@ -69,4 +69,9 @@ module.exports = [ handler: controller.updateStatus, }, }, + { + method: 'POST', + path: '/api/sessions/{id}/present-questions', + options: { handler: controller.presentQuestions }, + }, ]; diff --git a/src/utils/mail.js b/src/utils/mail.js index 5c3dbb7..647df89 100644 --- a/src/utils/mail.js +++ b/src/utils/mail.js @@ -381,6 +381,60 @@ ${message || '(no message)'} } } +async function sendWebsiteDemoRequestEmail({ + to, + name, + mobile, + email, + message, +}) { + const from = getMailFrom(); + const toStr = Array.isArray(to) ? to.filter(Boolean).join(', ') : String(to || '').trim(); + const subject = `[EventBuzz] Demo request — ${name || email || 'Website'}`; + const text = `New demo request from the EventBuzz marketing website. + +Name: ${name || '—'} +Mobile: ${mobile || '—'} +Email: ${email || '—'} + +Message: +${message || '(no message)'} + +— eventbuzz.live Request a demo`; + + const html = `
New demo request from the EventBuzz marketing website.
+Name: ${escapeHtml(name || '—')}
+Mobile: ${escapeHtml(mobile || '—')}
+Email: ${escapeHtml(email || '—')}
${escapeHtml(message || '(no message)')}
+— eventbuzz.live Request a demo
`; + + if (!toStr) { + console.log('[Website demo request — no recipients]\n', text); + return false; + } + + const t = getDefaultTransporter(); + if (!t) { + console.log(`[Website demo request — no MAIL_HOST/SMTP_HOST] to=${toStr}\n${text}`); + return true; + } + try { + await t.sendMail({ + from, + to: toStr, + replyTo: email || undefined, + subject, + text, + html, + }); + return true; + } catch (e) { + console.error('sendWebsiteDemoRequestEmail', e.message); + return false; + } +} + async function sendContactSalesLeadEmail({ to, requesterEmail, @@ -444,5 +498,6 @@ module.exports = { sendVisitorOtpEmail, sendVisitorMagicLinkEmail, sendMicrositeContactEmail, + sendWebsiteDemoRequestEmail, sendContactSalesLeadEmail, }; diff --git a/src/utils/photoWallFrameLayout.js b/src/utils/photoWallFrameLayout.js new file mode 100644 index 0000000..b1ff909 --- /dev/null +++ b/src/utils/photoWallFrameLayout.js @@ -0,0 +1,134 @@ +/** Shared normalize/clamp helpers for photo-wall digital frame layout (pixel-based). */ + +function clamp(n, min, max) { + const v = Number(n); + if (!Number.isFinite(v)) return min; + return Math.min(max, Math.max(min, v)); +} + +function normalizeFrameImageLayout(raw) { + const o = raw && typeof raw === 'object' ? raw : {}; + return { + x: clamp(o.x ?? 0, -400, 400), + y: clamp(o.y ?? 0, -400, 400), + w: clamp(o.w ?? 100, 10, 500), + h: clamp(o.h ?? 100, 10, 500), + }; +} + +function looksLikePercentLayout(raw) { + if (!raw || typeof raw !== 'object') return false; + if (raw.sizeUnit === 'px') return false; + if (raw.fontSizePx != null && raw.fontSizePct == null) return false; + const nums = [raw.x, raw.y, raw.w, raw.h].map(Number).filter((n) => Number.isFinite(n)); + if (!nums.length) return false; + if (nums.some((n) => n > 100)) return false; + // Old layouts used %; prefer migrate when fontSizePct present or sizeUnit missing + return raw.fontSizePct != null || raw.sizeUnit !== 'px'; +} + +function migratePercentToPx(raw, canvasW = 1080, canvasH = 1080) { + const out = { ...raw }; + out.x = Math.round((Number(raw.x || 0) / 100) * canvasW); + out.y = Math.round((Number(raw.y || 0) / 100) * canvasH); + if (raw.w != null) out.w = Math.round((Number(raw.w) / 100) * canvasW); + if (raw.h != null) out.h = Math.round((Number(raw.h) / 100) * canvasH); + if (raw.fontSizePct != null) { + out.fontSizePx = Math.round((Number(raw.fontSizePct) / 100) * canvasH); + } + out.sizeUnit = 'px'; + return out; +} + +function normalizeFrameLayoutElement(raw, canvasW = 1080, canvasH = 1080) { + if (!raw || typeof raw !== 'object') return null; + let src = raw; + if (looksLikePercentLayout(raw)) { + src = migratePercentToPx(raw, canvasW, canvasH); + } + + const kind = String(src.kind || 'field'); + if (!['field', 'custom_text', 'custom_image'].includes(kind)) return null; + + const id = String(src.id || '').trim() || `el_${Date.now()}`; + const el = { + id, + kind, + sizeUnit: 'px', + x: clamp(src.x ?? 40, 0, canvasW), + y: clamp(src.y ?? 40, 0, canvasH), + }; + + if (src.w != null) el.w = clamp(src.w, 8, canvasW); + if (src.h != null) el.h = clamp(src.h, 8, canvasH); + + if (kind === 'field') { + const field = String(src.field || '').trim(); + if (!['photo', 'name', 'form'].includes(field)) return null; + el.field = field; + if (field === 'form') { + const key = String(src.fieldKey || '').trim(); + if (!key) return null; + el.fieldKey = key.slice(0, 120); + } + if (field === 'photo' || kind === 'custom_image') { + const shape = String(src.photoShape || 'rect'); + el.photoShape = ['rect', 'rounded', 'circle'].includes(shape) ? shape : 'rect'; + if (el.photoShape === 'rounded') { + el.borderRadiusPx = clamp(src.borderRadiusPx ?? 24, 0, 512); + } + } + } + if (kind === 'custom_text') { + el.text = String(src.text || '').slice(0, 500); + } + if (kind === 'custom_image') { + el.imageUrl = String(src.imageUrl || '').trim().slice(0, 2000); + if (src.imageAspectRatio != null && Number.isFinite(Number(src.imageAspectRatio))) { + el.imageAspectRatio = Number(src.imageAspectRatio); + } + const shape = String(src.photoShape || 'rect'); + el.photoShape = ['rect', 'rounded', 'circle'].includes(shape) ? shape : 'rect'; + if (el.photoShape === 'rounded') { + el.borderRadiusPx = clamp(src.borderRadiusPx ?? 24, 0, 512); + } + } + + if (src.fontSizePx != null) el.fontSizePx = clamp(src.fontSizePx, 8, 400); + else if (src.fontSizePct != null) el.fontSizePx = clamp((Number(src.fontSizePct) / 100) * canvasH, 8, 400); + + if (src.fontFamily) el.fontFamily = String(src.fontFamily).slice(0, 80); + if (src.color) el.color = String(src.color).slice(0, 40); + if (src.fontWeight === 'bold' || src.fontWeight === 'normal') el.fontWeight = src.fontWeight; + if (src.textAlign === 'left' || src.textAlign === 'center' || src.textAlign === 'right') { + el.textAlign = src.textAlign; + } + if (typeof src.fullWidthCenter === 'boolean') el.fullWidthCenter = src.fullWidthCenter; + if (typeof src.shrinkToFit === 'boolean') el.shrinkToFit = src.shrinkToFit; + if (src.prefix != null) el.prefix = String(src.prefix).slice(0, 80); + if (src.postfix != null) el.postfix = String(src.postfix).slice(0, 80); + + return el; +} + +function normalizeFrameLayout(raw, canvasW = 1080, canvasH = 1080) { + if (!Array.isArray(raw)) return []; + const out = []; + for (const item of raw.slice(0, 40)) { + const el = normalizeFrameLayoutElement(item, canvasW, canvasH); + if (el) out.push(el); + } + return out; +} + +function defaultFrameImageLayout() { + return { x: 0, y: 0, w: 100, h: 100 }; +} + +module.exports = { + normalizeFrameImageLayout, + normalizeFrameLayout, + normalizeFrameLayoutElement, + defaultFrameImageLayout, + clamp, +}; diff --git a/src/utils/photoWallFrameRender.js b/src/utils/photoWallFrameRender.js new file mode 100644 index 0000000..a59ff72 --- /dev/null +++ b/src/utils/photoWallFrameRender.js @@ -0,0 +1,254 @@ +const https = require('https'); +const http = require('http'); +const { createCanvas, loadImage } = require('@napi-rs/canvas'); +const sharp = require('sharp'); +const { + uploadPhotoWallPhotoToS3, + getEventPhotoBufferFromS3, +} = require('./s3EventPhotos'); +const { normalizeFrameImageLayout, normalizeFrameLayout } = require('./photoWallFrameLayout'); + +function fetchUrlBuffer(url) { + return new Promise((resolve, reject) => { + const lib = String(url).startsWith('https') ? https : http; + const req = lib.get(url, { timeout: 20000 }, (res) => { + if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + fetchUrlBuffer(res.headers.location).then(resolve, reject); + return; + } + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode} fetching image`)); + res.resume(); + return; + } + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => resolve(Buffer.concat(chunks))); + }); + req.on('error', reject); + req.on('timeout', () => { + req.destroy(); + reject(new Error('Image fetch timeout')); + }); + }); +} + +async function loadImageFromUrlOrKey(url, s3Key) { + let buf = null; + if (s3Key) { + try { + buf = await getEventPhotoBufferFromS3(s3Key); + } catch { + buf = null; + } + } + if (!buf && url) { + buf = await fetchUrlBuffer(String(url).trim()); + } + if (!buf?.length) throw new Error('Could not load image'); + try { + return await loadImage(buf); + } catch { + const png = await sharp(buf).png().toBuffer(); + return loadImage(png); + } +} + +function clipPhotoPath(ctx, boxX, boxY, boxW, boxH, shape, borderRadiusPx) { + ctx.beginPath(); + if (shape === 'circle') { + const r = Math.min(boxW, boxH) / 2; + const cx = boxX + boxW / 2; + const cy = boxY + boxH / 2; + ctx.arc(cx, cy, r, 0, Math.PI * 2); + } else if (shape === 'rounded') { + const r = Math.min(borderRadiusPx || 24, boxW / 2, boxH / 2); + const x = boxX; + const y = boxY; + const w = boxW; + const h = boxH; + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); + } else { + ctx.rect(boxX, boxY, boxW, boxH); + } +} + +function coverDraw(ctx, img, boxX, boxY, boxW, boxH, shape = 'rect', borderRadiusPx = 24) { + const scale = Math.max(boxW / img.width, boxH / img.height); + const dw = img.width * scale; + const dh = img.height * scale; + const dx = boxX + (boxW - dw) / 2; + const dy = boxY + (boxH - dh) / 2; + ctx.save(); + clipPhotoPath(ctx, boxX, boxY, boxW, boxH, shape, borderRadiusPx); + ctx.clip(); + ctx.drawImage(img, dx, dy, dw, dh); + ctx.restore(); +} + +function formDataPlain(formData) { + if (!formData) return {}; + if (formData instanceof Map) return Object.fromEntries(formData); + if (typeof formData === 'object') return formData; + return {}; +} + +function resolveText(el, photo) { + const prefix = el.prefix != null ? String(el.prefix) : ''; + const postfix = el.postfix != null ? String(el.postfix) : ''; + let base = ''; + if (el.kind === 'custom_text') { + base = String(el.text || '').trim(); + } else if (el.field === 'name') { + base = photo.displayName ? String(photo.displayName).trim() : ''; + } else if (el.field === 'form' && el.fieldKey) { + const data = formDataPlain(photo.formData); + const v = data[el.fieldKey]; + base = v == null ? '' : String(v).trim(); + } + if (!base) return ''; + return `${prefix}${base}${postfix}`; +} + +function drawText(ctx, el, text, canvasW) { + if (!text) return; + const boxX = Number(el.x) || 0; + const boxY = Number(el.y) || 0; + const boxW = el.w != null ? Number(el.w) : Math.max(40, canvasW - boxX); + let fontSize = el.fontSizePx != null ? Number(el.fontSizePx) : 36; + fontSize = Math.max(8, fontSize); + const family = el.fontFamily || 'sans-serif'; + const weight = el.fontWeight === 'bold' ? 'bold' : 'normal'; + const color = el.color || '#ffffff'; + const align = el.textAlign || 'center'; + + ctx.fillStyle = color; + ctx.textBaseline = 'top'; + ctx.textAlign = align === 'left' ? 'left' : align === 'right' ? 'right' : 'center'; + + const shrink = el.shrinkToFit !== false; + ctx.font = `${weight} ${fontSize}px ${family}`; + if (shrink) { + while (fontSize > 8 && ctx.measureText(text).width > boxW * 0.98) { + fontSize -= 1; + ctx.font = `${weight} ${fontSize}px ${family}`; + } + } + + let tx = boxX + boxW / 2; + if (align === 'left') tx = boxX; + if (align === 'right') tx = boxX + boxW; + ctx.fillText(text, tx, boxY, boxW); +} + +async function drawFrameBackground(ctx, wall, canvasW, canvasH) { + const url = String(wall.frameImageUrl || '').trim(); + if (!url) { + ctx.fillStyle = '#1a1d24'; + ctx.fillRect(0, 0, canvasW, canvasH); + return; + } + try { + const img = await loadImageFromUrlOrKey(url, null); + const layout = normalizeFrameImageLayout(wall.frameImageLayout); + const boxX = (layout.x / 100) * canvasW; + const boxY = (layout.y / 100) * canvasH; + const boxW = (layout.w / 100) * canvasW; + const boxH = (layout.h / 100) * canvasH; + coverDraw(ctx, img, boxX, boxY, boxW, boxH, 'rect'); + } catch (err) { + console.error('[photoWallFrame] background failed', err?.message); + ctx.fillStyle = '#1a1d24'; + ctx.fillRect(0, 0, canvasW, canvasH); + } +} + +/** + * Composite framed image for a photo wall entry. + * @returns {{ framedUrl: string, framedS3Key: string }} + */ +async function renderAndUploadFramedPhoto({ wall, photo }) { + if (!wall?.frameEnabled) { + throw new Error('Frame not enabled'); + } + const canvasW = Math.min(4096, Math.max(200, Math.round(Number(wall.frameCanvasWidth) || 1080))); + const canvasH = Math.min(4096, Math.max(200, Math.round(Number(wall.frameCanvasHeight) || 1080))); + const canvas = createCanvas(canvasW, canvasH); + const ctx = canvas.getContext('2d'); + + await drawFrameBackground(ctx, wall, canvasW, canvasH); + + const layout = normalizeFrameLayout(wall.frameLayout, canvasW, canvasH); + let selfieImg = null; + try { + selfieImg = await loadImageFromUrlOrKey(photo.url, photo.s3Key); + } catch (err) { + console.error('[photoWallFrame] selfie load failed', err?.message); + } + + for (const el of layout) { + try { + if (el.kind === 'field' && el.field === 'photo') { + if (!selfieImg) continue; + const boxX = Number(el.x) || 0; + const boxY = Number(el.y) || 0; + const boxW = el.w != null ? Number(el.w) : Math.round(canvasW * 0.4); + const boxH = el.h != null ? Number(el.h) : Math.round(canvasH * 0.4); + coverDraw( + ctx, + selfieImg, + boxX, + boxY, + boxW, + boxH, + el.photoShape || 'rect', + el.borderRadiusPx ?? 24, + ); + continue; + } + if (el.kind === 'custom_image' && el.imageUrl) { + const img = await loadImageFromUrlOrKey(el.imageUrl, null); + const boxX = Number(el.x) || 0; + const boxY = Number(el.y) || 0; + const boxW = el.w != null ? Number(el.w) : Math.round(canvasW * 0.2); + const boxH = el.h != null ? Number(el.h) : Math.round(canvasH * 0.2); + coverDraw( + ctx, + img, + boxX, + boxY, + boxW, + boxH, + el.photoShape || 'rect', + el.borderRadiusPx ?? 24, + ); + continue; + } + const text = resolveText(el, photo); + if (text) drawText(ctx, el, text, canvasW); + } catch (err) { + console.error('[photoWallFrame] element failed', el.id, err?.message); + } + } + + const pngBuf = canvas.toBuffer('image/png'); + const jpegBuf = await sharp(pngBuf).jpeg({ quality: 90 }).toBuffer(); + const filename = `framed-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.jpg`; + const uploaded = await uploadPhotoWallPhotoToS3({ + buffer: jpegBuf, + contentType: 'image/jpeg', + eventId: String(wall.event), + filename, + framed: true, + }); + return { framedUrl: uploaded.url, framedS3Key: uploaded.s3Key }; +} + +module.exports = { + renderAndUploadFramedPhoto, +}; diff --git a/src/utils/quizPollFirebaseSync.js b/src/utils/quizPollFirebaseSync.js index ce2a751..1b8edc6 100644 --- a/src/utils/quizPollFirebaseSync.js +++ b/src/utils/quizPollFirebaseSync.js @@ -39,6 +39,7 @@ function normalizeSettings(settings) { s.liveQuestionId = null; } s.showCorrectAnswer = s.showCorrectAnswer === true || s.showCorrectAnswer === 'true'; + s.showLeaderboard = s.showLeaderboard === true || s.showLeaderboard === 'true'; return s; } diff --git a/src/utils/quizPollLiveControls.js b/src/utils/quizPollLiveControls.js index 5c868b1..d630fed 100644 --- a/src/utils/quizPollLiveControls.js +++ b/src/utils/quizPollLiveControls.js @@ -1,5 +1,6 @@ const QuizPoll = require('../models/QuizPoll'); const { syncQuizPollToFirebase } = require('./quizPollFirebaseSync'); +const { clearSessionPresentFocus } = require('./sessionPresentationFirebaseSync'); const MANUAL_OR_QUIZ_MASTER = new Set(['manual', 'quiz_master']); @@ -63,6 +64,7 @@ async function applyPublish(item) { item.settings.isLive = true; await item.save(); await syncQuizPollToFirebase(item); + if (item.session) await clearSessionPresentFocus(item.session); await offlineSiblingManualAndQuizMaster(item); return { ok: true, item, message: 'Published' }; } @@ -75,6 +77,7 @@ async function applyClose(item) { item.settings.isLive = false; item.settings.liveQuestionId = null; item.settings.showCorrectAnswer = false; + item.settings.showLeaderboard = false; await item.save(); await syncQuizPollToFirebase(item); return { ok: true, item, message: 'Closed' }; @@ -99,6 +102,7 @@ async function applySetLiveQuestion(item, rawQuestionId) { if (rawQuestionId == null || String(rawQuestionId).trim() === '') { item.settings.liveQuestionId = null; item.settings.showCorrectAnswer = false; + item.settings.showLeaderboard = false; await item.save(); await syncQuizPollToFirebase(item); return { ok: true, item, message: 'Live question cleared' }; @@ -122,6 +126,7 @@ async function applySetLiveQuestion(item, rawQuestionId) { item.settings.isLive = true; await item.save(); await syncQuizPollToFirebase(item); + if (item.session) await clearSessionPresentFocus(item.session); await offlineSiblingManualAndQuizMaster(item); return { ok: true, item, message: 'Question is now live' }; } diff --git a/src/utils/quizPollResultsSummary.js b/src/utils/quizPollResultsSummary.js index 96ab288..aeec918 100644 --- a/src/utils/quizPollResultsSummary.js +++ b/src/utils/quizPollResultsSummary.js @@ -329,9 +329,64 @@ function buildQuestionPresentationStats(quizPoll, allResponses, questionIndex) { return { ...base, kind: 'single', options: opts }; } +/** + * Whole-quiz leaderboard for presentation mode. + * Rank: most correct answers, then fastest total time. + * @param {object} quizPoll lean doc + * @param {object[]} allResponses populated with participant + */ +function buildQuizPresentationLeaderboard(quizPoll, allResponses) { + const questions = quizPoll.questions || []; + const totalQuestions = questions.length; + const rows = []; + + for (const doc of allResponses) { + const items = Array.isArray(doc.responses) ? doc.responses : []; + let correctCount = 0; + let timeFromItems = 0; + for (const item of items) { + if (item && item.isCorrect === true) correctCount += 1; + timeFromItems += Math.max(0, Number(item?.responseTimeSec) || 0); + } + const timeSpentDoc = Math.max(0, Math.floor(Number(doc.timeSpent) || 0)); + const timeTakenSec = timeFromItems > 0 ? Math.floor(timeFromItems) : timeSpentDoc; + + rows.push({ + participantId: doc.participant != null ? String(doc.participant._id || doc.participant) : '', + displayName: participantDisplayName(doc.participant), + correctCount, + totalQuestions, + timeTakenSec, + totalScore: Number(doc.totalScore) || 0, + }); + } + + rows.sort((a, b) => { + if (b.correctCount !== a.correctCount) return b.correctCount - a.correctCount; + const aTime = a.timeTakenSec > 0 ? a.timeTakenSec : Number.POSITIVE_INFINITY; + const bTime = b.timeTakenSec > 0 ? b.timeTakenSec : Number.POSITIVE_INFINITY; + if (aTime !== bTime) return aTime - bTime; + return String(a.displayName).localeCompare(String(b.displayName)); + }); + + return { + totalQuestions, + entries: rows.map((row, idx) => ({ + rank: idx + 1, + participantId: row.participantId, + displayName: row.displayName, + correctCount: row.correctCount, + totalQuestions: row.totalQuestions, + timeTakenSec: row.timeTakenSec, + totalScore: row.totalScore, + })), + }; +} + module.exports = { normAnswerVal, participantDisplayName, buildVisitorPollResultsSummary, buildQuestionPresentationStats, + buildQuizPresentationLeaderboard, }; diff --git a/src/utils/s3EventPhotos.js b/src/utils/s3EventPhotos.js index a21f376..f42637c 100644 --- a/src/utils/s3EventPhotos.js +++ b/src/utils/s3EventPhotos.js @@ -13,18 +13,53 @@ function sanitizeKeySegment(segment) { /** * Build S3 key for event photos under eventbuzz-pictures bucket. - * Pattern: {eventId}/{filename} + * Pattern: {eventId}/{filename} or {eventId}/{subdir}/{filename} */ -function buildEventPhotoKey({ eventId, filename }) { +function buildEventPhotoKey({ eventId, filename, subdir }) { const safeEventId = sanitizeKeySegment(eventId || 'unknown-event'); const safeFilename = sanitizeKeySegment(filename || 'file'); + const safeSubdir = subdir ? sanitizeKeySegment(subdir) : ''; + if (safeSubdir) return `${safeEventId}/${safeSubdir}/${safeFilename}`; return `${safeEventId}/${safeFilename}`; } +/** + * Photo wall keys (pictures bucket): + * - original: photo_wall/{eventId}/{filename} + * - framed: photo_wall/{eventId}/frame/{filename} + */ +function buildPhotoWallKey({ eventId, filename, framed = false }) { + const safeEventId = sanitizeKeySegment(eventId || 'unknown-event'); + const safeFilename = sanitizeKeySegment(filename || 'file'); + if (framed) return `photo_wall/${safeEventId}/frame/${safeFilename}`; + return `photo_wall/${safeEventId}/${safeFilename}`; +} + +function picturesCloudfrontBase() { + // Pictures CDN (eventbuzz-pictures). Do not fall back to AWS_URL (rapportapp media CDN). + return getEnv('AWS_URL_PICTURES', 'https://d155t9or8i84n8.cloudfront.net').replace(/\/+$/, ''); +} + +/** + * Absolute CloudFront URL for a pictures-bucket key. + * Accepts a bare key or an already-absolute URL (returned unchanged). + */ function cloudfrontUrlForKey(key) { - const base = getEnv('AWS_URL_PICTURES', '').replace(/\/+$/, ''); - if (!base) return key; - return `${base}/${key}`; + const raw = String(key || '').trim(); + if (!raw) return ''; + if (/^https?:\/\//i.test(raw)) return raw; + const base = picturesCloudfrontBase(); + if (!base) { + console.warn('[s3EventPhotos] AWS_URL_PICTURES is not set — returning S3 key as url'); + return raw; + } + return `${base}/${raw.replace(/^\/+/, '')}`; +} + +/** Normalize stored url/framedUrl that may be a bare key (legacy / misconfigured env). */ +function resolvePhotoPublicUrl(urlOrKey) { + if (urlOrKey == null || String(urlOrKey).trim() === '') return null; + return cloudfrontUrlForKey(urlOrKey); } let cachedS3Client = null; @@ -46,12 +81,34 @@ function getS3Client() { /** * Upload a photo buffer to the eventbuzz-pictures S3 bucket. * Returns { s3Key, url }. + * Optional `subdir` nests the key (e.g. photo-wall → {eventId}/photo-wall/{filename}). */ -async function uploadEventPhotoToS3({ buffer, contentType, eventId, filename }) { +async function uploadEventPhotoToS3({ buffer, contentType, eventId, filename, subdir }) { const bucket = getEnv('AWS_EVENT_PICTURES_BUCKET', 'eventbuzz-pictures'); if (!bucket) throw new Error('AWS_EVENT_PICTURES_BUCKET is not configured'); - const key = buildEventPhotoKey({ eventId, filename }); + const key = buildEventPhotoKey({ eventId, filename, subdir }); + + await getS3Client().send( + new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: buffer, + ContentType: contentType || 'application/octet-stream', + }), + ); + + return { s3Key: key, url: cloudfrontUrlForKey(key) }; +} + +/** + * Upload a photo-wall image (original or framed composite) to the pictures bucket. + */ +async function uploadPhotoWallPhotoToS3({ buffer, contentType, eventId, filename, framed = false }) { + const bucket = getEnv('AWS_EVENT_PICTURES_BUCKET', 'eventbuzz-pictures'); + if (!bucket) throw new Error('AWS_EVENT_PICTURES_BUCKET is not configured'); + + const key = buildPhotoWallKey({ eventId, filename, framed }); await getS3Client().send( new PutObjectCommand({ @@ -104,8 +161,12 @@ async function getEventPhotoBufferFromS3(s3Key) { module.exports = { uploadEventPhotoToS3, + uploadPhotoWallPhotoToS3, deleteEventPhotoFromS3, getEventPhotoBufferFromS3, buildEventPhotoKey, + buildPhotoWallKey, + cloudfrontUrlForKey, + resolvePhotoPublicUrl, }; diff --git a/src/utils/sessionPresentationFirebaseSync.js b/src/utils/sessionPresentationFirebaseSync.js new file mode 100644 index 0000000..c3a083f --- /dev/null +++ b/src/utils/sessionPresentationFirebaseSync.js @@ -0,0 +1,29 @@ +const { getFirebaseDb } = require('./firebase'); + +/** + * Session-level present-screen focus for projectors. + * Path: sessions/{sessionId}/presentation + * @param {string} sessionId + * @param {'questions' | null} focus + */ +async function setSessionPresentFocus(sessionId, focus) { + const db = getFirebaseDb(); + if (!db) return; + const sid = String(sessionId || '').trim(); + if (!sid) return; + + const nextFocus = focus === 'questions' ? 'questions' : null; + await db.ref(`sessions/${sid}/presentation`).set({ + focus: nextFocus, + updatedAt: new Date().toISOString(), + }); +} + +async function clearSessionPresentFocus(sessionId) { + return setSessionPresentFocus(sessionId, null); +} + +module.exports = { + setSessionPresentFocus, + clearSessionPresentFocus, +};