This commit is contained in:
Ravi Gulhane 2026-08-13 10:37:35 +05:30
parent 49f9357f72
commit d77d6f7cd1
30 changed files with 2291 additions and 90 deletions

View File

@ -51,6 +51,9 @@ AWS_SECRET_ACCESS_KEY=ZpIxby1vbCLGB7CPWZsAltZBEqkhwzgBpFOKDGj8
AWS_DEFAULT_REGION=ap-south-1 AWS_DEFAULT_REGION=ap-south-1
AWS_BUCKET=rapportapp AWS_BUCKET=rapportapp
AWS_URL=https://did94hhhcgq1d.cloudfront.net 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 # 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). # 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. # 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) # Optional DB override (default: database name from MONGODB_URI)
# CONCIERGE_RECOMMENDATION_DB= # 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) # HMAC secret for public form captcha tokens (falls back to JWT_SECRET if unset)
# FORM_CAPTCHA_SECRET= # FORM_CAPTCHA_SECRET=

132
package-lock.json generated
View File

@ -9,7 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@aws-sdk/client-bedrock-runtime": "^3.1030.0", "@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/client-s3": "^3.1012.0",
"@aws-sdk/lib-storage": "^3.1012.0", "@aws-sdk/lib-storage": "^3.1012.0",
"@hapi/hapi": "^21.3.0", "@hapi/hapi": "^21.3.0",
@ -315,13 +315,13 @@
} }
}, },
"node_modules/@aws-sdk/client-rekognition": { "node_modules/@aws-sdk/client-rekognition": {
"version": "3.1101.0", "version": "3.1104.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-rekognition/-/client-rekognition-3.1101.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/client-rekognition/-/client-rekognition-3.1104.0.tgz",
"integrity": "sha512-M+bb4PuyAk4WC5oxn65aFMP5mYcPM9CAVmwu4Fzd3fridCX8ElBVYBE8L4micpihtV6VvN1NQb8PzeT4qm244g==", "integrity": "sha512-/hno+AX0KW16ZPxcgNdWrLUyxoJ0XKqLerb8rUGz9DLKNvdL03VIt620TwF05mpen18x6Aybd8kzV3LXYqSSBA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.977.4", "@aws-sdk/core": "^3.977.6",
"@aws-sdk/credential-provider-node": "^3.972.76", "@aws-sdk/credential-provider-node": "^3.972.78",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.31.1", "@smithy/core": "^3.31.1",
"@smithy/fetch-http-handler": "^5.6.13", "@smithy/fetch-http-handler": "^5.6.13",
@ -400,9 +400,9 @@
} }
}, },
"node_modules/@aws-sdk/core": { "node_modules/@aws-sdk/core": {
"version": "3.977.4", "version": "3.977.6",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.4.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.6.tgz",
"integrity": "sha512-CEkcQlMOQJCvul60U7wdAOACjtdgFWDsfJI+6wUOGdhGNV2lGbuJpi/R50QLpFG3Tp+sQxa/RmzC3X7KHbhuTA==", "integrity": "sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
@ -441,12 +441,12 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-env": { "node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.65", "version": "3.972.67",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.65.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.67.tgz",
"integrity": "sha512-lJT2aRw9wCV8jPHyFJjdZLD4HTydL6/22AnCSOB8e/LqOc55nEJGLHkJQeSxhn8QiqyjFwPKQFtMw0ovjRUY/g==", "integrity": "sha512-rcIpk5kxUqDaaNa6Xk23pQ6ViY7jlqzmfFWCahQcBT97ddXaXYYwzCen9Tz1Jvo6aJft6wDl5bN44/Jw5B4oLA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.977.4", "@aws-sdk/core": "^3.977.6",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.31.1", "@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1", "@smithy/types": "^4.16.1",
@ -457,12 +457,12 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-http": { "node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.67", "version": "3.972.69",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.67.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.69.tgz",
"integrity": "sha512-N7fw/15hSwI/CPxe5ohOyb7O4ge9f5me1gVIn8OIkBRB0squ8OJqQyDyH/HoL+Sb1W5xdC88jVC+bHkw73iu+Q==", "integrity": "sha512-nggwJtZ4eeNsUw5IeWBMXsi1ryct5idi0K+/SCRF3kybLubOMaNTb3XCihXpWMiVpyzyPeIrl0zTkzhBH9porA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.977.4", "@aws-sdk/core": "^3.977.6",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.31.1", "@smithy/core": "^3.31.1",
"@smithy/fetch-http-handler": "^5.6.13", "@smithy/fetch-http-handler": "^5.6.13",
@ -475,19 +475,19 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-ini": { "node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.973.10", "version": "3.973.12",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.10.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.12.tgz",
"integrity": "sha512-Zh9XRaPnDN9buO7GfWBubS22R6Nq5D6hbyYEMN05LiOnXugm/8WDjUx6y756bSPbdn3aJB2qG4zFW3bN82QhoQ==", "integrity": "sha512-pNEf/OeyN5X3VmLKlgSO6TqaWmW10CvI3TfwL1XhsuhYjSLT2VDaxFnCPHnOeQXSaFisMX4jNhpETriqN8DOmg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.977.4", "@aws-sdk/core": "^3.977.6",
"@aws-sdk/credential-provider-env": "^3.972.65", "@aws-sdk/credential-provider-env": "^3.972.67",
"@aws-sdk/credential-provider-http": "^3.972.67", "@aws-sdk/credential-provider-http": "^3.972.69",
"@aws-sdk/credential-provider-login": "^3.972.72", "@aws-sdk/credential-provider-login": "^3.972.74",
"@aws-sdk/credential-provider-process": "^3.972.65", "@aws-sdk/credential-provider-process": "^3.972.67",
"@aws-sdk/credential-provider-sso": "^3.973.9", "@aws-sdk/credential-provider-sso": "^3.973.11",
"@aws-sdk/credential-provider-web-identity": "^3.972.71", "@aws-sdk/credential-provider-web-identity": "^3.972.73",
"@aws-sdk/nested-clients": "^3.997.39", "@aws-sdk/nested-clients": "^3.997.41",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.31.1", "@smithy/core": "^3.31.1",
"@smithy/credential-provider-imds": "^4.4.16", "@smithy/credential-provider-imds": "^4.4.16",
@ -499,13 +499,13 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-login": { "node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.72", "version": "3.972.74",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.72.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.74.tgz",
"integrity": "sha512-zZapIKwaHp7TdTf9hbH1I3CVUdEupmt7FXO/BoTQGC+4h6NkXKWpqF2p5WyfpjurDLHCpSyh+BzMlAg8arqWLA==", "integrity": "sha512-0AQfDcf99TNmqVKv0owHrw/TQs6i4ZE5t9qmz6NvO53bE/sA/tpXhXL9AAcEP1qHc6Zzjd1UMb69+/9zdhvY3g==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.977.4", "@aws-sdk/core": "^3.977.6",
"@aws-sdk/nested-clients": "^3.997.39", "@aws-sdk/nested-clients": "^3.997.41",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.31.1", "@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1", "@smithy/types": "^4.16.1",
@ -516,17 +516,17 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-node": { "node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.76", "version": "3.972.78",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.76.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.78.tgz",
"integrity": "sha512-1yzLmRiYSgGC25v7ZZEwJn/auhHHTIHgFOmzL2f36hf1+7jSLcX+1QrAz4760WEzPiiQl8xmlpFhHfl2OoyVzA==", "integrity": "sha512-OgPAnfvbGAMWac6yvxJ1ihslrvDpPVwR68D2csospdNCCyPvHk9JLzYKwz48SNiS1T2znDwHauywRKRFfpyYng==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.65", "@aws-sdk/credential-provider-env": "^3.972.67",
"@aws-sdk/credential-provider-http": "^3.972.67", "@aws-sdk/credential-provider-http": "^3.972.69",
"@aws-sdk/credential-provider-ini": "^3.973.10", "@aws-sdk/credential-provider-ini": "^3.973.12",
"@aws-sdk/credential-provider-process": "^3.972.65", "@aws-sdk/credential-provider-process": "^3.972.67",
"@aws-sdk/credential-provider-sso": "^3.973.9", "@aws-sdk/credential-provider-sso": "^3.973.11",
"@aws-sdk/credential-provider-web-identity": "^3.972.71", "@aws-sdk/credential-provider-web-identity": "^3.972.73",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.31.1", "@smithy/core": "^3.31.1",
"@smithy/credential-provider-imds": "^4.4.16", "@smithy/credential-provider-imds": "^4.4.16",
@ -538,12 +538,12 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-process": { "node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.65", "version": "3.972.67",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.65.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.67.tgz",
"integrity": "sha512-e5DbbNteOSalN58U83G6kFa4ECLEuGbGqNBHIXE7zYXA/m4GHblIGjFbSH7wYv6gBV8iNSDcRZBKfQZF5vF9nw==", "integrity": "sha512-IlUEejorGTWKb4/Dm7K5Yw4QxUmXLThLhrvBmzVBqZFTbW72cv9LTcITmo1dsnYriALE4h68mOq4LB99x6sQ7Q==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.977.4", "@aws-sdk/core": "^3.977.6",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.31.1", "@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1", "@smithy/types": "^4.16.1",
@ -554,14 +554,14 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-sso": { "node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.973.9", "version": "3.973.11",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.9.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.11.tgz",
"integrity": "sha512-0V0u4t+KBku9fbh5CPCaC5hUWwSzDafp8nCuDy817zWbp2gz80jO44rMQkiwnZ+k54B+tjAtzRy00DJRGTKGBg==", "integrity": "sha512-gAQBkBZxUB84d71+pPcI9L+jh2ujhuAVxc/4FgGiWFDjkPBlMKxzd5XDtkSXTFX8Ro7ansnT88+XadasxMeCRw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.977.4", "@aws-sdk/core": "^3.977.6",
"@aws-sdk/nested-clients": "^3.997.39", "@aws-sdk/nested-clients": "^3.997.41",
"@aws-sdk/token-providers": "3.1100.0", "@aws-sdk/token-providers": "3.1103.0",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.31.1", "@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1", "@smithy/types": "^4.16.1",
@ -572,13 +572,13 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": {
"version": "3.1100.0", "version": "3.1103.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1100.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1103.0.tgz",
"integrity": "sha512-THf3MkgY3fNJZ3zdgSenLqR7gSE68KccCj1RCKretlG73Ppszvues02VpCUO9NlB/tZDC483FvGCld+AiPCkvg==", "integrity": "sha512-N4wy26MNn31ItGVHYHPrEuCIFY4MBBjC+C5v1lJKqIUSA7OZBdhleCY53zCCrXn27hsk7YNOaTuhQu807S4AfQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.977.4", "@aws-sdk/core": "^3.977.6",
"@aws-sdk/nested-clients": "^3.997.39", "@aws-sdk/nested-clients": "^3.997.41",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.31.1", "@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1", "@smithy/types": "^4.16.1",
@ -589,13 +589,13 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-web-identity": { "node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.71", "version": "3.972.73",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.71.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.73.tgz",
"integrity": "sha512-e4dwiRltGAaQ+2yxw57Hj0l/BF3BHiG14+QpYE7bGYBlpAq/fkIri2BDhjWon8c0mhhtd2txQBAkQb9BcTStFg==", "integrity": "sha512-SnlEmQa6SjOgs6iOPLUQl1Eyq4AKiAdPQlkOhFhqNfDtDCwibMGvL6QlkSmf3o6vAUSImzdPCxowT5dfQUZP1A==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.977.4", "@aws-sdk/core": "^3.977.6",
"@aws-sdk/nested-clients": "^3.997.39", "@aws-sdk/nested-clients": "^3.997.41",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.31.1", "@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1", "@smithy/types": "^4.16.1",
@ -855,12 +855,12 @@
} }
}, },
"node_modules/@aws-sdk/nested-clients": { "node_modules/@aws-sdk/nested-clients": {
"version": "3.997.39", "version": "3.997.41",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.39.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.41.tgz",
"integrity": "sha512-wU5NPnj62Sb7A8xn/Zb+xThe05P3otNtDl37iOIi5DDMeCesNeCckaG+eXWGUs12Z9R34I8CD05TaTe6SIa61g==", "integrity": "sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "^3.977.4", "@aws-sdk/core": "^3.977.6",
"@aws-sdk/signature-v4-multi-region": "^3.996.43", "@aws-sdk/signature-v4-multi-region": "^3.996.43",
"@aws-sdk/types": "^3.974.2", "@aws-sdk/types": "^3.974.2",
"@smithy/core": "^3.31.1", "@smithy/core": "^3.31.1",

View File

@ -34,7 +34,7 @@
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-bedrock-runtime": "^3.1030.0", "@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/client-s3": "^3.1012.0",
"@aws-sdk/lib-storage": "^3.1012.0", "@aws-sdk/lib-storage": "^3.1012.0",
"@hapi/hapi": "^21.3.0", "@hapi/hapi": "^21.3.0",

View File

@ -38,6 +38,7 @@ const eventTicketRoutes = require('./routes/eventTicket.routes');
const publicEventTicketRoutes = require('./routes/publicEventTicket.routes'); const publicEventTicketRoutes = require('./routes/publicEventTicket.routes');
const publicMicrositeRoutes = require('./routes/publicMicrosite.routes'); const publicMicrositeRoutes = require('./routes/publicMicrosite.routes');
const publicCaptchaRoutes = require('./routes/publicCaptcha.routes'); const publicCaptchaRoutes = require('./routes/publicCaptcha.routes');
const publicDemoRequestRoutes = require('./routes/publicDemoRequest.routes');
const publicAttendanceRoutes = require('./routes/publicAttendance.routes'); const publicAttendanceRoutes = require('./routes/publicAttendance.routes');
const publicParticipantQrRoutes = require('./routes/publicParticipantQr.routes'); const publicParticipantQrRoutes = require('./routes/publicParticipantQr.routes');
const attendanceRoutes = require('./routes/attendance.routes'); const attendanceRoutes = require('./routes/attendance.routes');
@ -47,8 +48,10 @@ const eventTicketingManualRoutes = require('./routes/eventTicketingManual.routes
const conciergeBotRoutes = require('./routes/conciergeBot.routes'); const conciergeBotRoutes = require('./routes/conciergeBot.routes');
const publicConciergeRoutes = require('./routes/publicConcierge.routes'); const publicConciergeRoutes = require('./routes/publicConcierge.routes');
const publicQuizPollRoutes = require('./routes/publicQuizPoll.routes'); const publicQuizPollRoutes = require('./routes/publicQuizPoll.routes');
const publicPhotoWallRoutes = require('./routes/publicPhotoWall.routes');
const staticMicrositeRoutes = require('./routes/staticMicrosite.routes'); const staticMicrositeRoutes = require('./routes/staticMicrosite.routes');
const eventPhotoRoutes = require('./routes/eventPhoto.routes'); const eventPhotoRoutes = require('./routes/eventPhoto.routes');
const photoWallRoutes = require('./routes/photoWall.routes');
const createServer = async () => { 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). */ /** 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(publicEventTicketRoutes);
server.route(publicMicrositeRoutes); server.route(publicMicrositeRoutes);
server.route(publicCaptchaRoutes); server.route(publicCaptchaRoutes);
server.route(publicDemoRequestRoutes);
server.route(publicAttendanceRoutes); server.route(publicAttendanceRoutes);
server.route(publicParticipantQrRoutes); server.route(publicParticipantQrRoutes);
server.route(publicConciergeRoutes); server.route(publicConciergeRoutes);
server.route(publicQuizPollRoutes); server.route(publicQuizPollRoutes);
server.route(publicPhotoWallRoutes);
server.route(authRoutes); server.route(authRoutes);
server.route(eventRoutes); server.route(eventRoutes);
@ -319,6 +324,7 @@ const createServer = async () => {
server.route(ticketManualRoutes); server.route(ticketManualRoutes);
server.route(eventTicketingManualRoutes); server.route(eventTicketingManualRoutes);
server.route(eventPhotoRoutes); server.route(eventPhotoRoutes);
server.route(photoWallRoutes);
server.route(conciergeBotRoutes); server.route(conciergeBotRoutes);
server.route(attendanceRoutes); server.route(attendanceRoutes);
server.route(checkinsRoutes); server.route(checkinsRoutes);

View File

@ -80,6 +80,13 @@ const ACL_MODULE_DEFS = {
rowLevel: false, rowLevel: false,
planFeatures: [], planFeatures: [],
}, },
photo_wall: {
key: 'photo_wall',
label: 'Photo Wall',
category: 'event',
rowLevel: false,
planFeatures: [],
},
}; };
for (const key of COMBINED_PLAN_KEYS) { for (const key of COMBINED_PLAN_KEYS) {
@ -106,6 +113,7 @@ const ORDERED_ACL_MODULE_KEYS = [
'speaker_management', 'speaker_management',
'exhibitors', 'exhibitors',
'content_management', 'content_management',
'photo_wall',
'email_outreach_campaigns', 'email_outreach_campaigns',
'custom_badge_and_print_file_creation', 'custom_badge_and_print_file_creation',
'custom_template_for_wa_messaging', 'custom_template_for_wa_messaging',
@ -119,7 +127,7 @@ const ORDERED_ACL_MODULE_KEYS = [
'billing', 'billing',
...COMBINED_PLAN_KEYS.filter((k) => ![ ...COMBINED_PLAN_KEYS.filter((k) => ![
'events', 'tracks', 'sessions', 'forms', 'web_check_in', 'attendance', 'participants', 'agenda_builder', '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', 'email_outreach_campaigns', 'custom_badge_and_print_file_creation',
'custom_template_for_wa_messaging', 'live_polls', 'multiple_quiz_modes', 'custom_template_for_wa_messaging', 'live_polls', 'multiple_quiz_modes',
'qna_with_moderation', 'ticketing_free_and_paid', 'payment_gateway_integration', 'qna_with_moderation', 'ticketing_free_and_paid', 'payment_gateway_integration',

View File

@ -16,7 +16,20 @@ function allPlanModules(actions, scope = 'all') {
return keys.map((module) => perm(module, actions, scope)); 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 = { const SYSTEM_ROLE_PERMISSIONS = {
super_admin: [ super_admin: [
@ -28,6 +41,7 @@ const SYSTEM_ROLE_PERMISSIONS = {
perm('attendance', ['read', 'write', 'delete'], 'all'), perm('attendance', ['read', 'write', 'delete'], 'all'),
perm('participants', ['read', 'write', 'delete'], 'all'), perm('participants', ['read', 'write', 'delete'], 'all'),
perm('exhibitors', ['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('user_management', ['read', 'write', 'delete'], 'all'),
perm('settings', ['read', 'write', 'delete'], 'all'), perm('settings', ['read', 'write', 'delete'], 'all'),
perm('billing', ['read', 'write'], 'all'), perm('billing', ['read', 'write'], 'all'),
@ -44,6 +58,7 @@ const SYSTEM_ROLE_PERMISSIONS = {
perm('attendance', ['read', 'write', 'delete'], 'all'), perm('attendance', ['read', 'write', 'delete'], 'all'),
perm('participants', ['read', 'write', 'delete'], 'all'), perm('participants', ['read', 'write', 'delete'], 'all'),
perm('exhibitors', ['read', 'write', 'delete'], 'all'), perm('exhibitors', ['read', 'write', 'delete'], 'all'),
perm('photo_wall', ['read', 'write', 'delete'], 'all'),
perm('user_management', ['read', 'write'], 'all'), perm('user_management', ['read', 'write'], 'all'),
perm('settings', ['read', 'write'], 'all'), perm('settings', ['read', 'write'], 'all'),
perm('billing', ['read', 'write'], 'all'), perm('billing', ['read', 'write'], 'all'),
@ -60,6 +75,7 @@ const SYSTEM_ROLE_PERMISSIONS = {
perm('attendance', ['read', 'write'], 'assigned'), perm('attendance', ['read', 'write'], 'assigned'),
perm('participants', ['read', 'write'], 'all'), perm('participants', ['read', 'write'], 'all'),
perm('exhibitors', ['read', 'write'], 'assigned'), perm('exhibitors', ['read', 'write'], 'assigned'),
perm('photo_wall', ['read', 'write'], 'assigned'),
...allPlanModules(['read', 'write'], 'all').filter( ...allPlanModules(['read', 'write'], 'all').filter(
(p) => !CORE_EXPLICIT.includes(p.module), (p) => !CORE_EXPLICIT.includes(p.module),
), ),
@ -73,6 +89,7 @@ const SYSTEM_ROLE_PERMISSIONS = {
perm('attendance', ['read'], 'assigned'), perm('attendance', ['read'], 'assigned'),
perm('participants', ['read'], 'all'), perm('participants', ['read'], 'all'),
perm('exhibitors', ['read'], 'assigned'), perm('exhibitors', ['read'], 'assigned'),
perm('photo_wall', ['read'], 'assigned'),
...allPlanModules(['read'], 'all').filter( ...allPlanModules(['read'], 'all').filter(
(p) => !CORE_EXPLICIT.includes(p.module), (p) => !CORE_EXPLICIT.includes(p.module),
), ),

View File

@ -40,8 +40,27 @@ const {
const { buildParticipantQr } = require('../utils/webCheckInQr'); 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) { 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". // Some upstream code can accidentally include mongoose internals like "$__deferred".
const strip = (x) => { const strip = (x) => {
if (x == null) return x; if (x == null) return x;
@ -54,8 +73,16 @@ function safeFormDataForMongo(val) {
const out = {}; const out = {};
for (const [k, v] of Object.entries(x)) { for (const [k, v] of Object.entries(x)) {
if (k.startsWith('$')) continue; // Drop mongoose / operator internals (ASCII $ prefix only).
out[k] = strip(v); 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; return out;
}; };
@ -71,7 +98,7 @@ function mapToPlain(obj) {
function safeMergeSubmissionData(submissionDoc, extra) { function safeMergeSubmissionData(submissionDoc, extra) {
const current = mapToPlain(submissionDoc?.data); const current = mapToPlain(submissionDoc?.data);
return { ...current, ...extra }; return safeFormDataForMongo({ ...current, ...extra });
} }
function throwRegError(message, code) { function throwRegError(message, code) {
@ -850,8 +877,34 @@ const submit = async (request, h) => {
function dataMapToObject(data) { function dataMapToObject(data) {
if (!data) return {}; if (!data) return {};
if (data instanceof Map) return Object.fromEntries(data); const raw =
return typeof data === 'object' ? { ...data } : {}; 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) { function submissionSearchBlob(sub) {
@ -924,7 +977,7 @@ function submissionsToCsv(form, rows) {
for (const s of rows) { for (const s of rows) {
const o = dataMapToObject(s.data); const o = dataMapToObject(s.data);
const email = s.participant && typeof s.participant === 'object' ? s.participant.email || '' : ''; 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]; const row = [new Date(s.submittedAt).toISOString(), email, ...vals];
lines.push(row.map(escapeCsvCell).join(',')); 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 limit = Math.min(100, Math.max(1, parseInt(String(limitStr || '20'), 10) || 20));
const total = all.length; const total = all.length;
const skip = (page - 1) * limit; 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 })); return h.response(success({ submissions, total, page, limit }));
}; };

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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,
};

View File

@ -1,7 +1,10 @@
const QuizPoll = require('../models/QuizPoll'); const QuizPoll = require('../models/QuizPoll');
const QuizPollResponse = require('../models/QuizPollResponse'); const QuizPollResponse = require('../models/QuizPollResponse');
const { success, error } = require('../utils/response'); const { success, error } = require('../utils/response');
const { buildQuestionPresentationStats } = require('../utils/quizPollResultsSummary'); const {
buildQuestionPresentationStats,
buildQuizPresentationLeaderboard,
} = require('../utils/quizPollResultsSummary');
const { const {
applyPublish, applyPublish,
applyClose, applyClose,
@ -9,7 +12,18 @@ const {
} = require('../utils/quizPollLiveControls'); } = require('../utils/quizPollLiveControls');
function presentationPublicBase() { 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) { function mapQuestionForPresentation(q) {
@ -37,6 +51,7 @@ function mapSettingsForPresentation(settings) {
? String(s.liveQuestionId).trim() ? String(s.liveQuestionId).trim()
: null, : null,
showCorrectAnswer: Boolean(s.showCorrectAnswer), showCorrectAnswer: Boolean(s.showCorrectAnswer),
showLeaderboard: Boolean(s.showLeaderboard),
showResults: s.showResults !== false, showResults: s.showResults !== false,
}; };
} }
@ -104,6 +119,24 @@ async function getQuestionStats(request, h) {
return h.response(success(stats)); 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 */ /** POST /api/public/quiz-polls/present/{token}/publish */
async function publishPresentation(request, h) { async function publishPresentation(request, h) {
const item = await loadQuizPollDocByPresentationToken(request.params.token); const item = await loadQuizPollDocByPresentationToken(request.params.token);
@ -174,6 +207,7 @@ async function getSessionActivities(request, h) {
module.exports = { module.exports = {
getPresentation, getPresentation,
getQuestionStats, getQuestionStats,
getLeaderboard,
publishPresentation, publishPresentation,
closePresentation, closePresentation,
setPresentationLiveQuestion, setPresentationLiveQuestion,

View File

@ -11,7 +11,18 @@ const {
} = require('../utils/quizPollLiveControls'); } = require('../utils/quizPollLiveControls');
function presentationPublicBase() { 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) { 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')); 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 ensurePresentationToken = async (request, h) => {
const item = await QuizPoll.findOne({ _id: request.params.id, deletedAt: null }); 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); 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 = { module.exports = {
list, create, get, update, remove, publish, close, setLiveQuestion, setShowCorrect, ensurePresentationToken, results, list, create, get, update, remove, publish, close, setLiveQuestion, setShowCorrect, setShowLeaderboard,
submitResponse, listResponses, sessionQuizPolls, ensurePresentationToken, results, submitResponse, listResponses, sessionQuizPolls,
}; };

View File

@ -14,6 +14,7 @@ const {
KEY_PERSON_ROLES, KEY_PERSON_ROLES,
DEFAULT_KEY_PERSON_ROLE, DEFAULT_KEY_PERSON_ROLE,
} = require('../config/constants'); } = require('../config/constants');
const { setSessionPresentFocus } = require('../utils/sessionPresentationFirebaseSync');
const KEY_PERSON_ROLE_SET = new Set(KEY_PERSON_ROLES); const KEY_PERSON_ROLE_SET = new Set(KEY_PERSON_ROLES);
@ -212,4 +213,18 @@ const updateStatus = async (request, h) => {
return h.response(success(session)); 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 };

88
src/models/PhotoWall.js Normal file
View File

@ -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);

View File

@ -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);

View File

@ -56,6 +56,8 @@ const quizPollSchema = new mongoose.Schema(
liveQuestionId: { type: String, default: null }, liveQuestionId: { type: String, default: null },
/** quiz_master: projector page highlights correct answer(s) for the live question */ /** quiz_master: projector page highlights correct answer(s) for the live question */
showCorrectAnswer: { type: Boolean, default: false }, 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 */ /** Unguessable token for login-free presentation / projector page */
presentationToken: { type: String, trim: true }, presentationToken: { type: String, trim: true },
rewardRules: rewardRulesSubSchema, rewardRules: rewardRulesSubSchema,

View File

@ -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,
},
},
];

View File

@ -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,
},
},
];

