Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
570 changes: 560 additions & 10 deletions backend/package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions backend/routes/pivotAdminRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
112 changes: 112 additions & 0 deletions backend/routes/platformTenantRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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);
Expand Down
75 changes: 75 additions & 0 deletions backend/schemas/pivotPosterTemplate.js
Original file line number Diff line number Diff line change
@@ -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;
8 changes: 7 additions & 1 deletion backend/services/getGlobalModelService.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ 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).
* Use only in auth flows and admin-resolution logic.
* 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) => {
Expand Down Expand Up @@ -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) => {
Expand Down
59 changes: 38 additions & 21 deletions backend/services/pivotCatalogPurgeService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);

Expand All @@ -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;

Expand All @@ -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 {
Expand All @@ -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();

Expand All @@ -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,
Expand All @@ -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) => {
Expand All @@ -146,6 +162,8 @@ async function purgePivotCatalog(req, options = {}) {

return {
data: {
batchWeek,
scope: batchWeek ? 'week' : 'all-weeks',
tenants: tenantResults,
totals,
},
Expand All @@ -157,5 +175,4 @@ module.exports = {
purgeTenantPivotCatalog,
PURGE_CONFIRM_TOKEN,
PIVOT_CATALOG_EVENT_QUERY,
isDevEnvironment,
};
Loading