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