View File

@ -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,
},
},
];

View File

@ -16,6 +16,11 @@ module.exports = [
path: '/api/public/quiz-polls/present/{token}/questions/{questionIndex}/stats', path: '/api/public/quiz-polls/present/{token}/questions/{questionIndex}/stats',
options: { auth: false, handler: controller.getQuestionStats }, options: { auth: false, handler: controller.getQuestionStats },
}, },
{
method: 'GET',
path: '/api/public/quiz-polls/present/{token}/leaderboard',
options: { auth: false, handler: controller.getLeaderboard },
},
{ {
method: 'GET', method: 'GET',
path: '/api/public/quiz-polls/present/{token}/session-activities', path: '/api/public/quiz-polls/present/{token}/session-activities',

View File

@ -48,6 +48,7 @@ const quizPollPayload = Joi.object({
timeLimit: Joi.number().integer().min(0), timeLimit: Joi.number().integer().min(0),
liveQuestionId: Joi.string().allow('', null), liveQuestionId: Joi.string().allow('', null),
showCorrectAnswer: Joi.boolean(), showCorrectAnswer: Joi.boolean(),
showLeaderboard: Joi.boolean(),
presentationToken: Joi.string().allow('', null), presentationToken: Joi.string().allow('', null),
rewardRules: rewardRulesPayload, rewardRules: rewardRulesPayload,
}), }),
@ -73,6 +74,10 @@ const showCorrectPayload = Joi.object({
show: Joi.boolean().required(), show: Joi.boolean().required(),
}); });
const showLeaderboardPayload = Joi.object({
show: Joi.boolean().required(),
});
module.exports = [ module.exports = [
{ method: 'GET', path: '/api/events/{eventId}/quiz-polls', options: { handler: controller.list } }, { 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 } }, { 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}/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}/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-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: '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: '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 } }, { method: 'POST', path: '/api/quiz-polls/{quizPollId}/responses', options: { validate: { payload: responsePayload }, handler: controller.submitResponse } },

