253 lines
9.6 KiB
JavaScript
253 lines
9.6 KiB
JavaScript
const mongoose = require('mongoose');
|
|
const Content = require('../models/Content');
|
|
const LibraryContent = require('../models/LibraryContent');
|
|
const ContentCollection = require('../models/ContentCollection');
|
|
|
|
const LIBRARY_MODULE = 'content-library';
|
|
const { success, error } = require('../utils/response');
|
|
const { resolveScopedOrganizationId } = require('../utils/tenantListScope');
|
|
const { uploadBufferToS3AndGetCloudFrontUrl, deleteS3ObjectByKey } = require('../utils/s3Media');
|
|
|
|
function parseTagsFromPayload(payload) {
|
|
const raw = payload.tags;
|
|
if (raw == null) return [];
|
|
if (Array.isArray(raw)) return raw.map((t) => String(t).trim()).filter(Boolean);
|
|
if (typeof raw === 'string') {
|
|
const s = raw.trim();
|
|
if (!s) return [];
|
|
try {
|
|
const j = JSON.parse(s);
|
|
if (Array.isArray(j)) return j.map((t) => String(t).trim()).filter(Boolean);
|
|
} catch {
|
|
/* comma-separated */
|
|
}
|
|
return s.split(/[,;\n]+/).map((t) => t.trim()).filter(Boolean);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function inferContentType(mimeType, payloadType) {
|
|
if (payloadType && ['image', 'pdf', 'video', 'document', 'other'].includes(payloadType)) return payloadType;
|
|
const m = String(mimeType || '').toLowerCase();
|
|
if (m.startsWith('image/')) return 'image';
|
|
if (m.startsWith('video/')) return 'video';
|
|
if (m === 'application/pdf') return 'pdf';
|
|
if (m.startsWith('text/') || m.includes('document') || m.includes('msword')) return 'document';
|
|
return 'document';
|
|
}
|
|
|
|
function normalizeVisibility(v) {
|
|
const s = String(v || '').toLowerCase();
|
|
if (s === 'public') return 'public';
|
|
return 'private';
|
|
}
|
|
|
|
/**
|
|
* Hapi route payload.output only allows data|stream|file. With output:'data', binary file parts are often
|
|
* a raw Buffer on payload.file (see @hapi/subtext internals.part). Some clients may send { _data } or { payload }.
|
|
*/
|
|
function extractUploadFile(payload) {
|
|
const file = payload.file;
|
|
if (!file) return null;
|
|
|
|
if (Buffer.isBuffer(file)) {
|
|
return {
|
|
buffer: file,
|
|
filename: (typeof payload.name === 'string' && payload.name.trim()) || 'file',
|
|
mimeType: 'application/octet-stream',
|
|
};
|
|
}
|
|
|
|
if (typeof file === 'object' && file.payload && Buffer.isBuffer(file.payload)) {
|
|
return {
|
|
buffer: file.payload,
|
|
filename: file.filename || (typeof payload.name === 'string' && payload.name) || 'file',
|
|
mimeType: file.headers?.['content-type'] || 'application/octet-stream',
|
|
};
|
|
}
|
|
|
|
if (typeof file === 'object' && file._data && Buffer.isBuffer(file._data)) {
|
|
return {
|
|
buffer: file._data,
|
|
filename: file.filename || (typeof payload.name === 'string' && payload.name) || 'file',
|
|
mimeType: file.headers?.['content-type'] || 'application/octet-stream',
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function listOrgFilter(user, query) {
|
|
const scoped = resolveScopedOrganizationId(user, query.organization);
|
|
if (scoped.error) return { error: scoped.error };
|
|
return { filter: { organization: scoped.organizationId } };
|
|
}
|
|
|
|
/** Non-library uploads (events, speakers, etc.) — `Content` collection. */
|
|
const list = async (request, h) => {
|
|
const user = request.auth.credentials.user;
|
|
const orgRes = listOrgFilter(user, request.query);
|
|
if (orgRes.error) return h.response(error(orgRes.error.code, orgRes.error.message)).code(400);
|
|
const filter = { ...orgRes.filter };
|
|
if (request.query.type) filter.type = request.query.type;
|
|
if (request.query.collectionName) filter.collectionName = request.query.collectionName;
|
|
if (request.query.eventId || request.query.event_id) filter.eventId = request.query.eventId || request.query.event_id;
|
|
const { page = 1, limit = 20 } = request.query;
|
|
const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
|
|
const [media, total] = await Promise.all([
|
|
Content.find(filter).populate({ path: 'uploadedBy', select: 'firstName lastName email phone' }).skip(skip).limit(parseInt(limit, 10)).lean(),
|
|
Content.countDocuments(filter),
|
|
]);
|
|
return h.response(success({ media, total, page: parseInt(page, 10), limit: parseInt(limit, 10) }))
|
|
.header('X-Total-Count', total);
|
|
};
|
|
|
|
/** Portfolio → Library only — `LibraryContent` collection. */
|
|
const listLibrary = async (request, h) => {
|
|
const user = request.auth.credentials.user;
|
|
const orgRes = listOrgFilter(user, request.query);
|
|
if (orgRes.error) return h.response(error(orgRes.error.code, orgRes.error.message)).code(400);
|
|
const filter = { ...orgRes.filter };
|
|
if (request.query.type) filter.type = request.query.type;
|
|
const { page = 1, limit = 20 } = request.query;
|
|
const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
|
|
const [media, total] = await Promise.all([
|
|
LibraryContent.find(filter).populate({ path: 'uploadedBy', select: 'firstName lastName email phone' }).sort({ createdAt: -1 }).skip(skip).limit(parseInt(limit, 10)).lean(),
|
|
LibraryContent.countDocuments(filter),
|
|
]);
|
|
return h.response(success({ media, total, page: parseInt(page, 10), limit: parseInt(limit, 10) }))
|
|
.header('X-Total-Count', total);
|
|
};
|
|
|
|
const upload = async (request, h) => {
|
|
const user = request.auth.credentials.user;
|
|
const orgId = user.organization?._id || user.organization;
|
|
if (!orgId) return h.response(error('VALIDATION_ERROR', 'Organization required')).code(400);
|
|
const payload = request.payload;
|
|
const extracted = extractUploadFile(payload);
|
|
|
|
// Required by your storage scheme: eventbuzz/<orgId>/<eventId>/<module>/...
|
|
const eventId = payload.eventId || payload.event_id || payload.event || '';
|
|
const module = payload.module || payload.collectionName || 'general';
|
|
const isLibraryUpload = String(module) === LIBRARY_MODULE;
|
|
|
|
let url = payload.url || '';
|
|
let size = 0;
|
|
let mimeType = '';
|
|
let s3Key = '';
|
|
|
|
if (extracted) {
|
|
const { buffer, filename: origName, mimeType: ct } = extracted;
|
|
const filename = `${Date.now()}-${(origName || 'file').replace(/[^a-zA-Z0-9.-]/g, '_')}`;
|
|
mimeType = ct || 'application/octet-stream';
|
|
|
|
const uploaded = await uploadBufferToS3AndGetCloudFrontUrl({
|
|
buffer,
|
|
contentType: mimeType,
|
|
orgId,
|
|
eventId,
|
|
module,
|
|
filename,
|
|
});
|
|
|
|
url = uploaded.url;
|
|
s3Key = uploaded.s3Key;
|
|
size = buffer.length;
|
|
} else if (!payload.url) {
|
|
return h
|
|
.response(
|
|
error(
|
|
'VALIDATION_ERROR',
|
|
'No file received. Send multipart/form-data with a field named "file" (binary). Do not use JSON body for uploads.',
|
|
),
|
|
)
|
|
.code(400);
|
|
}
|
|
|
|
const collRaw = payload.contentCollectionId || payload.content_collection_id;
|
|
if (
|
|
isLibraryUpload &&
|
|
collRaw &&
|
|
mongoose.Types.ObjectId.isValid(String(collRaw))
|
|
) {
|
|
const collId = new mongoose.Types.ObjectId(String(collRaw));
|
|
const collOk = await ContentCollection.exists({ _id: collId, organization: orgId });
|
|
if (!collOk) {
|
|
return h.response(error('VALIDATION_ERROR', 'Invalid content collection for this organization')).code(400);
|
|
}
|
|
}
|
|
|
|
const type = inferContentType(mimeType, payload.type);
|
|
const tags = parseTagsFromPayload(payload);
|
|
const description = typeof payload.description === 'string' ? payload.description.trim() : '';
|
|
const visibility = normalizeVisibility(payload.visibility);
|
|
|
|
const baseDoc = {
|
|
organization: orgId,
|
|
eventId: eventId || undefined,
|
|
name: payload.name || extracted?.filename || 'Untitled',
|
|
description,
|
|
visibility,
|
|
type,
|
|
url,
|
|
size,
|
|
mimeType,
|
|
tags,
|
|
collectionName: payload.collectionName,
|
|
s3Key,
|
|
uploadedBy: user._id,
|
|
};
|
|
|
|
const media = isLibraryUpload
|
|
? await LibraryContent.create(baseDoc)
|
|
: await Content.create(baseDoc);
|
|
|
|
if (collRaw && mongoose.Types.ObjectId.isValid(String(collRaw)) && isLibraryUpload) {
|
|
const collId = new mongoose.Types.ObjectId(String(collRaw));
|
|
await ContentCollection.updateOne({ _id: collId, organization: orgId }, { $addToSet: { contentIds: media._id } });
|
|
}
|
|
|
|
return h.response(success(media, 'File uploaded', 201)).code(201);
|
|
};
|
|
|
|
const get = async (request, h) => {
|
|
let media = await Content.findById(request.params.id);
|
|
if (!media) media = await LibraryContent.findById(request.params.id);
|
|
if (!media) return h.response(error('NOT_FOUND', 'File not found')).code(404);
|
|
const user = request.auth.credentials.user;
|
|
if (user.role !== 'super_admin' && media.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) {
|
|
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
|
|
}
|
|
return h.response(success(media));
|
|
};
|
|
|
|
const remove = async (request, h) => {
|
|
let media = await Content.findById(request.params.id);
|
|
if (!media) media = await LibraryContent.findById(request.params.id);
|
|
if (!media) return h.response(error('NOT_FOUND', 'File not found')).code(404);
|
|
const user = request.auth.credentials.user;
|
|
if (user.role !== 'super_admin' && media.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) {
|
|
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
|
|
}
|
|
|
|
// Prefer S3 deletion if we have the key, otherwise fall back to legacy local /uploads deletion.
|
|
if (media.s3Key) {
|
|
await deleteS3ObjectByKey({ s3Key: media.s3Key });
|
|
} else if (media.url && media.url.startsWith('/uploads/')) {
|
|
// Legacy fallback (optional): local file deletion if still present.
|
|
try {
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const filepath = path.join(process.cwd(), media.url);
|
|
if (fs.existsSync(filepath)) fs.unlinkSync(filepath);
|
|
} catch {}
|
|
}
|
|
|
|
const id = media._id;
|
|
await media.deleteOne();
|
|
await ContentCollection.updateMany({ contentIds: id }, { $pull: { contentIds: id } });
|
|
return h.response(success(null, 'File deleted')).code(200);
|
|
};
|
|
|
|
module.exports = { list, listLibrary, upload, get, remove };
|