EventBuzz_Backend/src/controllers/staticMicrosite.controller.js

653 lines
23 KiB
JavaScript

const mongoose = require('mongoose');
const Event = require('../models/Event');
const EventStaticMicrosite = require('../models/EventStaticMicrosite');
const MicrositeV2Template = require('../models/MicrositeV2Template');
const { success, error } = require('../utils/response');
const { normalizeSubdomain, subdomainAvailability } = require('../utils/subdomainAvailability');
const {
uploadTemplateFile,
uploadTemplateAsset,
templateAssetsBaseUrl,
templateKey,
cloudfrontUrlForKey,
replaceSlugInS3Key,
copyTemplateFilesBetweenSlugs,
} = require('../utils/s3MicrositeV2');
const { parseTemplateSchema, buildDefaultConfig } = require('../utils/templateSchema');
const {
renderStaticMicrositeHtml,
publishStaticMicrosite,
loadTemplateSchema,
} = require('../utils/publishStaticMicrosite');
const { renderHandlebarsTemplate } = require('../utils/handlebarsRender');
const {
isLocalMicrositeV2TemplatesEnabled,
localPreviewAssetsBaseUrl,
resolveLocalAssetFile,
mimeForLocalAsset,
} = require('../utils/localMicrositeV2Templates');
const fs = require('fs');
function requirePlatformAdmin(user) {
return user.platform_admin === 'yes';
}
function toObjectId(id) {
if (id == null || id === '') return null;
const s = String(id);
if (!mongoose.Types.ObjectId.isValid(s)) return null;
return new mongoose.Types.ObjectId(s);
}
function orgIdOf(user) {
return toObjectId(user.organization?._id || user.organization);
}
function refIdString(ref) {
if (ref == null) return '';
if (typeof ref === 'object' && ref._id != null) return String(ref._id);
return String(ref);
}
function templateAccessibleToOrg(template, orgId) {
if (!template || template.visibility !== 'private') return true;
const oid = toObjectId(orgId);
if (!oid) return false;
const target = String(oid);
const allowed = template.allowedOrganizations || [];
return allowed.some((id) => refIdString(id) === target);
}
function normalizeOrgIds(raw) {
if (!Array.isArray(raw)) return [];
const ids = raw.map((id) => toObjectId(id)).filter(Boolean);
const unique = [...new Set(ids.map((oid) => oid.toString()))];
return unique.map((s) => new mongoose.Types.ObjectId(s));
}
function buildTemplateCatalogQuery(organizationId) {
const base = { isActive: true };
const publicClause = [{ visibility: { $exists: false } }, { visibility: 'public' }];
if (!organizationId) {
return { ...base, $or: publicClause };
}
return {
...base,
$or: [...publicClause, { visibility: 'private', allowedOrganizations: organizationId }],
};
}
async function resolveOrganizationForTemplateList(request) {
const user = request.auth.credentials.user;
const { eventId } = request.query || {};
if (eventId && mongoose.Types.ObjectId.isValid(eventId)) {
const { event, forbidden } = await loadEventForUser(eventId, user);
if (forbidden) return { error: 'FORBIDDEN' };
if (!event) return { error: 'NOT_FOUND' };
return { organizationId: toObjectId(event.organization) };
}
return { organizationId: orgIdOf(user) };
}
async function loadEventForUser(eventId, user) {
const event = await Event.findById(eventId);
if (!event) return { event: null, forbidden: false };
if (orgMismatch(event, user)) return { event: null, forbidden: true };
return { event, forbidden: false };
}
function extractBufferPart(part, fallbackName) {
if (!part) return null;
if (Buffer.isBuffer(part)) {
return { buffer: part, filename: fallbackName, mimeType: 'application/octet-stream' };
}
if (typeof part === 'object' && part.payload && Buffer.isBuffer(part.payload)) {
return {
buffer: part.payload,
filename: part.filename || fallbackName,
mimeType: part.headers?.['content-type'] || 'application/octet-stream',
};
}
if (typeof part === 'object' && part._data && Buffer.isBuffer(part._data)) {
return {
buffer: part._data,
filename: part.filename || fallbackName,
mimeType: part.headers?.['content-type'] || 'application/octet-stream',
};
}
return null;
}
function orgMismatch(event, user) {
if (user.role === 'super_admin') return false;
return event.organization?.toString() !== (user.organization?._id || user.organization)?.toString();
}
// --- Template catalog (read) ---
async function assertTemplateReadable(request, template) {
const user = request.auth.credentials.user;
if (requirePlatformAdmin(user)) return null;
const { eventId } = request.query || {};
if (eventId && mongoose.Types.ObjectId.isValid(eventId)) {
const { event, forbidden } = await loadEventForUser(eventId, user);
if (!forbidden && event) {
const existingSite = await EventStaticMicrosite.findOne({
event: event._id,
template: template._id,
})
.select('_id')
.lean();
if (existingSite) return null;
}
const scoped = await resolveOrganizationForTemplateList(request);
if (scoped.error === 'FORBIDDEN') {
return { status: 403, message: 'Access denied' };
}
if (scoped.error === 'NOT_FOUND') {
return { status: 404, message: 'Event not found' };
}
if (templateAccessibleToOrg(template, scoped.organizationId)) {
return null;
}
}
if (templateAccessibleToOrg(template, orgIdOf(user))) {
return null;
}
return {
status: 403,
message: 'This template is not available for your organization',
};
}
const listTemplates = async (request, h) => {
const scoped = await resolveOrganizationForTemplateList(request);
if (scoped.error === 'FORBIDDEN') {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
if (scoped.error === 'NOT_FOUND') {
return h.response(error('NOT_FOUND', 'Event not found')).code(404);
}
const query = buildTemplateCatalogQuery(scoped.organizationId);
const rows = await MicrositeV2Template.find(query)
.sort({ createdAt: -1 })
.select('slug name description thumbnailUrl version isActive visibility createdAt')
.lean();
return h.response(success(rows));
};
const listAdminTemplates = async (request, h) => {
const user = request.auth.credentials.user;
if (!requirePlatformAdmin(user)) {
return h.response(error('FORBIDDEN', 'Only platform admins can list all templates')).code(403);
}
const rows = await MicrositeV2Template.find({})
.sort({ createdAt: -1 })
.populate('createdBy', 'name email')
.populate('allowedOrganizations', 'name email')
.lean();
return h.response(success(rows));
};
const getTemplate = async (request, h) => {
const template = await MicrositeV2Template.findById(request.params.id)
.populate('allowedOrganizations', 'name email')
.lean();
if (!template) return h.response(error('NOT_FOUND', 'Template not found')).code(404);
const access = await assertTemplateReadable(request, template);
if (access) {
return h.response(error('FORBIDDEN', access.message)).code(access.status);
}
let schema;
try {
schema = await loadTemplateSchema(template);
} catch {
schema = template.schema || null;
}
return h.response(success({ ...template, schema }));
};
// --- Template admin ---
const createTemplate = async (request, h) => {
const user = request.auth.credentials.user;
if (!requirePlatformAdmin(user)) {
return h.response(error('FORBIDDEN', 'Only platform admins can create templates')).code(403);
}
const { slug, name, description, version, visibility, allowedOrganizations } = request.payload;
const normalizedSlug = normalizeSubdomain(slug);
if (!normalizedSlug) {
return h.response(error('VALIDATION_ERROR', 'Valid slug is required')).code(400);
}
const vis = visibility === 'private' ? 'private' : 'public';
const orgIds = vis === 'private' ? normalizeOrgIds(allowedOrganizations) : [];
if (vis === 'private' && orgIds.length === 0) {
return h.response(error('VALIDATION_ERROR', 'Select at least one organization for a private template')).code(400);
}
const existing = await MicrositeV2Template.findOne({ slug: normalizedSlug }).lean();
if (existing) {
return h.response(error('CONFLICT', 'Template slug already exists')).code(409);
}
const template = await MicrositeV2Template.create({
slug: normalizedSlug,
name: name || normalizedSlug,
description: description || '',
version: version || 1,
visibility: vis,
allowedOrganizations: orgIds,
isActive: false,
createdBy: user._id,
});
return h.response(success(template, 'Template created', 201)).code(201);
};
const updateTemplate = async (request, h) => {
const user = request.auth.credentials.user;
if (!requirePlatformAdmin(user)) {
return h.response(error('FORBIDDEN', 'Only super_admin can update templates')).code(403);
}
const template = await MicrositeV2Template.findById(request.params.id);
if (!template) return h.response(error('NOT_FOUND', 'Template not found')).code(404);
const { name, description, isActive, version, visibility, allowedOrganizations } = request.payload;
if (name !== undefined) template.name = name;
if (description !== undefined) template.description = description;
if (isActive !== undefined) template.isActive = isActive;
if (version !== undefined) template.version = version;
if (visibility !== undefined) {
template.visibility = visibility === 'private' ? 'private' : 'public';
}
if (allowedOrganizations !== undefined) {
template.allowedOrganizations = normalizeOrgIds(allowedOrganizations);
}
if (template.visibility === 'private' && (!template.allowedOrganizations || template.allowedOrganizations.length === 0)) {
return h.response(error('VALIDATION_ERROR', 'Select at least one organization for a private template')).code(400);
}
if (template.visibility === 'public') {
template.allowedOrganizations = [];
}
await template.save();
const populated = await MicrositeV2Template.findById(template._id)
.populate('allowedOrganizations', 'name email')
.lean();
return h.response(success(populated));
};
const duplicateTemplate = async (request, h) => {
const user = request.auth.credentials.user;
if (!requirePlatformAdmin(user)) {
return h.response(error('FORBIDDEN', 'Only super_admin can duplicate templates')).code(403);
}
const source = await MicrositeV2Template.findById(request.params.id);
if (!source) return h.response(error('NOT_FOUND', 'Template not found')).code(404);
const payload = request.payload || {};
let newSlug = payload.slug ? normalizeSubdomain(payload.slug) : `${source.slug}-copy`;
if (!newSlug) {
return h.response(error('VALIDATION_ERROR', 'Valid slug is required')).code(400);
}
const baseSlug = newSlug;
let suffix = 1;
while (await MicrositeV2Template.findOne({ slug: newSlug }).lean()) {
suffix += 1;
newSlug = `${baseSlug}-${suffix}`;
}
const newName = (payload.name || `${source.name} (Copy)`).trim();
if (source.htmlS3Key || source.schemaS3Key || source.assetsS3Prefix) {
await copyTemplateFilesBetweenSlugs(source.slug, newSlug);
}
const doc = source.toObject();
delete doc._id;
delete doc.createdAt;
delete doc.updatedAt;
doc.slug = newSlug;
doc.name = newName;
doc.createdBy = user._id;
doc.htmlS3Key = replaceSlugInS3Key(doc.htmlS3Key, source.slug, newSlug);
doc.schemaS3Key = replaceSlugInS3Key(doc.schemaS3Key, source.slug, newSlug);
if (doc.assetsS3Prefix) {
doc.assetsS3Prefix = replaceSlugInS3Key(doc.assetsS3Prefix, source.slug, newSlug);
}
doc.assetsBaseUrl = templateAssetsBaseUrl(newSlug);
if (source.thumbnailUrl) {
const extMatch = String(source.thumbnailUrl).match(/thumbnail\.(\w+)/i);
const ext = extMatch ? extMatch[1] : 'jpg';
doc.thumbnailUrl = cloudfrontUrlForKey(templateKey(newSlug, `thumbnail.${ext}`));
}
const created = await MicrositeV2Template.create(doc);
const populated = await MicrositeV2Template.findById(created._id)
.populate('allowedOrganizations', 'name email')
.lean();
return h.response(success(populated, 'Template duplicated', 201)).code(201);
};
const removeTemplate = async (request, h) => {
const user = request.auth.credentials.user;
if (!requirePlatformAdmin(user)) {
return h.response(error('FORBIDDEN', 'Only super_admin can delete templates')).code(403);
}
const template = await MicrositeV2Template.findById(request.params.id);
if (!template) return h.response(error('NOT_FOUND', 'Template not found')).code(404);
const inUse = await EventStaticMicrosite.countDocuments({ template: template._id });
if (inUse > 0) {
return h.response(error('CONFLICT', 'Template is in use by event microsites')).code(409);
}
await template.deleteOne();
return h.response(success(null, 'Template deleted'));
};
const uploadTemplateFiles = async (request, h) => {
const user = request.auth.credentials.user;
if (!requirePlatformAdmin(user)) {
return h.response(error('FORBIDDEN', 'Only super_admin can upload templates')).code(403);
}
const template = await MicrositeV2Template.findById(request.params.id);
if (!template) return h.response(error('NOT_FOUND', 'Template not found')).code(404);
const payload = request.payload || {};
const slug = template.slug;
const htmlPart = extractBufferPart(payload.templateHtml, 'template.html');
const jsonPart = extractBufferPart(payload.templateJson, 'template.json');
const thumbPart = extractBufferPart(payload.thumbnail, 'thumbnail.jpg');
const zipPart = extractBufferPart(payload.assetsZip, 'assets.zip');
if (htmlPart) {
const up = await uploadTemplateFile(slug, 'template.html', htmlPart.buffer, 'text/html; charset=utf-8');
template.htmlS3Key = up.s3Key;
}
if (jsonPart) {
const schema = parseTemplateSchema(jsonPart.buffer);
const up = await uploadTemplateFile(slug, 'template.json', jsonPart.buffer, 'application/json');
template.schemaS3Key = up.s3Key;
template.schema = schema;
if (htmlPart) {
try {
const htmlStr = htmlPart.buffer.toString('utf8');
const defaultConfig = buildDefaultConfig(schema);
renderHandlebarsTemplate(htmlStr, {
meta: defaultConfig.meta,
theme: defaultConfig.theme,
sections: defaultConfig.sections,
assetsBaseUrl: template.assetsBaseUrl || templateAssetsBaseUrl(slug),
eventId: '',
subdomain: '',
templateSlug: slug,
});
} catch (e) {
return h.response(error('VALIDATION_ERROR', `Handlebars compile failed: ${e.message}`)).code(400);
}
}
}
if (thumbPart) {
const ext = (thumbPart.filename || '').split('.').pop() || 'jpg';
const mime = thumbPart.mimeType.startsWith('image/') ? thumbPart.mimeType : `image/${ext}`;
const up = await uploadTemplateFile(slug, `thumbnail.${ext}`, thumbPart.buffer, mime);
template.thumbnailUrl = up.url;
}
if (zipPart) {
// Lazy require — only needed for super-admin template zip upload.
// eslint-disable-next-line global-require
const AdmZip = require('adm-zip');
const zip = new AdmZip(zipPart.buffer);
const entries = zip.getEntries();
for (const entry of entries) {
if (entry.isDirectory) continue;
const rel = entry.entryName.replace(/^\/+/, '');
const data = entry.getData();
const mime = rel.endsWith('.css')
? 'text/css'
: rel.endsWith('.js')
? 'application/javascript'
: 'application/octet-stream';
await uploadTemplateAsset(slug, rel, data, mime);
}
template.assetsS3Prefix = `templates/${slug}/assets`;
}
template.assetsBaseUrl = templateAssetsBaseUrl(slug);
if (template.htmlS3Key && template.schemaS3Key) {
template.isActive = true;
}
await template.save();
return h.response(success(template, 'Template files uploaded'));
};
const previewTemplateHtml = async (request, h) => {
const user = request.auth.credentials.user;
if (!requirePlatformAdmin(user)) {
return h.response(error('FORBIDDEN', 'Only super_admin can preview templates')).code(403);
}
const template = await MicrositeV2Template.findById(request.params.id).lean();
if (!template) return h.response(error('NOT_FOUND', 'Template not found')).code(404);
try {
const schema = await loadTemplateSchema(template);
const defaultConfig = buildDefaultConfig(schema);
const assetsBaseUrl = localPreviewAssetsBaseUrl(template.slug, request) || undefined;
const html = await renderStaticMicrositeHtml({
template,
config: defaultConfig,
assetsBaseUrl,
});
return h.response(html).type('text/html');
} catch (e) {
return h.response(error('RENDER_ERROR', e.message)).code(400);
}
};
/**
* Serve template CSS/JS/images from local disk when MICROSITE_V2_READ_FROM_LOCAL is on.
* Public (no JWT) so blob-URL previews can load assets.
*/
const serveLocalTemplateAsset = async (request, h) => {
if (!isLocalMicrositeV2TemplatesEnabled()) {
return h.response(error('NOT_FOUND', 'Local microsite assets are disabled')).code(404);
}
const slug = String(request.params.slug || '').trim().toLowerCase();
const assetPath = Array.isArray(request.params.assetPath)
? request.params.assetPath.join('/')
: String(request.params.assetPath || '');
const fullPath = resolveLocalAssetFile(slug, assetPath);
if (!fullPath) {
return h.response(error('NOT_FOUND', 'Asset not found')).code(404);
}
try {
const buf = fs.readFileSync(fullPath);
return h
.response(buf)
.type(mimeForLocalAsset(fullPath))
.header('Cache-Control', 'no-store, max-age=0')
.header('Access-Control-Allow-Origin', '*');
} catch (e) {
return h.response(error('SERVER_ERROR', e.message || 'Failed to read asset')).code(500);
}
};
// --- Event static microsite ---
const getForEvent = async (request, h) => {
const { event, forbidden } = await loadEventForUser(request.params.eventId, request.auth.credentials.user);
if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const site = await EventStaticMicrosite.findOne({ event: event._id })
.populate('template', 'slug name description thumbnailUrl version assetsBaseUrl')
.lean();
if (!site) return h.response(error('NOT_FOUND', 'Static microsite not configured')).code(404);
return h.response(success(site));
};
const upsertForEvent = async (request, h) => {
const user = request.auth.credentials.user;
const { event, forbidden } = await loadEventForUser(request.params.eventId, user);
if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const { subdomain, templateId, config } = request.payload;
const normalized = normalizeSubdomain(subdomain);
if (!normalized) {
return h.response(error('VALIDATION_ERROR', 'Valid subdomain is required')).code(400);
}
if (!templateId || !mongoose.Types.ObjectId.isValid(templateId)) {
return h.response(error('VALIDATION_ERROR', 'Valid template id is required')).code(400);
}
const template = await MicrositeV2Template.findOne({ _id: templateId, isActive: true });
if (!template) return h.response(error('NOT_FOUND', 'Template not found or inactive')).code(404);
if (!templateAccessibleToOrg(template, event.organization)) {
return h.response(error('FORBIDDEN', 'This template is not available for your organization')).code(403);
}
let site = await EventStaticMicrosite.findOne({ event: event._id });
const avail = await subdomainAvailability(normalized, event._id.toString(), site?._id?.toString());
if (!avail.available) {
return h.response(error('VALIDATION_ERROR', avail.message)).code(400);
}
if (!site) {
site = new EventStaticMicrosite({
event: event._id,
organization: event.organization,
subdomain: normalized,
template: template._id,
config: config || {},
updatedBy: user._id,
});
} else {
site.subdomain = normalized;
site.template = template._id;
if (config !== undefined) site.config = config;
site.updatedBy = user._id;
}
const wasNew = site.isNew;
await site.save();
const populated = await EventStaticMicrosite.findById(site._id)
.populate('template', 'slug name description thumbnailUrl version assetsBaseUrl')
.lean();
return h.response(success(populated, wasNew ? 'Created' : 'Updated'));
};
const checkSubdomain = async (request, h) => {
const { event, forbidden } = await loadEventForUser(request.params.eventId, request.auth.credentials.user);
if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const normalized = normalizeSubdomain(request.query.subdomain);
const site = await EventStaticMicrosite.findOne({ event: event._id }).select('_id').lean();
const avail = await subdomainAvailability(
normalized,
event._id.toString(),
site?._id?.toString(),
);
return h.response(success(avail));
};
const publishForEvent = async (request, h) => {
const user = request.auth.credentials.user;
const { event, forbidden } = await loadEventForUser(request.params.eventId, user);
if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const site = await EventStaticMicrosite.findOne({ event: event._id }).populate('template');
if (!site) return h.response(error('NOT_FOUND', 'Static microsite not configured')).code(404);
try {
const result = await publishStaticMicrosite({
staticMicrosite: site,
template: site.template,
userId: user._id,
});
Object.assign(site, result);
await site.save();
const populated = await EventStaticMicrosite.findById(site._id)
.populate('template', 'slug name description thumbnailUrl version assetsBaseUrl')
.lean();
return h.response(success({ ...populated, publicUrl: result.publicUrl }, 'Published'));
} catch (e) {
return h.response(error('PUBLISH_ERROR', e.message)).code(400);
}
};
const previewForEvent = async (request, h) => {
const { event, forbidden } = await loadEventForUser(request.params.eventId, request.auth.credentials.user);
if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const site = await EventStaticMicrosite.findOne({ event: event._id }).populate('template');
if (!site) return h.response(error('NOT_FOUND', 'Static microsite not configured')).code(404);
try {
const assetsBaseUrl =
localPreviewAssetsBaseUrl(site.template?.slug, request) || undefined;
const html = await renderStaticMicrositeHtml({
template: site.template,
config: site.config,
eventId: event._id,
subdomain: site.subdomain,
assetsBaseUrl,
});
return h.response(html).type('text/html');
} catch (e) {
return h.response(error('RENDER_ERROR', e.message)).code(400);
}
};
module.exports = {
listTemplates,
listAdminTemplates,
getTemplate,
createTemplate,
updateTemplate,
duplicateTemplate,
removeTemplate,
uploadTemplateFiles,
previewTemplateHtml,
serveLocalTemplateAsset,
getForEvent,
upsertForEvent,
checkSubdomain,
publishForEvent,
previewForEvent,
};