View File

@ -69,4 +69,9 @@ module.exports = [
handler: controller.updateStatus, handler: controller.updateStatus,
}, },
}, },
{
method: 'POST',
path: '/api/sessions/{id}/present-questions',
options: { handler: controller.presentQuestions },
},
]; ];

View File

@ -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 = `<p><strong>New demo request</strong> from the EventBuzz marketing website.</p>
<p><strong>Name:</strong> ${escapeHtml(name || '—')}<br/>
<strong>Mobile:</strong> ${escapeHtml(mobile || '—')}<br/>
<strong>Email:</strong> <a href="mailto:${escapeHtml(email || '')}">${escapeHtml(email || '—')}</a></p>
<p style="white-space:pre-wrap;border-left:3px solid #ed8e1c;padding-left:12px;">${escapeHtml(message || '(no message)')}</p>
<p style="color:#64748b;font-size:13px;">— eventbuzz.live Request a demo</p>`;
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({ async function sendContactSalesLeadEmail({
to, to,
requesterEmail, requesterEmail,
@ -444,5 +498,6 @@ module.exports = {
sendVisitorOtpEmail, sendVisitorOtpEmail,
sendVisitorMagicLinkEmail, sendVisitorMagicLinkEmail,
sendMicrositeContactEmail, sendMicrositeContactEmail,
sendWebsiteDemoRequestEmail,
sendContactSalesLeadEmail, sendContactSalesLeadEmail,
}; };

View File

@ -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,
};

View File

@ -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,
};

View File

@ -39,6 +39,7 @@ function normalizeSettings(settings) {
s.liveQuestionId = null; s.liveQuestionId = null;
} }
s.showCorrectAnswer = s.showCorrectAnswer === true || s.showCorrectAnswer === 'true'; s.showCorrectAnswer = s.showCorrectAnswer === true || s.showCorrectAnswer === 'true';
s.showLeaderboard = s.showLeaderboard === true || s.showLeaderboard === 'true';
return s; return s;
} }

