From e60340d2158f500ef8fea773af7432da02fd19bc Mon Sep 17 00:00:00 2001
From: AZ0228 <53315675+AZ0228@users.noreply.github.com>
Date: Thu, 9 Jul 2026 16:13:19 -0700
Subject: [PATCH] adding poster tech, ungating purge from prod
---
backend/package-lock.json | 570 +++++++++++++++++-
backend/package.json | 1 +
backend/routes/pivotAdminRoutes.js | 1 +
backend/routes/platformTenantRoutes.js | 112 ++++
backend/schemas/pivotPosterTemplate.js | 75 +++
backend/services/getGlobalModelService.js | 8 +-
backend/services/pivotCatalogPurgeService.js | 59 +-
.../services/pivotPosterTemplateService.js | 354 +++++++++++
backend/services/pivotTagSuggestService.js | 1 +
.../unit/pivotCatalogPurgeService.test.js | 67 +-
.../src/pages/InviteLanding/InviteLanding.jsx | 12 +-
.../pages/InviteLanding/InviteLanding.scss | 20 +
.../PlatformAdmin/PivotLab/PivotLabPage.jsx | 126 ++--
.../PivotInviteQRModal.jsx | 159 +++++
.../PivotInviteQRModal.scss | 141 +++++
.../PivotPosterDownloadModal.jsx | 160 +++++
.../PivotPosterDownloadModal.scss | 157 +++++
.../PivotPosterQrBoxEditor.jsx | 105 ++++
.../PivotPosterTemplatesModal.jsx | 322 ++++++++++
.../PivotPosterTemplatesModal.scss | 250 ++++++++
.../PivotReferralCodesPanel.jsx | 109 +++-
.../PivotReferralCodesPanel.scss | 8 +
22 files changed, 2704 insertions(+), 113 deletions(-)
create mode 100644 backend/schemas/pivotPosterTemplate.js
create mode 100644 backend/services/pivotPosterTemplateService.js
create mode 100644 frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotInviteQRModal.jsx
create mode 100644 frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotInviteQRModal.scss
create mode 100644 frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotPosterDownloadModal.jsx
create mode 100644 frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotPosterDownloadModal.scss
create mode 100644 frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotPosterQrBoxEditor.jsx
create mode 100644 frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotPosterTemplatesModal.jsx
create mode 100644 frontend/src/pages/PlatformAdmin/TenantManagement/PivotReferralCodesPanel/PivotPosterTemplatesModal.scss
diff --git a/backend/package-lock.json b/backend/package-lock.json
index 08418ff7..4af400b3 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -42,6 +42,7 @@
"resend": "^4.0.1",
"saml2": "^0.0.8",
"samlify": "^2.10.0",
+ "sharp": "^0.35.3",
"socket.io": "^4.7.5",
"ws": "^8.18.0",
"xml-crypto": "^6.1.2",
@@ -669,10 +670,9 @@
}
},
"node_modules/@emnapi/runtime": {
- "version": "1.9.0",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
- "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
- "dev": true,
+ "version": "1.11.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
+ "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -727,6 +727,506 @@
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
"license": "MIT"
},
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
+ "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
+ "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-freebsd-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
+ "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
+ "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
+ "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
+ "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
+ "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
+ "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
+ "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
+ "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
+ "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
+ "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
+ "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
+ "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
+ "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
+ "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
+ "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
+ "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
+ "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
+ "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
+ "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
+ "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.11.1"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-webcontainers-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
+ "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
+ "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
+ "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
+ "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
"node_modules/@inngest/ai": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@inngest/ai/-/ai-0.1.6.tgz",
@@ -5327,9 +5827,10 @@
}
},
"node_modules/detect-libc": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz",
- "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==",
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
"engines": {
"node": ">=8"
}
@@ -10453,9 +10954,9 @@
}
},
"node_modules/semver": {
- "version": "7.7.4",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
- "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -10636,6 +11137,55 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
+ "node_modules/sharp": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
+ "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.1.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.8.5"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.35.3",
+ "@img/sharp-darwin-x64": "0.35.3",
+ "@img/sharp-freebsd-wasm32": "0.35.3",
+ "@img/sharp-libvips-darwin-arm64": "1.3.2",
+ "@img/sharp-libvips-darwin-x64": "1.3.2",
+ "@img/sharp-libvips-linux-arm": "1.3.2",
+ "@img/sharp-libvips-linux-arm64": "1.3.2",
+ "@img/sharp-libvips-linux-ppc64": "1.3.2",
+ "@img/sharp-libvips-linux-riscv64": "1.3.2",
+ "@img/sharp-libvips-linux-s390x": "1.3.2",
+ "@img/sharp-libvips-linux-x64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2",
+ "@img/sharp-linux-arm": "0.35.3",
+ "@img/sharp-linux-arm64": "0.35.3",
+ "@img/sharp-linux-ppc64": "0.35.3",
+ "@img/sharp-linux-riscv64": "0.35.3",
+ "@img/sharp-linux-s390x": "0.35.3",
+ "@img/sharp-linux-x64": "0.35.3",
+ "@img/sharp-linuxmusl-arm64": "0.35.3",
+ "@img/sharp-linuxmusl-x64": "0.35.3",
+ "@img/sharp-webcontainers-wasm32": "0.35.3",
+ "@img/sharp-win32-arm64": "0.35.3",
+ "@img/sharp-win32-ia32": "0.35.3",
+ "@img/sharp-win32-x64": "0.35.3"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
diff --git a/backend/package.json b/backend/package.json
index fbd2678e..ea8a3e66 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -33,6 +33,7 @@
"resend": "^4.0.1",
"saml2": "^0.0.8",
"samlify": "^2.10.0",
+ "sharp": "^0.35.3",
"socket.io": "^4.7.5",
"ws": "^8.18.0",
"xml-crypto": "^6.1.2",
diff --git a/backend/routes/pivotAdminRoutes.js b/backend/routes/pivotAdminRoutes.js
index 8d92291f..b08e424a 100644
--- a/backend/routes/pivotAdminRoutes.js
+++ b/backend/routes/pivotAdminRoutes.js
@@ -487,6 +487,7 @@ router.post('/dev/purge-catalog', verifyToken, requirePlatformAdmin, async (req,
tenantKey: req.body?.tenantKey,
confirm: req.body?.confirm,
clearSnapshots: req.body?.clearSnapshots,
+ batchWeek: req.body?.batchWeek,
});
if (result.error) {
return res.status(result.status || 400).json({
diff --git a/backend/routes/platformTenantRoutes.js b/backend/routes/platformTenantRoutes.js
index 8c711f5d..b1a36a7d 100644
--- a/backend/routes/platformTenantRoutes.js
+++ b/backend/routes/platformTenantRoutes.js
@@ -27,6 +27,14 @@ const {
updateReferralCode,
deleteReferralCode,
} = require('../services/pivotReferralCodeService');
+const {
+ listPosterTemplates,
+ createPosterTemplate,
+ updatePosterTemplate,
+ deletePosterTemplate,
+ renderPoster,
+} = require('../services/pivotPosterTemplateService');
+const { upload } = require('../services/imageUploadService');
const router = express.Router();
@@ -340,6 +348,110 @@ router.delete(
}
);
+router.get(
+ '/admin/platform/tenants/:tenantKey/pivot-poster-templates',
+ verifyToken,
+ requirePlatformAdmin,
+ async (req, res) => {
+ try {
+ const tenantKey = String(req.params.tenantKey || '').trim().toLowerCase();
+ const result = await listPosterTemplates(req, tenantKey);
+ if (result.error) {
+ return res.status(result.status || 400).json({ success: false, message: result.error });
+ }
+ res.json({ success: true, data: result });
+ } catch (err) {
+ console.error('GET pivot-poster-templates failed:', err);
+ res.status(500).json({ success: false, message: err.message });
+ }
+ }
+);
+
+router.post(
+ '/admin/platform/tenants/:tenantKey/pivot-poster-templates',
+ verifyToken,
+ requirePlatformAdmin,
+ upload.single('poster'),
+ async (req, res) => {
+ try {
+ const tenantKey = String(req.params.tenantKey || '').trim().toLowerCase();
+ const result = await createPosterTemplate(req, tenantKey, req.file, req.body);
+ if (result.error) {
+ return res.status(result.status || 400).json({ success: false, message: result.error });
+ }
+ res.status(201).json({ success: true, data: result });
+ } catch (err) {
+ console.error('POST pivot-poster-templates failed:', err);
+ res.status(500).json({ success: false, message: err.message });
+ }
+ }
+);
+
+router.put(
+ '/admin/platform/tenants/:tenantKey/pivot-poster-templates/:id',
+ verifyToken,
+ requirePlatformAdmin,
+ async (req, res) => {
+ try {
+ const tenantKey = String(req.params.tenantKey || '').trim().toLowerCase();
+ const id = String(req.params.id || '').trim();
+ const result = await updatePosterTemplate(req, tenantKey, id, req.body);
+ if (result.error) {
+ return res.status(result.status || 400).json({ success: false, message: result.error });
+ }
+ res.json({ success: true, data: result });
+ } catch (err) {
+ console.error('PUT pivot-poster-templates failed:', err);
+ res.status(500).json({ success: false, message: err.message });
+ }
+ }
+);
+
+router.delete(
+ '/admin/platform/tenants/:tenantKey/pivot-poster-templates/:id',
+ verifyToken,
+ requirePlatformAdmin,
+ async (req, res) => {
+ try {
+ const tenantKey = String(req.params.tenantKey || '').trim().toLowerCase();
+ const id = String(req.params.id || '').trim();
+ const result = await deletePosterTemplate(req, tenantKey, id);
+ if (result.error) {
+ return res.status(result.status || 400).json({ success: false, message: result.error });
+ }
+ res.json({ success: true, data: result });
+ } catch (err) {
+ console.error('DELETE pivot-poster-templates failed:', err);
+ res.status(500).json({ success: false, message: err.message });
+ }
+ }
+);
+
+router.get(
+ '/admin/platform/tenants/:tenantKey/pivot-poster-templates/:id/render',
+ verifyToken,
+ requirePlatformAdmin,
+ async (req, res) => {
+ try {
+ const tenantKey = String(req.params.tenantKey || '').trim().toLowerCase();
+ const id = String(req.params.id || '').trim();
+ const code = String(req.query.code || '').trim();
+ const origin = String(req.query.origin || '').trim();
+ const result = await renderPoster(req, tenantKey, id, code, origin);
+ if (result.error) {
+ return res.status(result.status || 400).json({ success: false, message: result.error });
+ }
+ res.set('Content-Type', 'image/png');
+ res.set('Content-Disposition', `attachment; filename="${result.filename}"`);
+ res.set('Cache-Control', 'no-store');
+ res.send(result.buffer);
+ } catch (err) {
+ console.error('GET pivot-poster-templates render failed:', err);
+ res.status(500).json({ success: false, message: err.message });
+ }
+ }
+);
+
router.post('/admin/platform/tenants/sync-cache', verifyToken, requirePlatformAdmin, async (req, res) => {
try {
const cache = await syncTenantUriCache(req);
diff --git a/backend/schemas/pivotPosterTemplate.js b/backend/schemas/pivotPosterTemplate.js
new file mode 100644
index 00000000..4a3cfa14
--- /dev/null
+++ b/backend/schemas/pivotPosterTemplate.js
@@ -0,0 +1,75 @@
+const mongoose = require('mongoose');
+
+// A poster template is base artwork (stored in S3) plus a normalized square
+// region where a per-code invite QR is stamped at render time. Coordinates are
+// fractions of the poster's natural dimensions (0..1) so they are resolution
+// independent: qrBox.x/y is the top-left of the square, qrBox.w is the side
+// length as a fraction of the poster width (the region is always square in px).
+const qrBoxSchema = new mongoose.Schema(
+ {
+ x: { type: Number, required: true, min: 0, max: 1, default: 0.5 },
+ y: { type: Number, required: true, min: 0, max: 1, default: 0.5 },
+ w: { type: Number, required: true, min: 0.02, max: 1, default: 0.25 },
+ },
+ { _id: false }
+);
+
+const pivotPosterTemplateSchema = new mongoose.Schema(
+ {
+ tenantKey: {
+ type: String,
+ required: true,
+ trim: true,
+ lowercase: true,
+ },
+ name: {
+ type: String,
+ required: true,
+ trim: true,
+ },
+ imageUrl: {
+ type: String,
+ required: true,
+ },
+ // S3 object key (folder/filename) so we can fetch/delete without relying on
+ // the poster being publicly readable.
+ imageKey: {
+ type: String,
+ required: true,
+ },
+ // Natural pixel dimensions of the base artwork (from sharp metadata).
+ width: { type: Number, default: null },
+ height: { type: Number, default: null },
+ qrBox: {
+ type: qrBoxSchema,
+ required: true,
+ default: () => ({ x: 0.5, y: 0.5, w: 0.25 }),
+ },
+ // QR foreground color.
+ qrColor: {
+ type: String,
+ default: '#1A1714',
+ trim: true,
+ },
+ // When true, a rounded white card is drawn behind the QR for scannability on
+ // busy artwork; otherwise the QR is stamped with a transparent background.
+ plate: {
+ type: Boolean,
+ default: true,
+ },
+ },
+ { timestamps: true }
+);
+
+pivotPosterTemplateSchema.pre('validate', function normalizeFields() {
+ if (this.tenantKey) {
+ this.tenantKey = String(this.tenantKey).trim().toLowerCase();
+ }
+ if (this.name) {
+ this.name = String(this.name).trim();
+ }
+});
+
+pivotPosterTemplateSchema.index({ tenantKey: 1, createdAt: -1 });
+
+module.exports = pivotPosterTemplateSchema;
diff --git a/backend/services/getGlobalModelService.js b/backend/services/getGlobalModelService.js
index b7212b3b..7879a493 100644
--- a/backend/services/getGlobalModelService.js
+++ b/backend/services/getGlobalModelService.js
@@ -8,6 +8,7 @@ const pivotReferralRedemptionSchema = require('../schemas/pivotReferralRedemptio
const pivotWeeklySnapshotSchema = require('../schemas/pivotWeeklySnapshot');
const pivotLabNotesSchema = require('../schemas/pivotLabNotes');
const pivotTagCatalogSchema = require('../schemas/pivotTagCatalog');
+const pivotPosterTemplateSchema = require('../schemas/pivotPosterTemplate');
/**
* Get models from the global/platform DB (cross-tenant data).
@@ -15,7 +16,7 @@ const pivotTagCatalogSchema = require('../schemas/pivotTagCatalog');
* Requires req.globalDb to be set (see app.js middleware).
*
* @param {object} req - request with req.globalDb
- * @param {...string} names - model names: 'GlobalUser', 'PlatformRole', 'TenantMembership', 'Session', 'TenantConfig', 'PivotReferralCode', 'PivotReferralRedemption', 'PivotWeeklySnapshot', 'PivotLabNotes', 'PivotTagCatalog'
+ * @param {...string} names - model names: 'GlobalUser', 'PlatformRole', 'TenantMembership', 'Session', 'TenantConfig', 'PivotReferralCode', 'PivotReferralRedemption', 'PivotWeeklySnapshot', 'PivotLabNotes', 'PivotTagCatalog', 'PivotPosterTemplate'
* @returns {object} map of requested models
*/
const getGlobalModels = (req, ...names) => {
@@ -43,6 +44,11 @@ const getGlobalModels = (req, ...names) => {
),
PivotLabNotes: db.model('PivotLabNotes', pivotLabNotesSchema, 'pivot_lab_notes'),
PivotTagCatalog: db.model('PivotTagCatalog', pivotTagCatalogSchema, 'pivot_tag_catalog'),
+ PivotPosterTemplate: db.model(
+ 'PivotPosterTemplate',
+ pivotPosterTemplateSchema,
+ 'pivot_poster_templates'
+ ),
};
return names.reduce((acc, name) => {
diff --git a/backend/services/pivotCatalogPurgeService.js b/backend/services/pivotCatalogPurgeService.js
index fb60b0b1..88df8132 100644
--- a/backend/services/pivotCatalogPurgeService.js
+++ b/backend/services/pivotCatalogPurgeService.js
@@ -4,15 +4,13 @@ const { getMergedTenants } = require('./tenantConfigService');
const { isPivotTenant } = require('./pivotReferralCodeService');
const { connectToDatabase } = require('../connectionsManager');
const { PIVOT_EVENT_FEATURE } = require('./pivotFeedbackService');
+const { isValidIsoWeek } = require('../utilities/pivotIsoWeek');
const PURGE_CONFIRM_TOKEN = 'PURGE';
const PIVOT_CATALOG_EVENT_QUERY = { 'customFields.pivot': { $exists: true } };
-function isDevEnvironment() {
- return process.env.NODE_ENV !== 'production';
-}
-
-async function purgeTenantPivotCatalog(tenantKey) {
+async function purgeTenantPivotCatalog(tenantKey, options = {}) {
+ const batchWeek = options.batchWeek || null;
const db = await connectToDatabase(tenantKey);
const tenantReq = { db };
const {
@@ -34,7 +32,11 @@ async function purgeTenantPivotCatalog(tenantKey) {
'AnalyticsEvent',
);
- const events = await Event.find(PIVOT_CATALOG_EVENT_QUERY).select('_id').lean();
+ const eventQuery = batchWeek
+ ? { ...PIVOT_CATALOG_EVENT_QUERY, 'customFields.pivot.batchWeek': batchWeek }
+ : PIVOT_CATALOG_EVENT_QUERY;
+
+ const events = await Event.find(eventQuery).select('_id').lean();
const eventIds = events.map((event) => event._id);
const eventIdStrings = eventIds.map(String);
@@ -48,14 +50,25 @@ async function purgeTenantPivotCatalog(tenantKey) {
analyticsEvents: 0,
};
+ // Attendee intents store batchWeek directly, so a weekly purge scopes to that field —
+ // this also cleans up intents whose event was already removed. A full purge falls back
+ // to event membership, and an empty filter sweeps any orphaned intents.
const intentResult = await PivotEventIntent.deleteMany(
- eventIds.length ? { eventId: { $in: eventIds } } : {},
+ batchWeek
+ ? { batchWeek }
+ : eventIds.length
+ ? { eventId: { $in: eventIds } }
+ : {},
);
deleted.intents = intentResult.deletedCount || 0;
const feedbackResult = await UniversalFeedback.deleteMany({
feature: PIVOT_EVENT_FEATURE,
- ...(eventIds.length ? { processId: { $in: eventIds } } : {}),
+ ...(batchWeek
+ ? { 'metadata.batchWeek': batchWeek }
+ : eventIds.length
+ ? { processId: { $in: eventIds } }
+ : {}),
});
deleted.feedback = feedbackResult.deletedCount || 0;
@@ -81,21 +94,15 @@ async function purgeTenantPivotCatalog(tenantKey) {
return deleted;
}
-async function purgeGlobalPivotSnapshots(req) {
+async function purgeGlobalPivotSnapshots(req, options = {}) {
const { PivotWeeklySnapshot } = getGlobalModels(req, 'PivotWeeklySnapshot');
- const result = await PivotWeeklySnapshot.deleteMany({});
+ const result = await PivotWeeklySnapshot.deleteMany(
+ options.batchWeek ? { batchWeek: options.batchWeek } : {},
+ );
return { weeklySnapshots: result.deletedCount || 0 };
}
async function purgePivotCatalog(req, options = {}) {
- if (!isDevEnvironment()) {
- return {
- error: 'Not available in production.',
- status: 404,
- code: 'NOT_FOUND',
- };
- }
-
const confirm = options.confirm?.trim();
if (confirm !== PURGE_CONFIRM_TOKEN) {
return {
@@ -105,6 +112,15 @@ async function purgePivotCatalog(req, options = {}) {
};
}
+ const batchWeek = options.batchWeek?.trim() || null;
+ if (batchWeek && !isValidIsoWeek(batchWeek)) {
+ return {
+ error: 'batchWeek must be ISO format YYYY-Www (e.g. 2026-W21).',
+ status: 400,
+ code: 'INVALID_BATCH_WEEK',
+ };
+ }
+
const pivotTenants = (await getMergedTenants(req)).filter(isPivotTenant);
const tenantKeyFilter = options.tenantKey?.trim()?.toLowerCase();
@@ -123,7 +139,7 @@ async function purgePivotCatalog(req, options = {}) {
const tenantResults = [];
for (const tenant of tenantsToPurge) {
- const counts = await purgeTenantPivotCatalog(tenant.tenantKey);
+ const counts = await purgeTenantPivotCatalog(tenant.tenantKey, { batchWeek });
tenantResults.push({
tenantKey: tenant.tenantKey,
cityDisplayName: tenant.location || tenant.name || tenant.tenantKey,
@@ -132,7 +148,7 @@ async function purgePivotCatalog(req, options = {}) {
}
const globalDeleted =
- options.clearSnapshots === false ? {} : await purgeGlobalPivotSnapshots(req);
+ options.clearSnapshots === false ? {} : await purgeGlobalPivotSnapshots(req, { batchWeek });
const totals = tenantResults.reduce(
(acc, row) => {
@@ -146,6 +162,8 @@ async function purgePivotCatalog(req, options = {}) {
return {
data: {
+ batchWeek,
+ scope: batchWeek ? 'week' : 'all-weeks',
tenants: tenantResults,
totals,
},
@@ -157,5 +175,4 @@ module.exports = {
purgeTenantPivotCatalog,
PURGE_CONFIRM_TOKEN,
PIVOT_CATALOG_EVENT_QUERY,
- isDevEnvironment,
};
diff --git a/backend/services/pivotPosterTemplateService.js b/backend/services/pivotPosterTemplateService.js
new file mode 100644
index 00000000..757f395c
--- /dev/null
+++ b/backend/services/pivotPosterTemplateService.js
@@ -0,0 +1,354 @@
+const crypto = require('crypto');
+const path = require('path');
+const sharp = require('sharp');
+const QRCode = require('qrcode');
+const s3 = require('../aws-config');
+const getGlobalModels = require('./getGlobalModelService');
+const { getTenantByKey } = require('./tenantConfigService');
+
+const POSTER_FOLDER = 'pivot-posters';
+const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
+
+function isPivotTenant(tenant) {
+ return tenant?.pivotPilot === true || tenant?.tenantType === 'pivot';
+}
+
+async function requirePivotTenant(req, tenantKey) {
+ const tenant = await getTenantByKey(req, tenantKey);
+ if (!tenant) {
+ return { error: 'Tenant not found.', status: 404 };
+ }
+ if (!isPivotTenant(tenant)) {
+ return { error: 'Poster templates are only available for Pivot city tenants.', status: 403 };
+ }
+ return { tenant };
+}
+
+function serializePosterTemplate(doc) {
+ const row = doc?.toObject ? doc.toObject() : doc;
+ return {
+ _id: String(row._id),
+ tenantKey: row.tenantKey,
+ name: row.name,
+ imageUrl: row.imageUrl,
+ width: row.width || null,
+ height: row.height || null,
+ qrBox: {
+ x: row.qrBox?.x ?? 0.5,
+ y: row.qrBox?.y ?? 0.5,
+ w: row.qrBox?.w ?? 0.25,
+ },
+ qrColor: row.qrColor || '#1A1714',
+ plate: row.plate !== false,
+ createdAt: row.createdAt,
+ updatedAt: row.updatedAt,
+ };
+}
+
+function parseQrBox(raw) {
+ let box = raw;
+ if (typeof raw === 'string') {
+ try {
+ box = JSON.parse(raw);
+ } catch {
+ return { error: 'qrBox must be valid JSON.' };
+ }
+ }
+ if (!box || typeof box !== 'object') return { error: 'qrBox is required.' };
+ const x = Number(box.x);
+ const y = Number(box.y);
+ const w = Number(box.w);
+ if (![x, y, w].every(Number.isFinite)) {
+ return { error: 'qrBox.x, qrBox.y and qrBox.w must be numbers.' };
+ }
+ if (x < 0 || x > 1 || y < 0 || y > 1) {
+ return { error: 'qrBox.x and qrBox.y must be between 0 and 1.' };
+ }
+ if (w < 0.02 || w > 1) {
+ return { error: 'qrBox.w must be between 0.02 and 1.' };
+ }
+ return { box: { x, y, w } };
+}
+
+function normalizeColor(raw, fallback = '#1A1714') {
+ const value = String(raw || '').trim();
+ return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value) ? value : fallback;
+}
+
+function s3KeyForPoster(tenantKey, file) {
+ const ext = path.extname(file.originalname || '').toLowerCase() || '.png';
+ const random = crypto.randomBytes(8).toString('hex');
+ return `${POSTER_FOLDER}/${tenantKey}/${Date.now()}-${random}${ext}`;
+}
+
+async function listPosterTemplates(req, tenantKey) {
+ const gate = await requirePivotTenant(req, tenantKey);
+ if (gate.error) return gate;
+
+ const { PivotPosterTemplate } = getGlobalModels(req, 'PivotPosterTemplate');
+ const docs = await PivotPosterTemplate.find({ tenantKey }).sort({ createdAt: -1 }).lean();
+ return { tenantKey, templates: docs.map(serializePosterTemplate) };
+}
+
+async function createPosterTemplate(req, tenantKey, file, body = {}) {
+ const gate = await requirePivotTenant(req, tenantKey);
+ if (gate.error) return gate;
+
+ if (!file) return { error: 'A poster image file is required.', status: 400 };
+ if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) {
+ return { error: 'Poster must be a JPEG, PNG, or WebP image.', status: 400 };
+ }
+
+ const name = String(body.name || '').trim();
+ if (!name) return { error: 'Template name is required.', status: 400 };
+
+ const parsedBox = parseQrBox(body.qrBox);
+ if (parsedBox.error) return { error: parsedBox.error, status: 400 };
+
+ let meta;
+ try {
+ meta = await sharp(file.buffer).metadata();
+ } catch {
+ return { error: 'Could not read the poster image.', status: 400 };
+ }
+
+ const key = s3KeyForPoster(tenantKey, file);
+ const uploaded = await s3
+ .upload({
+ Bucket: process.env.AWS_S3_BUCKET_NAME,
+ Key: key,
+ Body: file.buffer,
+ ContentType: file.mimetype,
+ ContentDisposition: 'inline',
+ CacheControl: 'public, max-age=31536000',
+ })
+ .promise();
+
+ const { PivotPosterTemplate } = getGlobalModels(req, 'PivotPosterTemplate');
+ const doc = await PivotPosterTemplate.create({
+ tenantKey,
+ name,
+ imageUrl: uploaded.Location,
+ imageKey: key,
+ width: meta.width || null,
+ height: meta.height || null,
+ qrBox: parsedBox.box,
+ qrColor: normalizeColor(body.qrColor),
+ plate: body.plate === undefined ? true : body.plate === true || body.plate === 'true',
+ });
+
+ return { template: serializePosterTemplate(doc) };
+}
+
+async function updatePosterTemplate(req, tenantKey, id, body = {}) {
+ const gate = await requirePivotTenant(req, tenantKey);
+ if (gate.error) return gate;
+
+ const { PivotPosterTemplate } = getGlobalModels(req, 'PivotPosterTemplate');
+ const doc = await PivotPosterTemplate.findOne({ _id: id, tenantKey });
+ if (!doc) return { error: 'Poster template not found.', status: 404 };
+
+ if (body.name !== undefined) {
+ const name = String(body.name || '').trim();
+ if (!name) return { error: 'Template name cannot be empty.', status: 400 };
+ doc.name = name;
+ }
+ if (body.qrBox !== undefined) {
+ const parsedBox = parseQrBox(body.qrBox);
+ if (parsedBox.error) return { error: parsedBox.error, status: 400 };
+ doc.qrBox = parsedBox.box;
+ }
+ if (body.qrColor !== undefined) {
+ doc.qrColor = normalizeColor(body.qrColor, doc.qrColor);
+ }
+ if (body.plate !== undefined) {
+ doc.plate = body.plate === true || body.plate === 'true';
+ }
+
+ await doc.save();
+ return { template: serializePosterTemplate(doc) };
+}
+
+async function deletePosterTemplate(req, tenantKey, id) {
+ const gate = await requirePivotTenant(req, tenantKey);
+ if (gate.error) return gate;
+
+ const { PivotPosterTemplate } = getGlobalModels(req, 'PivotPosterTemplate');
+ const doc = await PivotPosterTemplate.findOneAndDelete({ _id: id, tenantKey });
+ if (!doc) return { error: 'Poster template not found.', status: 404 };
+
+ if (doc.imageKey) {
+ try {
+ await s3
+ .deleteObject({ Bucket: process.env.AWS_S3_BUCKET_NAME, Key: doc.imageKey })
+ .promise();
+ } catch (err) {
+ // Non-fatal: the DB record is already gone; log and move on.
+ console.error('Failed to delete poster art from S3:', err.message);
+ }
+ }
+ return { deleted: true };
+}
+
+function resolveOrigin(requestedOrigin) {
+ const fallback =
+ process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : 'https://www.meridian.study';
+ if (!requestedOrigin) return fallback;
+ try {
+ const parsed = new URL(requestedOrigin);
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return fallback;
+ return parsed.origin;
+ } catch {
+ return fallback;
+ }
+}
+
+function buildInviteUrl(code, requestedOrigin) {
+ return `${resolveOrigin(requestedOrigin)}/invite?code=${encodeURIComponent(code)}`;
+}
+
+/**
+ * Build a rounded-style QR as an SVG string, matching the frontend "QR" option
+ * (qr-code-styling extra-rounded): data modules are drawn with corners rounded
+ * only where they have no filled neighbour (so runs connect into smooth shapes),
+ * and the three finder patterns are drawn as rounded square outlines with a
+ * rounded centre dot. Rasterized by sharp at composite time.
+ */
+function buildStyledQrSvg(text, { sizePx, color = '#1A1714', margin = 0 } = {}) {
+ const qr = QRCode.create(text, { errorCorrectionLevel: 'Q' });
+ const N = qr.modules.size;
+ const data = qr.modules.data;
+ const total = N + margin * 2;
+ const m = sizePx / total;
+ const off = margin * m;
+ const get = (r, c) => (r < 0 || c < 0 || r >= N || c >= N ? 0 : data[r * N + c]);
+ const inFinder = (r, c) =>
+ (r < 7 && c < 7) || (r < 7 && c >= N - 7) || (r >= N - 7 && c < 7);
+ const f = (n) => Math.round(n * 100) / 100;
+
+ const s = m;
+ const r = m / 2;
+ let d = '';
+ for (let row = 0; row < N; row++) {
+ for (let col = 0; col < N; col++) {
+ if (!get(row, col) || inFinder(row, col)) continue;
+ const px = col * m + off;
+ const py = row * m + off;
+ const rTL = !get(row - 1, col) && !get(row, col - 1);
+ const rTR = !get(row - 1, col) && !get(row, col + 1);
+ const rBR = !get(row + 1, col) && !get(row, col + 1);
+ const rBL = !get(row + 1, col) && !get(row, col - 1);
+ let p = `M ${f(px + (rTL ? r : 0))} ${f(py)} `;
+ p += `L ${f(px + s - (rTR ? r : 0))} ${f(py)} `;
+ p += rTR ? `A ${f(r)} ${f(r)} 0 0 1 ${f(px + s)} ${f(py + r)} ` : `L ${f(px + s)} ${f(py)} `;
+ p += `L ${f(px + s)} ${f(py + s - (rBR ? r : 0))} `;
+ p += rBR ? `A ${f(r)} ${f(r)} 0 0 1 ${f(px + s - r)} ${f(py + s)} ` : `L ${f(px + s)} ${f(py + s)} `;
+ p += `L ${f(px + (rBL ? r : 0))} ${f(py + s)} `;
+ p += rBL ? `A ${f(r)} ${f(r)} 0 0 1 ${f(px)} ${f(py + s - r)} ` : `L ${f(px)} ${f(py + s)} `;
+ p += `L ${f(px)} ${f(py + (rTL ? r : 0))} `;
+ p += rTL ? `A ${f(r)} ${f(r)} 0 0 1 ${f(px + r)} ${f(py)} ` : `L ${f(px)} ${f(py)} `;
+ d += `${p}Z `;
+ }
+ }
+
+ const finders = [[0, 0], [0, N - 7], [N - 7, 0]];
+ let finderSvg = '';
+ for (const [r0, c0] of finders) {
+ const ox = c0 * m + off;
+ const oy = r0 * m + off;
+ finderSvg +=
+ `
your city, one tap at a time
+don't overthink, just go!
just go is an experiment from Meridian. For this pilot - you'll use Meridian Go — our campus app on iPhone — - and your invite code unlocks the just go experience inside it. Android + you'll use Meridian Go and your invite code unlocks the just go experience inside it. Android isn't supported yet.
@@ -258,8 +257,7 @@ function InviteLanding() {- grab Meridian Go from the App Store — it's the same Meridian - app students use on campus. your invite code switches you into + grab Meridian Go from the App Store, your invite code switches you into just go after install.
{isAndroid ? ( @@ -278,8 +276,7 @@ function InviteLanding() {- after installing, return here and open just go — your code - carries over. + after installing, click the button below to open just go.
{deepLink ? ( @@ -308,6 +305,7 @@ function InviteLanding() {contact raven@meridian.study to leave feedback or feature requests.
); diff --git a/frontend/src/pages/InviteLanding/InviteLanding.scss b/frontend/src/pages/InviteLanding/InviteLanding.scss index 79ec0068..4a6716c5 100644 --- a/frontend/src/pages/InviteLanding/InviteLanding.scss +++ b/frontend/src/pages/InviteLanding/InviteLanding.scss @@ -150,6 +150,26 @@ font-weight: 700; } +.invite-landing__note { + margin: 0; + font-size: 0.72rem; + line-height: 1.55; + color: var(--jg-fg-muted); + font-weight: 400; + text-align: center; + margin-top: 1rem; +} + +.invite-landing__note a { + color: var(--jg-fg); + text-decoration: none; + font-weight: 700; +} + +.invite-landing__note a:hover { + text-decoration: underline; +} + .invite-landing__title { margin: 0 0 1rem; font-size: 1.35rem; diff --git a/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.jsx b/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.jsx index 295bccc5..aff49f05 100644 --- a/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.jsx +++ b/frontend/src/pages/PlatformAdmin/PivotLab/PivotLabPage.jsx @@ -31,7 +31,6 @@ import './PivotDeckCardPreview.scss'; const EMPTY_LIST = []; const NO_FETCH_CACHE = { enabled: false }; -const IS_DEV = process.env.NODE_ENV !== 'production'; const PURGE_CONFIRM_TOKEN = 'PURGE'; const PIVOT_JSON_IMPORT_EXAMPLE = `{ @@ -612,6 +611,7 @@ function PivotLabPage() { const [editSaving, setEditSaving] = useState(false); const [deckPreviewState, setDeckPreviewState] = useState(null); const [purgeScope, setPurgeScope] = useState('selected'); + const [purgeWeekScope, setPurgeWeekScope] = useState('week'); const [purgeConfirm, setPurgeConfirm] = useState(''); const [purgingCatalog, setPurgingCatalog] = useState(false); const [manualImportOpen, setManualImportOpen] = useState(false); @@ -835,9 +835,11 @@ function PivotLabPage() { selectedTenantKey || 'this city'; + const weekLabel = purgeWeekScope === 'week' ? batchWeek : 'every batch week'; + if ( !window.confirm( - `Permanently delete all pivot catalog events and related intents, feedback, and analytics for ${scopeLabel}? This cannot be undone.`, + `Permanently delete pivot catalog events for ${weekLabel} and related intents, feedback, and analytics for ${scopeLabel}? This cannot be undone.`, ) ) { return; @@ -849,6 +851,7 @@ function PivotLabPage() { data: { confirm: PURGE_CONFIRM_TOKEN, tenantKey: purgeScope === 'all' ? undefined : selectedTenantKey || undefined, + batchWeek: purgeWeekScope === 'week' ? batchWeek : undefined, clearSnapshots: true, }, }); @@ -864,18 +867,21 @@ function PivotLabPage() { } const totals = data.data?.totals || {}; + const purgedWeekLabel = purgeWeekScope === 'week' ? ` for ${batchWeek}` : ' across all weeks'; setPurgeConfirm(''); refetchOverview(); refetchEvents(); addNotification({ title: 'Catalog purged', - message: `Removed ${totals.events ?? 0} events, ${totals.intents ?? 0} intents, and ${totals.feedback ?? 0} feedback rows.`, + message: `Removed ${totals.events ?? 0} events, ${totals.intents ?? 0} intents, and ${totals.feedback ?? 0} feedback rows${purgedWeekLabel}.`, type: 'success', }); }, [ addNotification, + batchWeek, purgeConfirm, purgeScope, + purgeWeekScope, refetchEvents, refetchOverview, selectedTenantKey, @@ -2611,57 +2617,71 @@ function PivotLabPage() { - {IS_DEV ? ( -- Development only. Deletes all pivot catalog events (every batch week), attendee intents, - event feedback, and stored weekly snapshots. Referral codes and interview notes are kept. -
-
+ Scan to open the just go invite page with {code} prefilled.
+
+ Pick a template — the invite QR for this code is stamped in automatically. +
+No poster templates yet.
+ {onManage ? ( + + ) : null} ++ Upload poster art and place the QR box once. Each referral code can then be exported as a + ready-to-print poster with its own invite QR stamped in. +
+No poster templates yet. Add one to get started.
+ ) : ( + templates.map((tpl) => ( ++ Drag the box to position the QR; drag its corner to resize. +
++ Keep the plate on for busy artwork so the QR stays scannable. Turn it off to drop the QR + straight onto a clean area (transparent background). +
+ +{currentBatchWeek || '—'}
-
+ {error}
: null} @@ -365,6 +400,33 @@ function PivotReferralCodesPanel({ tenantKey }) {