Changes
This commit is contained in:
parent
49f9357f72
commit
d77d6f7cd1
|
|
@ -51,6 +51,9 @@ AWS_SECRET_ACCESS_KEY=ZpIxby1vbCLGB7CPWZsAltZBEqkhwzgBpFOKDGj8
|
|||
AWS_DEFAULT_REGION=ap-south-1
|
||||
AWS_BUCKET=rapportapp
|
||||
AWS_URL=https://did94hhhcgq1d.cloudfront.net
|
||||
# Event photos / photo-wall bucket + CDN (separate from AWS_URL media CDN)
|
||||
AWS_EVENT_PICTURES_BUCKET=eventbuzz-pictures
|
||||
AWS_URL_PICTURES=https://d155t9or8i84n8.cloudfront.net
|
||||
# Participant check-in QR PNGs: s3://{AWS_BUCKET}/eventbuzz/check-in-qr/participant-{id}.png
|
||||
# Served via AWS_URL (CloudFront). Created on first use (campaigns, public QR route, concierge).
|
||||
# Amazon Bedrock (AI event quick-create, concierge food/places summaries). Uses Converse API.
|
||||
|
|
@ -110,6 +113,9 @@ AWS_URL=https://did94hhhcgq1d.cloudfront.net
|
|||
# Optional DB override (default: database name from MONGODB_URI)
|
||||
# CONCIERGE_RECOMMENDATION_DB=
|
||||
|
||||
# Marketing site "Request a demo" recipient (comma-separated allowed)
|
||||
# WEBSITE_DEMO_REQUEST_TO=shabareesh@stratawiz.com
|
||||
|
||||
# HMAC secret for public form captcha tokens (falls back to JWT_SECRET if unset)
|
||||
# FORM_CAPTCHA_SECRET=
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.1030.0",
|
||||
"@aws-sdk/client-rekognition": "^3.1101.0",
|
||||
"@aws-sdk/client-rekognition": "^3.1104.0",
|
||||
"@aws-sdk/client-s3": "^3.1012.0",
|
||||
"@aws-sdk/lib-storage": "^3.1012.0",
|
||||
"@hapi/hapi": "^21.3.0",
|
||||
|
|
@ -315,13 +315,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/client-rekognition": {
|
||||
"version": "3.1101.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-rekognition/-/client-rekognition-3.1101.0.tgz",
|
||||
"integrity": "sha512-M+bb4PuyAk4WC5oxn65aFMP5mYcPM9CAVmwu4Fzd3fridCX8ElBVYBE8L4micpihtV6VvN1NQb8PzeT4qm244g==",
|
||||
"version": "3.1104.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-rekognition/-/client-rekognition-3.1104.0.tgz",
|
||||
"integrity": "sha512-/hno+AX0KW16ZPxcgNdWrLUyxoJ0XKqLerb8rUGz9DLKNvdL03VIt620TwF05mpen18x6Aybd8kzV3LXYqSSBA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.4",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.76",
|
||||
"@aws-sdk/core": "^3.977.6",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.78",
|
||||
"@aws-sdk/types": "^3.974.2",
|
||||
"@smithy/core": "^3.31.1",
|
||||
"@smithy/fetch-http-handler": "^5.6.13",
|
||||
|
|
@ -400,9 +400,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/core": {
|
||||
"version": "3.977.4",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.4.tgz",
|
||||
"integrity": "sha512-CEkcQlMOQJCvul60U7wdAOACjtdgFWDsfJI+6wUOGdhGNV2lGbuJpi/R50QLpFG3Tp+sQxa/RmzC3X7KHbhuTA==",
|
||||
"version": "3.977.6",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.6.tgz",
|
||||
"integrity": "sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/types": "^3.974.2",
|
||||
|
|
@ -441,12 +441,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-env": {
|
||||
"version": "3.972.65",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.65.tgz",
|
||||
"integrity": "sha512-lJT2aRw9wCV8jPHyFJjdZLD4HTydL6/22AnCSOB8e/LqOc55nEJGLHkJQeSxhn8QiqyjFwPKQFtMw0ovjRUY/g==",
|
||||
"version": "3.972.67",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.67.tgz",
|
||||
"integrity": "sha512-rcIpk5kxUqDaaNa6Xk23pQ6ViY7jlqzmfFWCahQcBT97ddXaXYYwzCen9Tz1Jvo6aJft6wDl5bN44/Jw5B4oLA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.4",
|
||||
"@aws-sdk/core": "^3.977.6",
|
||||
"@aws-sdk/types": "^3.974.2",
|
||||
"@smithy/core": "^3.31.1",
|
||||
"@smithy/types": "^4.16.1",
|
||||
|
|
@ -457,12 +457,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-http": {
|
||||
"version": "3.972.67",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.67.tgz",
|
||||
"integrity": "sha512-N7fw/15hSwI/CPxe5ohOyb7O4ge9f5me1gVIn8OIkBRB0squ8OJqQyDyH/HoL+Sb1W5xdC88jVC+bHkw73iu+Q==",
|
||||
"version": "3.972.69",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.69.tgz",
|
||||
"integrity": "sha512-nggwJtZ4eeNsUw5IeWBMXsi1ryct5idi0K+/SCRF3kybLubOMaNTb3XCihXpWMiVpyzyPeIrl0zTkzhBH9porA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.4",
|
||||
"@aws-sdk/core": "^3.977.6",
|
||||
"@aws-sdk/types": "^3.974.2",
|
||||
"@smithy/core": "^3.31.1",
|
||||
"@smithy/fetch-http-handler": "^5.6.13",
|
||||
|
|
@ -475,19 +475,19 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-ini": {
|
||||
"version": "3.973.10",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.10.tgz",
|
||||
"integrity": "sha512-Zh9XRaPnDN9buO7GfWBubS22R6Nq5D6hbyYEMN05LiOnXugm/8WDjUx6y756bSPbdn3aJB2qG4zFW3bN82QhoQ==",
|
||||
"version": "3.973.12",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.12.tgz",
|
||||
"integrity": "sha512-pNEf/OeyN5X3VmLKlgSO6TqaWmW10CvI3TfwL1XhsuhYjSLT2VDaxFnCPHnOeQXSaFisMX4jNhpETriqN8DOmg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.4",
|
||||
"@aws-sdk/credential-provider-env": "^3.972.65",
|
||||
"@aws-sdk/credential-provider-http": "^3.972.67",
|
||||
"@aws-sdk/credential-provider-login": "^3.972.72",
|
||||
"@aws-sdk/credential-provider-process": "^3.972.65",
|
||||
"@aws-sdk/credential-provider-sso": "^3.973.9",
|
||||
"@aws-sdk/credential-provider-web-identity": "^3.972.71",
|
||||
"@aws-sdk/nested-clients": "^3.997.39",
|
||||
"@aws-sdk/core": "^3.977.6",
|
||||
"@aws-sdk/credential-provider-env": "^3.972.67",
|
||||
"@aws-sdk/credential-provider-http": "^3.972.69",
|
||||
"@aws-sdk/credential-provider-login": "^3.972.74",
|
||||
"@aws-sdk/credential-provider-process": "^3.972.67",
|
||||
"@aws-sdk/credential-provider-sso": "^3.973.11",
|
||||
"@aws-sdk/credential-provider-web-identity": "^3.972.73",
|
||||
"@aws-sdk/nested-clients": "^3.997.41",
|
||||
"@aws-sdk/types": "^3.974.2",
|
||||
"@smithy/core": "^3.31.1",
|
||||
"@smithy/credential-provider-imds": "^4.4.16",
|
||||
|
|
@ -499,13 +499,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-login": {
|
||||
"version": "3.972.72",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.72.tgz",
|
||||
"integrity": "sha512-zZapIKwaHp7TdTf9hbH1I3CVUdEupmt7FXO/BoTQGC+4h6NkXKWpqF2p5WyfpjurDLHCpSyh+BzMlAg8arqWLA==",
|
||||
"version": "3.972.74",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.74.tgz",
|
||||
"integrity": "sha512-0AQfDcf99TNmqVKv0owHrw/TQs6i4ZE5t9qmz6NvO53bE/sA/tpXhXL9AAcEP1qHc6Zzjd1UMb69+/9zdhvY3g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.4",
|
||||
"@aws-sdk/nested-clients": "^3.997.39",
|
||||
"@aws-sdk/core": "^3.977.6",
|
||||
"@aws-sdk/nested-clients": "^3.997.41",
|
||||
"@aws-sdk/types": "^3.974.2",
|
||||
"@smithy/core": "^3.31.1",
|
||||
"@smithy/types": "^4.16.1",
|
||||
|
|
@ -516,17 +516,17 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-node": {
|
||||
"version": "3.972.76",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.76.tgz",
|
||||
"integrity": "sha512-1yzLmRiYSgGC25v7ZZEwJn/auhHHTIHgFOmzL2f36hf1+7jSLcX+1QrAz4760WEzPiiQl8xmlpFhHfl2OoyVzA==",
|
||||
"version": "3.972.78",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.78.tgz",
|
||||
"integrity": "sha512-OgPAnfvbGAMWac6yvxJ1ihslrvDpPVwR68D2csospdNCCyPvHk9JLzYKwz48SNiS1T2znDwHauywRKRFfpyYng==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-provider-env": "^3.972.65",
|
||||
"@aws-sdk/credential-provider-http": "^3.972.67",
|
||||
"@aws-sdk/credential-provider-ini": "^3.973.10",
|
||||
"@aws-sdk/credential-provider-process": "^3.972.65",
|
||||
"@aws-sdk/credential-provider-sso": "^3.973.9",
|
||||
"@aws-sdk/credential-provider-web-identity": "^3.972.71",
|
||||
"@aws-sdk/credential-provider-env": "^3.972.67",
|
||||
"@aws-sdk/credential-provider-http": "^3.972.69",
|
||||
"@aws-sdk/credential-provider-ini": "^3.973.12",
|
||||
"@aws-sdk/credential-provider-process": "^3.972.67",
|
||||
"@aws-sdk/credential-provider-sso": "^3.973.11",
|
||||
"@aws-sdk/credential-provider-web-identity": "^3.972.73",
|
||||
"@aws-sdk/types": "^3.974.2",
|
||||
"@smithy/core": "^3.31.1",
|
||||
"@smithy/credential-provider-imds": "^4.4.16",
|
||||
|
|
@ -538,12 +538,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-process": {
|
||||
"version": "3.972.65",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.65.tgz",
|
||||
"integrity": "sha512-e5DbbNteOSalN58U83G6kFa4ECLEuGbGqNBHIXE7zYXA/m4GHblIGjFbSH7wYv6gBV8iNSDcRZBKfQZF5vF9nw==",
|
||||
"version": "3.972.67",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.67.tgz",
|
||||
"integrity": "sha512-IlUEejorGTWKb4/Dm7K5Yw4QxUmXLThLhrvBmzVBqZFTbW72cv9LTcITmo1dsnYriALE4h68mOq4LB99x6sQ7Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.4",
|
||||
"@aws-sdk/core": "^3.977.6",
|
||||
"@aws-sdk/types": "^3.974.2",
|
||||
"@smithy/core": "^3.31.1",
|
||||
"@smithy/types": "^4.16.1",
|
||||
|
|
@ -554,14 +554,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-sso": {
|
||||
"version": "3.973.9",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.9.tgz",
|
||||
"integrity": "sha512-0V0u4t+KBku9fbh5CPCaC5hUWwSzDafp8nCuDy817zWbp2gz80jO44rMQkiwnZ+k54B+tjAtzRy00DJRGTKGBg==",
|
||||
"version": "3.973.11",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.11.tgz",
|
||||
"integrity": "sha512-gAQBkBZxUB84d71+pPcI9L+jh2ujhuAVxc/4FgGiWFDjkPBlMKxzd5XDtkSXTFX8Ro7ansnT88+XadasxMeCRw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.4",
|
||||
"@aws-sdk/nested-clients": "^3.997.39",
|
||||
"@aws-sdk/token-providers": "3.1100.0",
|
||||
"@aws-sdk/core": "^3.977.6",
|
||||
"@aws-sdk/nested-clients": "^3.997.41",
|
||||
"@aws-sdk/token-providers": "3.1103.0",
|
||||
"@aws-sdk/types": "^3.974.2",
|
||||
"@smithy/core": "^3.31.1",
|
||||
"@smithy/types": "^4.16.1",
|
||||
|
|
@ -572,13 +572,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": {
|
||||
"version": "3.1100.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1100.0.tgz",
|
||||
"integrity": "sha512-THf3MkgY3fNJZ3zdgSenLqR7gSE68KccCj1RCKretlG73Ppszvues02VpCUO9NlB/tZDC483FvGCld+AiPCkvg==",
|
||||
"version": "3.1103.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1103.0.tgz",
|
||||
"integrity": "sha512-N4wy26MNn31ItGVHYHPrEuCIFY4MBBjC+C5v1lJKqIUSA7OZBdhleCY53zCCrXn27hsk7YNOaTuhQu807S4AfQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.4",
|
||||
"@aws-sdk/nested-clients": "^3.997.39",
|
||||
"@aws-sdk/core": "^3.977.6",
|
||||
"@aws-sdk/nested-clients": "^3.997.41",
|
||||
"@aws-sdk/types": "^3.974.2",
|
||||
"@smithy/core": "^3.31.1",
|
||||
"@smithy/types": "^4.16.1",
|
||||
|
|
@ -589,13 +589,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-web-identity": {
|
||||
"version": "3.972.71",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.71.tgz",
|
||||
"integrity": "sha512-e4dwiRltGAaQ+2yxw57Hj0l/BF3BHiG14+QpYE7bGYBlpAq/fkIri2BDhjWon8c0mhhtd2txQBAkQb9BcTStFg==",
|
||||
"version": "3.972.73",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.73.tgz",
|
||||
"integrity": "sha512-SnlEmQa6SjOgs6iOPLUQl1Eyq4AKiAdPQlkOhFhqNfDtDCwibMGvL6QlkSmf3o6vAUSImzdPCxowT5dfQUZP1A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.4",
|
||||
"@aws-sdk/nested-clients": "^3.997.39",
|
||||
"@aws-sdk/core": "^3.977.6",
|
||||
"@aws-sdk/nested-clients": "^3.997.41",
|
||||
"@aws-sdk/types": "^3.974.2",
|
||||
"@smithy/core": "^3.31.1",
|
||||
"@smithy/types": "^4.16.1",
|
||||
|
|
@ -855,12 +855,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/nested-clients": {
|
||||
"version": "3.997.39",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.39.tgz",
|
||||
"integrity": "sha512-wU5NPnj62Sb7A8xn/Zb+xThe05P3otNtDl37iOIi5DDMeCesNeCckaG+eXWGUs12Z9R34I8CD05TaTe6SIa61g==",
|
||||
"version": "3.997.41",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.41.tgz",
|
||||
"integrity": "sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.4",
|
||||
"@aws-sdk/core": "^3.977.6",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.43",
|
||||
"@aws-sdk/types": "^3.974.2",
|
||||
"@smithy/core": "^3.31.1",
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.1030.0",
|
||||
"@aws-sdk/client-rekognition": "^3.1101.0",
|
||||
"@aws-sdk/client-rekognition": "^3.1104.0",
|
||||
"@aws-sdk/client-s3": "^3.1012.0",
|
||||
"@aws-sdk/lib-storage": "^3.1012.0",
|
||||
"@hapi/hapi": "^21.3.0",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ const eventTicketRoutes = require('./routes/eventTicket.routes');
|
|||
const publicEventTicketRoutes = require('./routes/publicEventTicket.routes');
|
||||
const publicMicrositeRoutes = require('./routes/publicMicrosite.routes');
|
||||
const publicCaptchaRoutes = require('./routes/publicCaptcha.routes');
|
||||
const publicDemoRequestRoutes = require('./routes/publicDemoRequest.routes');
|
||||
const publicAttendanceRoutes = require('./routes/publicAttendance.routes');
|
||||
const publicParticipantQrRoutes = require('./routes/publicParticipantQr.routes');
|
||||
const attendanceRoutes = require('./routes/attendance.routes');
|
||||
|
|
@ -47,8 +48,10 @@ const eventTicketingManualRoutes = require('./routes/eventTicketingManual.routes
|
|||
const conciergeBotRoutes = require('./routes/conciergeBot.routes');
|
||||
const publicConciergeRoutes = require('./routes/publicConcierge.routes');
|
||||
const publicQuizPollRoutes = require('./routes/publicQuizPoll.routes');
|
||||
const publicPhotoWallRoutes = require('./routes/publicPhotoWall.routes');
|
||||
const staticMicrositeRoutes = require('./routes/staticMicrosite.routes');
|
||||
const eventPhotoRoutes = require('./routes/eventPhoto.routes');
|
||||
const photoWallRoutes = require('./routes/photoWall.routes');
|
||||
|
||||
const createServer = async () => {
|
||||
/** Production: set CORS_ORIGIN (or FRONTEND_URL) to every origin that loads the SPA, e.g. https://console.example.com (no trailing slash). */
|
||||
|
|
@ -278,10 +281,12 @@ const createServer = async () => {
|
|||
server.route(publicEventTicketRoutes);
|
||||
server.route(publicMicrositeRoutes);
|
||||
server.route(publicCaptchaRoutes);
|
||||
server.route(publicDemoRequestRoutes);
|
||||
server.route(publicAttendanceRoutes);
|
||||
server.route(publicParticipantQrRoutes);
|
||||
server.route(publicConciergeRoutes);
|
||||
server.route(publicQuizPollRoutes);
|
||||
server.route(publicPhotoWallRoutes);
|
||||
|
||||
server.route(authRoutes);
|
||||
server.route(eventRoutes);
|
||||
|
|
@ -319,6 +324,7 @@ const createServer = async () => {
|
|||
server.route(ticketManualRoutes);
|
||||
server.route(eventTicketingManualRoutes);
|
||||
server.route(eventPhotoRoutes);
|
||||
server.route(photoWallRoutes);
|
||||
server.route(conciergeBotRoutes);
|
||||
server.route(attendanceRoutes);
|
||||
server.route(checkinsRoutes);
|
||||
|
|
|
|||
|
|
@ -80,6 +80,13 @@ const ACL_MODULE_DEFS = {
|
|||
rowLevel: false,
|
||||
planFeatures: [],
|
||||
},
|
||||
photo_wall: {
|
||||
key: 'photo_wall',
|
||||
label: 'Photo Wall',
|
||||
category: 'event',
|
||||
rowLevel: false,
|
||||
planFeatures: [],
|
||||
},
|
||||
};
|
||||
|
||||
for (const key of COMBINED_PLAN_KEYS) {
|
||||
|
|
@ -106,6 +113,7 @@ const ORDERED_ACL_MODULE_KEYS = [
|
|||
'speaker_management',
|
||||
'exhibitors',
|
||||
'content_management',
|
||||
'photo_wall',
|
||||
'email_outreach_campaigns',
|
||||
'custom_badge_and_print_file_creation',
|
||||
'custom_template_for_wa_messaging',
|
||||
|
|
@ -119,7 +127,7 @@ const ORDERED_ACL_MODULE_KEYS = [
|
|||
'billing',
|
||||
...COMBINED_PLAN_KEYS.filter((k) => ![
|
||||
'events', 'tracks', 'sessions', 'forms', 'web_check_in', 'attendance', 'participants', 'agenda_builder',
|
||||
'multi_track_agenda', 'speaker_management', 'exhibitors', 'content_management',
|
||||
'multi_track_agenda', 'speaker_management', 'exhibitors', 'content_management', 'photo_wall',
|
||||
'email_outreach_campaigns', 'custom_badge_and_print_file_creation',
|
||||
'custom_template_for_wa_messaging', 'live_polls', 'multiple_quiz_modes',
|
||||
'qna_with_moderation', 'ticketing_free_and_paid', 'payment_gateway_integration',
|
||||
|
|
|
|||
|
|
@ -16,7 +16,20 @@ function allPlanModules(actions, scope = 'all') {
|
|||
return keys.map((module) => perm(module, actions, scope));
|
||||
}
|
||||
|
||||
const CORE_EXPLICIT = ['events', 'tracks', 'sessions', 'forms', 'web_check_in', 'attendance', 'participants', 'exhibitors', 'user_management', 'settings', 'billing'];
|
||||
const CORE_EXPLICIT = [
|
||||
'events',
|
||||
'tracks',
|
||||
'sessions',
|
||||
'forms',
|
||||
'web_check_in',
|
||||
'attendance',
|
||||
'participants',
|
||||
'exhibitors',
|
||||
'photo_wall',
|
||||
'user_management',
|
||||
'settings',
|
||||
'billing',
|
||||
];
|
||||
|
||||
const SYSTEM_ROLE_PERMISSIONS = {
|
||||
super_admin: [
|
||||
|
|
@ -28,6 +41,7 @@ const SYSTEM_ROLE_PERMISSIONS = {
|
|||
perm('attendance', ['read', 'write', 'delete'], 'all'),
|
||||
perm('participants', ['read', 'write', 'delete'], 'all'),
|
||||
perm('exhibitors', ['read', 'write', 'delete'], 'all'),
|
||||
perm('photo_wall', ['read', 'write', 'delete'], 'all'),
|
||||
perm('user_management', ['read', 'write', 'delete'], 'all'),
|
||||
perm('settings', ['read', 'write', 'delete'], 'all'),
|
||||
perm('billing', ['read', 'write'], 'all'),
|
||||
|
|
@ -44,6 +58,7 @@ const SYSTEM_ROLE_PERMISSIONS = {
|
|||
perm('attendance', ['read', 'write', 'delete'], 'all'),
|
||||
perm('participants', ['read', 'write', 'delete'], 'all'),
|
||||
perm('exhibitors', ['read', 'write', 'delete'], 'all'),
|
||||
perm('photo_wall', ['read', 'write', 'delete'], 'all'),
|
||||
perm('user_management', ['read', 'write'], 'all'),
|
||||
perm('settings', ['read', 'write'], 'all'),
|
||||
perm('billing', ['read', 'write'], 'all'),
|
||||
|
|
@ -60,6 +75,7 @@ const SYSTEM_ROLE_PERMISSIONS = {
|
|||
perm('attendance', ['read', 'write'], 'assigned'),
|
||||
perm('participants', ['read', 'write'], 'all'),
|
||||
perm('exhibitors', ['read', 'write'], 'assigned'),
|
||||
perm('photo_wall', ['read', 'write'], 'assigned'),
|
||||
...allPlanModules(['read', 'write'], 'all').filter(
|
||||
(p) => !CORE_EXPLICIT.includes(p.module),
|
||||
),
|
||||
|
|
@ -73,6 +89,7 @@ const SYSTEM_ROLE_PERMISSIONS = {
|
|||
perm('attendance', ['read'], 'assigned'),
|
||||
perm('participants', ['read'], 'all'),
|
||||
perm('exhibitors', ['read'], 'assigned'),
|
||||
perm('photo_wall', ['read'], 'assigned'),
|
||||
...allPlanModules(['read'], 'all').filter(
|
||||
(p) => !CORE_EXPLICIT.includes(p.module),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -40,8 +40,27 @@ const {
|
|||
const { buildParticipantQr } = require('../utils/webCheckInQr');
|
||||
|
||||
|
||||
/**
|
||||
* Mongoose Map / MongoDB document paths reject `.` and `$` in keys.
|
||||
* Feedback forms often use labels like "1. Full Name" or "Session I: …" as keys.
|
||||
* We store with fullwidth substitutes, then restore on read for UI/export lookups.
|
||||
*/
|
||||
function sanitizeMongoMapKey(key) {
|
||||
return String(key ?? '')
|
||||
.replace(/\./g, '\uFF0E') // fullwidth . — keeps "1. Name" readable
|
||||
.replace(/\$/g, '\uFF04') // fullwidth $
|
||||
.replace(/\0/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function unsanitizeMongoMapKey(key) {
|
||||
return String(key ?? '')
|
||||
.replace(/\uFF0E/g, '.')
|
||||
.replace(/\uFF04/g, '$');
|
||||
}
|
||||
|
||||
function safeFormDataForMongo(val) {
|
||||
// Ensure we never pass objects with "$..." keys into a Mongoose Map.
|
||||
// Ensure we never pass objects with "$..." keys or dotted keys into a Mongoose Map.
|
||||
// Some upstream code can accidentally include mongoose internals like "$__deferred".
|
||||
const strip = (x) => {
|
||||
if (x == null) return x;
|
||||
|
|
@ -54,8 +73,16 @@ function safeFormDataForMongo(val) {
|
|||
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(x)) {
|
||||
if (k.startsWith('$')) continue;
|
||||
out[k] = strip(v);
|
||||
// Drop mongoose / operator internals (ASCII $ prefix only).
|
||||
if (typeof k === 'string' && k.startsWith('$')) continue;
|
||||
const key = sanitizeMongoMapKey(k);
|
||||
if (!key) continue;
|
||||
let unique = key;
|
||||
let n = 2;
|
||||
while (Object.prototype.hasOwnProperty.call(out, unique)) {
|
||||
unique = `${key}_${n++}`;
|
||||
}
|
||||
out[unique] = strip(v);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
|
@ -71,7 +98,7 @@ function mapToPlain(obj) {
|
|||
|
||||
function safeMergeSubmissionData(submissionDoc, extra) {
|
||||
const current = mapToPlain(submissionDoc?.data);
|
||||
return { ...current, ...extra };
|
||||
return safeFormDataForMongo({ ...current, ...extra });
|
||||
}
|
||||
|
||||
function throwRegError(message, code) {
|
||||
|
|
@ -850,8 +877,34 @@ const submit = async (request, h) => {
|
|||
|
||||
function dataMapToObject(data) {
|
||||
if (!data) return {};
|
||||
if (data instanceof Map) return Object.fromEntries(data);
|
||||
return typeof data === 'object' ? { ...data } : {};
|
||||
const raw =
|
||||
data instanceof Map
|
||||
? Object.fromEntries(data)
|
||||
: typeof data === 'object'
|
||||
? { ...data }
|
||||
: {};
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(raw)) {
|
||||
// Prefer restored ASCII keys so UI/export can match form field labels/names.
|
||||
const restored = unsanitizeMongoMapKey(k);
|
||||
if (out[restored] === undefined) out[restored] = v;
|
||||
// Keep original too (covers any consumer still using sanitized keys).
|
||||
if (restored !== k && out[k] === undefined) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Resolve a field value from submission data, tolerating sanitized Map keys. */
|
||||
function getSubmissionFieldValue(dataObj, key) {
|
||||
if (!key) return '';
|
||||
if (dataObj[key] != null && dataObj[key] !== '') return dataObj[key];
|
||||
const sanitized = sanitizeMongoMapKey(key);
|
||||
if (dataObj[sanitized] != null && dataObj[sanitized] !== '') return dataObj[sanitized];
|
||||
const want = String(key).replace(/\s+/g, ' ').trim();
|
||||
for (const [k, v] of Object.entries(dataObj)) {
|
||||
if (String(k).replace(/\s+/g, ' ').trim() === want && v != null && v !== '') return v;
|
||||
}
|
||||
return dataObj[key] ?? dataObj[sanitized] ?? '';
|
||||
}
|
||||
|
||||
function submissionSearchBlob(sub) {
|
||||
|
|
@ -924,7 +977,7 @@ function submissionsToCsv(form, rows) {
|
|||
for (const s of rows) {
|
||||
const o = dataMapToObject(s.data);
|
||||
const email = s.participant && typeof s.participant === 'object' ? s.participant.email || '' : '';
|
||||
const vals = cols.map((c) => o[c.key] ?? '');
|
||||
const vals = cols.map((c) => getSubmissionFieldValue(o, c.key));
|
||||
const row = [new Date(s.submittedAt).toISOString(), email, ...vals];
|
||||
lines.push(row.map(escapeCsvCell).join(','));
|
||||
}
|
||||
|
|
@ -976,7 +1029,11 @@ const listSubmissions = async (request, h) => {
|
|||
const limit = Math.min(100, Math.max(1, parseInt(String(limitStr || '20'), 10) || 20));
|
||||
const total = all.length;
|
||||
const skip = (page - 1) * limit;
|
||||
const submissions = all.slice(skip, skip + limit);
|
||||
const submissions = all.slice(skip, skip + limit).map((s) => ({
|
||||
...s,
|
||||
// Restore ASCII `.` / `$` in keys so the console can match field labels.
|
||||
data: dataMapToObject(s.data),
|
||||
}));
|
||||
return h.response(success({ submissions, total, page, limit }));
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
const QuizPoll = require('../models/QuizPoll');
|
||||
const QuizPollResponse = require('../models/QuizPollResponse');
|
||||
const { success, error } = require('../utils/response');
|
||||
const { buildQuestionPresentationStats } = require('../utils/quizPollResultsSummary');
|
||||
const {
|
||||
buildQuestionPresentationStats,
|
||||
buildQuizPresentationLeaderboard,
|
||||
} = require('../utils/quizPollResultsSummary');
|
||||
const {
|
||||
applyPublish,
|
||||
applyClose,
|
||||
|
|
@ -9,7 +12,18 @@ const {
|
|||
} = require('../utils/quizPollLiveControls');
|
||||
|
||||
function presentationPublicBase() {
|
||||
return (process.env.EVENTBUZZ_PUBLIC_URL || 'http://localhost:5174').replace(/\/$/, '');
|
||||
const raw = (process.env.EVENTBUZZ_PUBLIC_URL || 'http://localhost:5174').replace(/\/$/, '');
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
// Marketing apex is not the participant SPA — rewrite to join host.
|
||||
if (u.hostname === 'eventbuzz.live' || u.hostname === 'www.eventbuzz.live') {
|
||||
u.hostname = 'join.eventbuzz.live';
|
||||
return u.origin;
|
||||
}
|
||||
} catch {
|
||||
/* keep raw */
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function mapQuestionForPresentation(q) {
|
||||
|
|
@ -37,6 +51,7 @@ function mapSettingsForPresentation(settings) {
|
|||
? String(s.liveQuestionId).trim()
|
||||
: null,
|
||||
showCorrectAnswer: Boolean(s.showCorrectAnswer),
|
||||
showLeaderboard: Boolean(s.showLeaderboard),
|
||||
showResults: s.showResults !== false,
|
||||
};
|
||||
}
|
||||
|
|
@ -104,6 +119,24 @@ async function getQuestionStats(request, h) {
|
|||
return h.response(success(stats));
|
||||
}
|
||||
|
||||
/** GET /api/public/quiz-polls/present/{token}/leaderboard */
|
||||
async function getLeaderboard(request, h) {
|
||||
const quizPoll = await loadQuizPollByPresentationToken(request.params.token);
|
||||
if (!quizPoll) return h.response(error('NOT_FOUND', 'Presentation not found')).code(404);
|
||||
|
||||
const quizType = String(quizPoll.type || '').toLowerCase();
|
||||
if (quizType !== 'quiz') {
|
||||
return h.response(error('VALIDATION_ERROR', 'Leaderboard is only available for quizzes')).code(400);
|
||||
}
|
||||
|
||||
const allResponses = await QuizPollResponse.find({ quizPoll: quizPoll._id })
|
||||
.populate('participant', 'displayName firstName lastName')
|
||||
.lean();
|
||||
|
||||
const leaderboard = buildQuizPresentationLeaderboard(quizPoll, allResponses);
|
||||
return h.response(success(leaderboard));
|
||||
}
|
||||
|
||||
/** POST /api/public/quiz-polls/present/{token}/publish */
|
||||
async function publishPresentation(request, h) {
|
||||
const item = await loadQuizPollDocByPresentationToken(request.params.token);
|
||||
|
|
@ -174,6 +207,7 @@ async function getSessionActivities(request, h) {
|
|||
module.exports = {
|
||||
getPresentation,
|
||||
getQuestionStats,
|
||||
getLeaderboard,
|
||||
publishPresentation,
|
||||
closePresentation,
|
||||
setPresentationLiveQuestion,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,18 @@ const {
|
|||
} = require('../utils/quizPollLiveControls');
|
||||
|
||||
function presentationPublicBase() {
|
||||
return (process.env.EVENTBUZZ_PUBLIC_URL || 'http://localhost:5174').replace(/\/$/, '');
|
||||
const raw = (process.env.EVENTBUZZ_PUBLIC_URL || 'http://localhost:5174').replace(/\/$/, '');
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
// Marketing apex is not the participant SPA — rewrite to join host.
|
||||
if (u.hostname === 'eventbuzz.live' || u.hostname === 'www.eventbuzz.live') {
|
||||
u.hostname = 'join.eventbuzz.live';
|
||||
return u.origin;
|
||||
}
|
||||
} catch {
|
||||
/* keep raw */
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function assertQuizPollAccess(request, h, item) {
|
||||
|
|
@ -136,6 +147,29 @@ const setShowCorrect = async (request, h) => {
|
|||
return h.response(success(item, item.settings.showCorrectAnswer ? 'Correct answer shown' : 'Correct answer hidden'));
|
||||
};
|
||||
|
||||
const setShowLeaderboard = async (request, h) => {
|
||||
const item = await QuizPoll.findOne({ _id: request.params.id, deletedAt: null });
|
||||
if (!item) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404);
|
||||
const denied = await assertQuizPollAccess(request, h, item);
|
||||
if (denied) return denied;
|
||||
if (displayModeOf(item) !== 'quiz_master') {
|
||||
return h
|
||||
.response(error('VALIDATION_ERROR', 'Show leaderboard is only available in Quiz Master mode'))
|
||||
.code(400);
|
||||
}
|
||||
if (String(item.type || '').toLowerCase() !== 'quiz') {
|
||||
return h.response(error('VALIDATION_ERROR', 'Leaderboard is only available for quizzes')).code(400);
|
||||
}
|
||||
|
||||
item.settings = item.settings || {};
|
||||
item.settings.showLeaderboard = Boolean(request.payload?.show);
|
||||
await item.save();
|
||||
await syncQuizPollToFirebase(item);
|
||||
return h.response(
|
||||
success(item, item.settings.showLeaderboard ? 'Leaderboard shown' : 'Leaderboard hidden')
|
||||
);
|
||||
};
|
||||
|
||||
const ensurePresentationToken = async (request, h) => {
|
||||
const item = await QuizPoll.findOne({ _id: request.params.id, deletedAt: null });
|
||||
if (!item) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404);
|
||||
|
|
@ -197,6 +231,6 @@ const sessionQuizPolls = async (request, h) => {
|
|||
};
|
||||
|
||||
module.exports = {
|
||||
list, create, get, update, remove, publish, close, setLiveQuestion, setShowCorrect, ensurePresentationToken, results,
|
||||
submitResponse, listResponses, sessionQuizPolls,
|
||||
list, create, get, update, remove, publish, close, setLiveQuestion, setShowCorrect, setShowLeaderboard,
|
||||
ensurePresentationToken, results, submitResponse, listResponses, sessionQuizPolls,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const {
|
|||
KEY_PERSON_ROLES,
|
||||
DEFAULT_KEY_PERSON_ROLE,
|
||||
} = require('../config/constants');
|
||||
const { setSessionPresentFocus } = require('../utils/sessionPresentationFirebaseSync');
|
||||
|
||||
const KEY_PERSON_ROLE_SET = new Set(KEY_PERSON_ROLES);
|
||||
|
||||
|
|
@ -212,4 +213,18 @@ const updateStatus = async (request, h) => {
|
|||
return h.response(success(session));
|
||||
};
|
||||
|
||||
module.exports = { list, create, get, update, remove, updateStatus };
|
||||
/** POST /api/sessions/{id}/present-questions — projector switches to Questions for this session. */
|
||||
const presentQuestions = async (request, h) => {
|
||||
const session = await Session.findById(request.params.id);
|
||||
if (!session) return h.response(error('NOT_FOUND', 'Session not found')).code(404);
|
||||
const user = request.auth.credentials.user;
|
||||
const orgId = userOrgId(user);
|
||||
await assertModulePermission(user, orgId, 'sessions', 'write');
|
||||
if (!(await canAccessSession(user, orgId, session._id, 'write'))) {
|
||||
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
|
||||
}
|
||||
await setSessionPresentFocus(session._id, 'questions');
|
||||
return h.response(success({ focus: 'questions' }, 'Present screen switched to Questions'));
|
||||
};
|
||||
|
||||
module.exports = { list, create, get, update, remove, updateStatus, presentQuestions };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -56,6 +56,8 @@ const quizPollSchema = new mongoose.Schema(
|
|||
liveQuestionId: { type: String, default: null },
|
||||
/** quiz_master: projector page highlights correct answer(s) for the live question */
|
||||
showCorrectAnswer: { type: Boolean, default: false },
|
||||
/** quiz_master: projector page switches to the Leaderboard tab */
|
||||
showLeaderboard: { type: Boolean, default: false },
|
||||
/** Unguessable token for login-free presentation / projector page */
|
||||
presentationToken: { type: String, trim: true },
|
||||
rewardRules: rewardRulesSubSchema,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -16,6 +16,11 @@ module.exports = [
|
|||
path: '/api/public/quiz-polls/present/{token}/questions/{questionIndex}/stats',
|
||||
options: { auth: false, handler: controller.getQuestionStats },
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/public/quiz-polls/present/{token}/leaderboard',
|
||||
options: { auth: false, handler: controller.getLeaderboard },
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/public/quiz-polls/present/{token}/session-activities',
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ const quizPollPayload = Joi.object({
|
|||
timeLimit: Joi.number().integer().min(0),
|
||||
liveQuestionId: Joi.string().allow('', null),
|
||||
showCorrectAnswer: Joi.boolean(),
|
||||
showLeaderboard: Joi.boolean(),
|
||||
presentationToken: Joi.string().allow('', null),
|
||||
rewardRules: rewardRulesPayload,
|
||||
}),
|
||||
|
|
@ -73,6 +74,10 @@ const showCorrectPayload = Joi.object({
|
|||
show: Joi.boolean().required(),
|
||||
});
|
||||
|
||||
const showLeaderboardPayload = Joi.object({
|
||||
show: Joi.boolean().required(),
|
||||
});
|
||||
|
||||
module.exports = [
|
||||
{ method: 'GET', path: '/api/events/{eventId}/quiz-polls', options: { handler: controller.list } },
|
||||
{ method: 'POST', path: '/api/events/{eventId}/quiz-polls', options: { validate: { payload: quizPollPayload }, handler: controller.create } },
|
||||
|
|
@ -83,6 +88,7 @@ module.exports = [
|
|||
{ method: 'POST', path: '/api/quiz-polls/{id}/close', options: { handler: controller.close } },
|
||||
{ method: 'POST', path: '/api/quiz-polls/{id}/live-question', options: { validate: { payload: liveQuestionPayload }, handler: controller.setLiveQuestion } },
|
||||
{ method: 'POST', path: '/api/quiz-polls/{id}/show-correct', options: { validate: { payload: showCorrectPayload }, handler: controller.setShowCorrect } },
|
||||
{ method: 'POST', path: '/api/quiz-polls/{id}/show-leaderboard', options: { validate: { payload: showLeaderboardPayload }, handler: controller.setShowLeaderboard } },
|
||||
{ method: 'POST', path: '/api/quiz-polls/{id}/presentation-token', options: { handler: controller.ensurePresentationToken } },
|
||||
{ method: 'GET', path: '/api/quiz-polls/{id}/results', options: { handler: controller.results } },
|
||||
{ method: 'POST', path: '/api/quiz-polls/{quizPollId}/responses', options: { validate: { payload: responsePayload }, handler: controller.submitResponse } },
|
||||
|
|
|
|||
|
|
@ -69,4 +69,9 @@ module.exports = [
|
|||
handler: controller.updateStatus,
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/sessions/{id}/present-questions',
|
||||
options: { handler: controller.presentQuestions },
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
to,
|
||||
requesterEmail,
|
||||
|
|
@ -444,5 +498,6 @@ module.exports = {
|
|||
sendVisitorOtpEmail,
|
||||
sendVisitorMagicLinkEmail,
|
||||
sendMicrositeContactEmail,
|
||||
sendWebsiteDemoRequestEmail,
|
||||
sendContactSalesLeadEmail,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -39,6 +39,7 @@ function normalizeSettings(settings) {
|
|||
s.liveQuestionId = null;
|
||||
}
|
||||
s.showCorrectAnswer = s.showCorrectAnswer === true || s.showCorrectAnswer === 'true';
|
||||
s.showLeaderboard = s.showLeaderboard === true || s.showLeaderboard === 'true';
|
||||
return s;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
const QuizPoll = require('../models/QuizPoll');
|
||||
const { syncQuizPollToFirebase } = require('./quizPollFirebaseSync');
|
||||
const { clearSessionPresentFocus } = require('./sessionPresentationFirebaseSync');
|
||||
|
||||
const MANUAL_OR_QUIZ_MASTER = new Set(['manual', 'quiz_master']);
|
||||
|
||||
|
|
@ -63,6 +64,7 @@ async function applyPublish(item) {
|
|||
item.settings.isLive = true;
|
||||
await item.save();
|
||||
await syncQuizPollToFirebase(item);
|
||||
if (item.session) await clearSessionPresentFocus(item.session);
|
||||
await offlineSiblingManualAndQuizMaster(item);
|
||||
return { ok: true, item, message: 'Published' };
|
||||
}
|
||||
|
|
@ -75,6 +77,7 @@ async function applyClose(item) {
|
|||
item.settings.isLive = false;
|
||||
item.settings.liveQuestionId = null;
|
||||
item.settings.showCorrectAnswer = false;
|
||||
item.settings.showLeaderboard = false;
|
||||
await item.save();
|
||||
await syncQuizPollToFirebase(item);
|
||||
return { ok: true, item, message: 'Closed' };
|
||||
|
|
@ -99,6 +102,7 @@ async function applySetLiveQuestion(item, rawQuestionId) {
|
|||
if (rawQuestionId == null || String(rawQuestionId).trim() === '') {
|
||||
item.settings.liveQuestionId = null;
|
||||
item.settings.showCorrectAnswer = false;
|
||||
item.settings.showLeaderboard = false;
|
||||
await item.save();
|
||||
await syncQuizPollToFirebase(item);
|
||||
return { ok: true, item, message: 'Live question cleared' };
|
||||
|
|
@ -122,6 +126,7 @@ async function applySetLiveQuestion(item, rawQuestionId) {
|
|||
item.settings.isLive = true;
|
||||
await item.save();
|
||||
await syncQuizPollToFirebase(item);
|
||||
if (item.session) await clearSessionPresentFocus(item.session);
|
||||
await offlineSiblingManualAndQuizMaster(item);
|
||||
return { ok: true, item, message: 'Question is now live' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -329,9 +329,64 @@ function buildQuestionPresentationStats(quizPoll, allResponses, questionIndex) {
|
|||
return { ...base, kind: 'single', options: opts };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-quiz leaderboard for presentation mode.
|
||||
* Rank: most correct answers, then fastest total time.
|
||||
* @param {object} quizPoll lean doc
|
||||
* @param {object[]} allResponses populated with participant
|
||||
*/
|
||||
function buildQuizPresentationLeaderboard(quizPoll, allResponses) {
|
||||
const questions = quizPoll.questions || [];
|
||||
const totalQuestions = questions.length;
|
||||
const rows = [];
|
||||
|
||||
for (const doc of allResponses) {
|
||||
const items = Array.isArray(doc.responses) ? doc.responses : [];
|
||||
let correctCount = 0;
|
||||
let timeFromItems = 0;
|
||||
for (const item of items) {
|
||||
if (item && item.isCorrect === true) correctCount += 1;
|
||||
timeFromItems += Math.max(0, Number(item?.responseTimeSec) || 0);
|
||||
}
|
||||
const timeSpentDoc = Math.max(0, Math.floor(Number(doc.timeSpent) || 0));
|
||||
const timeTakenSec = timeFromItems > 0 ? Math.floor(timeFromItems) : timeSpentDoc;
|
||||
|
||||
rows.push({
|
||||
participantId: doc.participant != null ? String(doc.participant._id || doc.participant) : '',
|
||||
displayName: participantDisplayName(doc.participant),
|
||||
correctCount,
|
||||
totalQuestions,
|
||||
timeTakenSec,
|
||||
totalScore: Number(doc.totalScore) || 0,
|
||||
});
|
||||
}
|
||||
|
||||
rows.sort((a, b) => {
|
||||
if (b.correctCount !== a.correctCount) return b.correctCount - a.correctCount;
|
||||
const aTime = a.timeTakenSec > 0 ? a.timeTakenSec : Number.POSITIVE_INFINITY;
|
||||
const bTime = b.timeTakenSec > 0 ? b.timeTakenSec : Number.POSITIVE_INFINITY;
|
||||
if (aTime !== bTime) return aTime - bTime;
|
||||
return String(a.displayName).localeCompare(String(b.displayName));
|
||||
});
|
||||
|
||||
return {
|
||||
totalQuestions,
|
||||
entries: rows.map((row, idx) => ({
|
||||
rank: idx + 1,
|
||||
participantId: row.participantId,
|
||||
displayName: row.displayName,
|
||||
correctCount: row.correctCount,
|
||||
totalQuestions: row.totalQuestions,
|
||||
timeTakenSec: row.timeTakenSec,
|
||||
totalScore: row.totalScore,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normAnswerVal,
|
||||
participantDisplayName,
|
||||
buildVisitorPollResultsSummary,
|
||||
buildQuestionPresentationStats,
|
||||
buildQuizPresentationLeaderboard,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,18 +13,53 @@ function sanitizeKeySegment(segment) {
|
|||
|
||||
/**
|
||||
* Build S3 key for event photos under eventbuzz-pictures bucket.
|
||||
* Pattern: {eventId}/{filename}
|
||||
* Pattern: {eventId}/{filename} or {eventId}/{subdir}/{filename}
|
||||
*/
|
||||
function buildEventPhotoKey({ eventId, filename }) {
|
||||
function buildEventPhotoKey({ eventId, filename, subdir }) {
|
||||
const safeEventId = sanitizeKeySegment(eventId || 'unknown-event');
|
||||
const safeFilename = sanitizeKeySegment(filename || 'file');
|
||||
const safeSubdir = subdir ? sanitizeKeySegment(subdir) : '';
|
||||
if (safeSubdir) return `${safeEventId}/${safeSubdir}/${safeFilename}`;
|
||||
return `${safeEventId}/${safeFilename}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Photo wall keys (pictures bucket):
|
||||
* - original: photo_wall/{eventId}/{filename}
|
||||
* - framed: photo_wall/{eventId}/frame/{filename}
|
||||
*/
|
||||
function buildPhotoWallKey({ eventId, filename, framed = false }) {
|
||||
const safeEventId = sanitizeKeySegment(eventId || 'unknown-event');
|
||||
const safeFilename = sanitizeKeySegment(filename || 'file');
|
||||
if (framed) return `photo_wall/${safeEventId}/frame/${safeFilename}`;
|
||||
return `photo_wall/${safeEventId}/${safeFilename}`;
|
||||
}
|
||||
|
||||
function picturesCloudfrontBase() {
|
||||
// Pictures CDN (eventbuzz-pictures). Do not fall back to AWS_URL (rapportapp media CDN).
|
||||
return getEnv('AWS_URL_PICTURES', 'https://d155t9or8i84n8.cloudfront.net').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute CloudFront URL for a pictures-bucket key.
|
||||
* Accepts a bare key or an already-absolute URL (returned unchanged).
|
||||
*/
|
||||
function cloudfrontUrlForKey(key) {
|
||||
const base = getEnv('AWS_URL_PICTURES', '').replace(/\/+$/, '');
|
||||
if (!base) return key;
|
||||
return `${base}/${key}`;
|
||||
const raw = String(key || '').trim();
|
||||
if (!raw) return '';
|
||||
if (/^https?:\/\//i.test(raw)) return raw;
|
||||
const base = picturesCloudfrontBase();
|
||||
if (!base) {
|
||||
console.warn('[s3EventPhotos] AWS_URL_PICTURES is not set — returning S3 key as url');
|
||||
return raw;
|
||||
}
|
||||
return `${base}/${raw.replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
/** Normalize stored url/framedUrl that may be a bare key (legacy / misconfigured env). */
|
||||
function resolvePhotoPublicUrl(urlOrKey) {
|
||||
if (urlOrKey == null || String(urlOrKey).trim() === '') return null;
|
||||
return cloudfrontUrlForKey(urlOrKey);
|
||||
}
|
||||
|
||||
let cachedS3Client = null;
|
||||
|
|
@ -46,12 +81,34 @@ function getS3Client() {
|
|||
/**
|
||||
* Upload a photo buffer to the eventbuzz-pictures S3 bucket.
|
||||
* Returns { s3Key, url }.
|
||||
* Optional `subdir` nests the key (e.g. photo-wall → {eventId}/photo-wall/{filename}).
|
||||
*/
|
||||
async function uploadEventPhotoToS3({ buffer, contentType, eventId, filename }) {
|
||||
async function uploadEventPhotoToS3({ buffer, contentType, eventId, filename, subdir }) {
|
||||
const bucket = getEnv('AWS_EVENT_PICTURES_BUCKET', 'eventbuzz-pictures');
|
||||
if (!bucket) throw new Error('AWS_EVENT_PICTURES_BUCKET is not configured');
|
||||
|
||||
const key = buildEventPhotoKey({ eventId, filename });
|
||||
const key = buildEventPhotoKey({ eventId, filename, subdir });
|
||||
|
||||
await getS3Client().send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: contentType || 'application/octet-stream',
|
||||
}),
|
||||
);
|
||||
|
||||
return { s3Key: key, url: cloudfrontUrlForKey(key) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a photo-wall image (original or framed composite) to the pictures bucket.
|
||||
*/
|
||||
async function uploadPhotoWallPhotoToS3({ buffer, contentType, eventId, filename, framed = false }) {
|
||||
const bucket = getEnv('AWS_EVENT_PICTURES_BUCKET', 'eventbuzz-pictures');
|
||||
if (!bucket) throw new Error('AWS_EVENT_PICTURES_BUCKET is not configured');
|
||||
|
||||
const key = buildPhotoWallKey({ eventId, filename, framed });
|
||||
|
||||
await getS3Client().send(
|
||||
new PutObjectCommand({
|
||||
|
|
@ -104,8 +161,12 @@ async function getEventPhotoBufferFromS3(s3Key) {
|
|||
|
||||
module.exports = {
|
||||
uploadEventPhotoToS3,
|
||||
uploadPhotoWallPhotoToS3,
|
||||
deleteEventPhotoFromS3,
|
||||
getEventPhotoBufferFromS3,
|
||||
buildEventPhotoKey,
|
||||
buildPhotoWallKey,
|
||||
cloudfrontUrlForKey,
|
||||
resolvePhotoPublicUrl,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
Loading…
Reference in New Issue