View File

@ -1,5 +1,6 @@
const QuizPoll = require('../models/QuizPoll'); const QuizPoll = require('../models/QuizPoll');
const { syncQuizPollToFirebase } = require('./quizPollFirebaseSync'); const { syncQuizPollToFirebase } = require('./quizPollFirebaseSync');
const { clearSessionPresentFocus } = require('./sessionPresentationFirebaseSync');
const MANUAL_OR_QUIZ_MASTER = new Set(['manual', 'quiz_master']); const MANUAL_OR_QUIZ_MASTER = new Set(['manual', 'quiz_master']);
@ -63,6 +64,7 @@ async function applyPublish(item) {
item.settings.isLive = true; item.settings.isLive = true;
await item.save(); await item.save();
await syncQuizPollToFirebase(item); await syncQuizPollToFirebase(item);
if (item.session) await clearSessionPresentFocus(item.session);
await offlineSiblingManualAndQuizMaster(item); await offlineSiblingManualAndQuizMaster(item);
return { ok: true, item, message: 'Published' }; return { ok: true, item, message: 'Published' };
} }
@ -75,6 +77,7 @@ async function applyClose(item) {
item.settings.isLive = false; item.settings.isLive = false;
item.settings.liveQuestionId = null; item.settings.liveQuestionId = null;
item.settings.showCorrectAnswer = false; item.settings.showCorrectAnswer = false;
item.settings.showLeaderboard = false;
await item.save(); await item.save();
await syncQuizPollToFirebase(item); await syncQuizPollToFirebase(item);
return { ok: true, item, message: 'Closed' }; return { ok: true, item, message: 'Closed' };
@ -99,6 +102,7 @@ async function applySetLiveQuestion(item, rawQuestionId) {
if (rawQuestionId == null || String(rawQuestionId).trim() === '') { if (rawQuestionId == null || String(rawQuestionId).trim() === '') {
item.settings.liveQuestionId = null; item.settings.liveQuestionId = null;
item.settings.showCorrectAnswer = false; item.settings.showCorrectAnswer = false;
item.settings.showLeaderboard = false;
await item.save(); await item.save();
await syncQuizPollToFirebase(item); await syncQuizPollToFirebase(item);
return { ok: true, item, message: 'Live question cleared' }; return { ok: true, item, message: 'Live question cleared' };
@ -122,6 +126,7 @@ async function applySetLiveQuestion(item, rawQuestionId) {
item.settings.isLive = true; item.settings.isLive = true;
await item.save(); await item.save();
await syncQuizPollToFirebase(item); await syncQuizPollToFirebase(item);
if (item.session) await clearSessionPresentFocus(item.session);
await offlineSiblingManualAndQuizMaster(item); await offlineSiblingManualAndQuizMaster(item);
return { ok: true, item, message: 'Question is now live' }; return { ok: true, item, message: 'Question is now live' };
} }

View File

@ -329,9 +329,64 @@ function buildQuestionPresentationStats(quizPoll, allResponses, questionIndex) {
return { ...base, kind: 'single', options: opts }; 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 = { module.exports = {
normAnswerVal, normAnswerVal,
participantDisplayName, participantDisplayName,
buildVisitorPollResultsSummary, buildVisitorPollResultsSummary,
buildQuestionPresentationStats, buildQuestionPresentationStats,
buildQuizPresentationLeaderboard,
}; };

View File

@ -13,18 +13,53 @@ function sanitizeKeySegment(segment) {
/** /**
* Build S3 key for event photos under eventbuzz-pictures bucket. * 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 safeEventId = sanitizeKeySegment(eventId || 'unknown-event');
const safeFilename = sanitizeKeySegment(filename || 'file'); const safeFilename = sanitizeKeySegment(filename || 'file');
const safeSubdir = subdir ? sanitizeKeySegment(subdir) : '';
if (safeSubdir) return `${safeEventId}/${safeSubdir}/${safeFilename}`;
return `${safeEventId}/${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) { function cloudfrontUrlForKey(key) {
const base = getEnv('AWS_URL_PICTURES', '').replace(/\/+$/, ''); const raw = String(key || '').trim();
if (!base) return key; if (!raw) return '';
return `${base}/${key}`; 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; let cachedS3Client = null;
@ -46,12 +81,34 @@ function getS3Client() {
/** /**
* Upload a photo buffer to the eventbuzz-pictures S3 bucket. * Upload a photo buffer to the eventbuzz-pictures S3 bucket.
* Returns { s3Key, url }. * 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'); const bucket = getEnv('AWS_EVENT_PICTURES_BUCKET', 'eventbuzz-pictures');
if (!bucket) throw new Error('AWS_EVENT_PICTURES_BUCKET is not configured'); 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( await getS3Client().send(
new PutObjectCommand({ new PutObjectCommand({
@ -104,8 +161,12 @@ async function getEventPhotoBufferFromS3(s3Key) {
module.exports = { module.exports = {
uploadEventPhotoToS3, uploadEventPhotoToS3,
uploadPhotoWallPhotoToS3,
deleteEventPhotoFromS3, deleteEventPhotoFromS3,
getEventPhotoBufferFromS3, getEventPhotoBufferFromS3,
buildEventPhotoKey, buildEventPhotoKey,
buildPhotoWallKey,
cloudfrontUrlForKey,
resolvePhotoPublicUrl,
}; };

View File

@ -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,
};