// controllers/admin/assets.controller.js const path = require("path"); const crypto = require("crypto"); const sequelize = require("../../config/db.config"); const Asset = require("../../models/assets/assets.mdl"); const chibi = require("../../services/chibisafe.service"); const { extractVideoMeta } = require("../../services/ffprobe.service"); const R = require('../../utils/response.util'); const { paginate } = require("../../utils/paginate.util"); const { adminExclude, jsonbSchemas, computedAttributes, } = require("../../models/assets/assets.attributes"); const mdl_Users = require('../../models/users/users.mdl'); const { Op, Sequelize } = require('sequelize'); const auditByFields = ['createdBy', 'updatedBy', 'deletedBy']; // ─── Helpers ────────────────────────────────────────────────────────────────── const notDeleted = { deletedAt: null }; function resolveFileType(mimeType = "") { if (mimeType.startsWith("image/")) return "image"; if (mimeType.startsWith("video/")) return "video"; if (mimeType.startsWith("application/") || mimeType.startsWith("text/")) return "document"; return "image"; } function resolveExtension(originalName = "") { return path.extname(originalName).replace(".", "").toLowerCase() || null; } function resolveChecksum(buffer) { return crypto.createHash("sha256").update(buffer).digest("hex"); } function resolveResolution(width, height) { if (!width || !height) return null; const h = Math.min(width, height); if (h >= 2160) return "4K"; if (h >= 1440) return "1440p"; if (h >= 1080) return "1080p"; if (h >= 720) return "720p"; if (h >= 480) return "480p"; if (h >= 360) return "360p"; if (h >= 240) return "240p"; return `${width}x${height}`; } // ─── Helpers ────────────────────────────────────────────────────────────────── async function uploadToAlbum(file, ownerType) { const mime_type = file.mimetype; const extension = resolveExtension(file.originalname); const checksum = resolveChecksum(file.buffer); const file_type = resolveFileType(mime_type); const chibiResult = await chibi.uploadFile({ buffer: file.buffer, originalname: file.originalname, mimetype: mime_type, ownerType, }); return { file_url: chibiResult.url, chibi_uuid: chibiResult.uuid, mime_type, extension, checksum, file_type, }; } async function applyAssetUpdate(asset, file, body) { const isThumbnailOnly = asset.file_type === "video" && !!file; if (body.display_name !== undefined) asset.display_name = body.display_name; if (body.description !== undefined) asset.description = body.description; if (body.is_public !== undefined) asset.is_public = body.is_public === "true" || body.is_public === true; asset.updatedBy = body.updatedBy ?? null; if (file) { if (isThumbnailOnly) { asset.thumbnail_url = file.file_url; asset.thumbnail_storage_key = file.chibi_uuid; } else { asset.original_name = file.originalname; asset.file_url = file.file_url; asset.file_size = file.size; asset.mime_type = file.mime_type; asset.extension = file.extension; asset.checksum = file.checksum; asset.file_type = file.file_type; asset.storage_key = file.chibi_uuid; const parsedWidth = body.width ? parseInt(body.width) : null; const parsedHeight = body.height ? parseInt(body.height) : null; if (parsedWidth || parsedHeight) { asset.width = parsedWidth; asset.height = parsedHeight; asset.resolution = resolveResolution(parsedWidth, parsedHeight); } } } } async function deleteOldFile(asset, oldStorageKey, newUuid) { if (asset.storage_provider !== "chibisafe") return; if (!oldStorageKey || oldStorageKey === newUuid) return; try { await chibi.deleteFile(oldStorageKey); } catch (err) { console.warn("[ASSET][REPLACE FILE] Old file cleanup failed:", err.message); } } /** * Best-effort cleanup of Chibisafe files after a failed transaction. * Never throws — the original error is what matters. */ async function rollbackChibiUploads(uuids = []) { for (const uuid of uuids) { if (!uuid) continue; try { await chibi.deleteFile(uuid); } catch (cleanupErr) { console.error(`[ASSET][ROLLBACK] Failed to delete Chibisafe file ${uuid}:`, cleanupErr.message); } } } // ─── GET ALL ────────────────────────────────────────────────────────────────── exports.getAssets = async (req, res) => { try { const result = await paginate(Asset, req, { excludeAttributes: adminExclude, jsonbSchemas, computedAttributes, context: "list", auditOptions: { mdl_Users, parentAlias: 'Asset' }, findOptions: { where: { deletedAt: null }, }, }); return R.success(res, "Assets retrieved.", result); } catch (err) { console.error("[ASSET][GET ALL]", err); return R.error(res, "Could not retrieve assets.", 500); } }; // ─── GET ONE ────────────────────────────────────────────────────────────────── exports.getAsset = async (req, res) => { try { const { assetId } = req.params; if (!assetId || assetId === "undefined") { return R.error(res, "Invalid asset ID.", 400); } const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted }, attributes: { exclude: ["storage_key", "storage_bucket"] }, include: [ { model: mdl_Users, as: "creator", attributes: ["user_id", "personal_info"], foreignKey: "createdBy", }, { model: mdl_Users, as: "updater", attributes: ["user_id", "personal_info"], foreignKey: "updatedBy", }, ], }); if (!asset) return R.error(res, "Asset not found.", 404); // ── Flatten creator / updater name from personal_info JSONB ────────────── const json = asset.toJSON(); if (json.creator) { json.creator = { user_id: json.creator.user_id, full_name: json.creator.personal_info?.name?.full_name ?? null, }; } if (json.updater) { json.updater = { user_id: json.updater.user_id, full_name: json.updater.personal_info?.name?.full_name ?? null, }; } return R.success(res, "Asset retrieved.", { data: json }); } catch (err) { console.error("[ASSET][GET ONE]", err); return R.error(res, "Internal server error.", 500); } }; // ─── UPLOAD ─────────────────────────────────────────────────────────────────── // // ┌─────────────────────────────────────────────────────────────────────────┐ // │ TRANSACTION STRATEGY │ // │ │ // │ Phase 1 — SLOW WORK (no transaction, no DB connection held): │ // │ • Input validation │ // │ • ffprobe metadata extraction │ // │ • Chibisafe file upload → track UUID for rollback │ // │ • Chibisafe thumb upload → track UUID for rollback │ // │ │ // │ Phase 2 — FAST WORK (transaction open for milliseconds only): │ // │ • BEGIN transaction │ // │ • Asset.create() │ // │ • COMMIT │ // │ │ // │ On any Phase 2 error: │ // │ • ROLLBACK transaction │ // │ • deleteFile() each tracked Chibisafe UUID (cleanup orphans) │ // └─────────────────────────────────────────────────────────────────────────┘ // // Expects multer.fields([{ name: "file", maxCount: 1 }, { name: "thumbnail", maxCount: 1 }]) exports.uploadAsset = async (req, res) => { const uploadedChibiUuids = []; try { // ── Phase 1a: Validate inputs ───────────────────────────────────────────── const file = req.files?.file?.[0]; const thumbFile = req.files?.thumbnail?.[0]; if (!file) return R.error(res, "No file uploaded.", 400); const { display_name, description, is_public = false, storage_provider = "local", storage_bucket, storage_key, createdBy, } = req.body; if (!createdBy) { return R.error(res, "createdBy is required.", 400); } const mime_type = file.mimetype; const file_type = resolveFileType(mime_type); const extension = resolveExtension(file.originalname); const checksum = file.buffer ? resolveChecksum(file.buffer) : null; if (file_type === "video" && !thumbFile) { return R.error(res, "A thumbnail image is required for video uploads. Include it as the 'thumbnail' field.", 400); } if (storage_provider === "chibisafe" && !file.buffer) { return R.error(res, "File buffer is required for Chibisafe uploads. Ensure multer uses memoryStorage.", 400); } // ── Phase 1b: Upload main file to Chibisafe ─────────────────────────────── let file_url = null; let chibi_uuid = null; if (storage_provider === "chibisafe") { const chibiResult = await chibi.uploadFile({ buffer: file.buffer, originalname: file.originalname, mimetype: mime_type, }); file_url = chibiResult.url; chibi_uuid = chibiResult.uuid; uploadedChibiUuids.push(chibi_uuid); } else { file_url = storage_provider === "local" ? `/uploads/${file.filename}` : req.body.file_url; if (!file_url) { return R.error(res, "file_url is required for non-local storage.", 400); } } // ── Phase 1c: ffprobe + thumbnail upload ────────────────────────────────── let width = null; let height = null; let resolution = null; let duration = null; let frame_rate = null; let bitrate = null; let video_codec = null; let audio_codec = null; let thumbnail_url = null; if (file_type === "video") { const meta = await extractVideoMeta({ buffer: file.buffer, extension: extension || "mp4", }); width = meta.width; height = meta.height; resolution = meta.resolution; duration = meta.duration; frame_rate = meta.frame_rate; bitrate = meta.bitrate; video_codec = meta.video_codec; audio_codec = meta.audio_codec; if (storage_provider === "chibisafe") { if (!thumbFile.buffer) { await rollbackChibiUploads(uploadedChibiUuids); return R.error(res, "Thumbnail buffer is required. Ensure multer uses memoryStorage.", 400); } const baseName = file.originalname.replace(/\.[^.]+$/, ""); const thumbResult = await chibi.uploadFile({ buffer: thumbFile.buffer, originalname: `thumb_${baseName}.${resolveExtension(thumbFile.originalname) || "jpg"}`, mimetype: thumbFile.mimetype, ownerType: "thumbnail", }); thumbnail_url = thumbResult.url; uploadedChibiUuids.push(thumbResult.uuid); } else { thumbnail_url = thumbFile.filename ? `/uploads/${thumbFile.filename}` : null; } } else { const parsedWidth = req.body.width ? parseInt(req.body.width) : null; const parsedHeight = req.body.height ? parseInt(req.body.height) : null; width = parsedWidth; height = parsedHeight; resolution = resolveResolution(parsedWidth, parsedHeight); } // ── Phase 2: DB insert ──────────────────────────────────────────────────── const t = await sequelize.transaction(); try { const asset = await Asset.create({ original_name: file.originalname, display_name: display_name || file.originalname, file_url, file_size: file.size, mime_type, extension, checksum, file_type, width, height, resolution, duration, frame_rate, bitrate, video_codec, audio_codec, thumbnail_url, description, storage_provider, storage_bucket: storage_bucket || null, storage_key: chibi_uuid || storage_key || file.filename, is_public, createdBy, }, { transaction: t }); await t.commit(); return R.success(res, "Asset uploaded.", { data: asset }, 201); } catch (dbErr) { try { await t.rollback(); } catch { /* connection already gone */ } await rollbackChibiUploads(uploadedChibiUuids); throw dbErr; } } catch (err) { console.error("[ASSET][UPLOAD]", err); if (err.status) { return R.error(res, err.message, err.status, { detail: err.chibiBody }); } return R.error(res, "Internal server error.", 500); } }; // ─── ARCHIVE (single) ───────────────────────────────────────────────────────── exports.archiveAsset = async (req, res) => { try { const { assetId } = req.params; if (!assetId || assetId === "undefined") return R.error(res, "Invalid asset ID.", 400); const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } }); if (!asset) return R.error(res, "Asset not found.", 404); await asset.update({ deletedBy: req.body.deletedBy ?? null }); await asset.destroy(); return R.success(res, "Asset archived."); } catch (err) { console.error("[ASSET][ARCHIVE]", err); return R.error(res, "Internal server error.", 500); } }; // ─── ARCHIVE (bulk) ─────────────────────────────────────────────────────────── exports.archiveAssets = async (req, res) => { try { const { ids, deletedBy } = req.body; if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400); const assets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids }, ...notDeleted } }); if (!assets.length) return R.error(res, "No assets found.", 404); const activeIds = assets.map((a) => a.asset_id); await Asset.update( { deletedBy: deletedBy ?? null }, { where: { asset_id: { [Op.in]: activeIds } } } ); await Asset.destroy({ where: { asset_id: { [Op.in]: activeIds } } }); return R.success(res, `${activeIds.length} asset(s) archived.`, { archived_ids: activeIds, skipped_ids: ids.filter((id) => !activeIds.includes(id)), }); } catch (err) { console.error("[ASSET][BULK ARCHIVE]", err); return R.error(res, "Internal server error.", 500); } }; // ─── RESTORE (single) ───────────────────────────────────────────────────────── exports.restoreAsset = async (req, res) => { try { const { assetId } = req.params; const asset = await Asset.findOne({ where: { asset_id: assetId }, paranoid: false, }); if (!asset) return R.error(res, "Asset not found.", 404); if (!asset.deletedAt) return R.error(res, "Asset is not archived.", 400); await asset.restore(); await asset.update({ deletedBy: null }); return R.success(res, "Asset restored.", { data: asset }); } catch (err) { console.error("[ASSET][RESTORE]", err); return R.error(res, "Internal server error.", 500); } }; // ─── RESTORE (bulk) ─────────────────────────────────────────────────────────── exports.restoreAssets = async (req, res) => { try { const { ids } = req.body; if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400); const assets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids } }, paranoid: false, }); if (!assets.length) return R.error(res, "No assets found.", 404); const archivedAssets = assets.filter((a) => a.deletedAt); if (!archivedAssets.length) return R.error(res, "All selected assets are already active.", 400); const archivedIds = archivedAssets.map((a) => a.asset_id); await Asset.restore({ where: { asset_id: { [Op.in]: archivedIds } } }); await Asset.update( { deletedBy: null }, { where: { asset_id: { [Op.in]: archivedIds } }, paranoid: false } ); return R.success(res, `${archivedIds.length} asset(s) restored.`, { restored_ids: archivedIds, skipped_ids: ids.filter((id) => !archivedIds.includes(id)), }); } catch (err) { console.error("[ASSET][BULK RESTORE]", err); return R.error(res, "Internal server error.", 500); } }; exports.updateAsset = async (req, res) => { let newChibiUuid = null; try { const { assetId } = req.params; if (!assetId || assetId === "undefined") return R.error(res, "Invalid asset ID.", 400); const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } }); if (!asset) return R.error(res, "Asset not found.", 404); const file = req.files?.file?.[0] ?? req.file ?? null; const isVideo = asset.file_type === "video"; const isDocument = asset.file_type === "document"; if (isDocument && file) return R.error(res, "Document files cannot be replaced.", 400); if (isVideo && file && !req.body.is_thumbnail) return R.error(res, "Video files cannot be replaced. Upload a new asset instead.", 400); if (file && !file.buffer) return R.error(res, "File buffer is required.", 400); // ── Phase 1: Upload ─────────────────────────────────────────────────────── let uploaded = null; let oldStorageKey = isVideo ? asset.thumbnail_storage_key : asset.storage_key; if (file && asset.storage_provider === "chibisafe") { const ownerType = isVideo ? "thumbnail" : resolveFileType(file.mimetype); uploaded = await uploadToAlbum(file, ownerType); newChibiUuid = uploaded.chibi_uuid; } // ── Phase 2: DB update ──────────────────────────────────────────────────── const t = await sequelize.transaction(); try { await applyAssetUpdate(asset, uploaded ? { ...file, ...uploaded } : null, req.body); await asset.save({ transaction: t }); await t.commit(); } catch (dbErr) { try { await t.rollback(); } catch { /* gone */ } if (newChibiUuid) await rollbackChibiUploads([newChibiUuid]); throw dbErr; } // ── Phase 3: Cleanup ────────────────────────────────────────────────────── if (uploaded) await deleteOldFile(asset, oldStorageKey, newChibiUuid); return R.success(res, "Asset updated.", { data: asset }); } catch (err) { if (newChibiUuid) await rollbackChibiUploads([newChibiUuid]); console.error("[ASSET][REPLACE FILE]", err); if (err.status) return R.error(res, err.message, err.status, { detail: err.chibiBody }); return R.error(res, "Internal server error.", 500); } }; exports.getArchivedAssets = async (req, res) => { try { const result = await paginate(Asset, req, { excludeAttributes: adminExclude, jsonbSchemas, computedAttributes, context: "list", auditOptions: { mdl_Users, parentAlias: 'Asset' }, findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } }, }, }); return R.success(res, "Archived assets retrieved.", result); } catch (err) { console.error("[ASSET][GET ARCHIVED]", err); return R.error(res, "Could not retrieve archived assets.", 500); } }; exports.getAssetFieldValues = async (req, res) => { try { const { field } = req.query; if (!field) return R.error(res, "Field is required.", 400); const allowedFields = Object.keys(Asset.rawAttributes); const dateFields = ["createdAt", "updatedAt", "deletedAt"]; if (!allowedFields.includes(field)) return R.error(res, "Invalid or restricted field.", 400); if (auditByFields.includes(field)) { const [rows] = await sequelize.query(` SELECT DISTINCT u."personal_info"->'name'->>'full_name' AS value FROM assets a JOIN users u ON u.user_id = a."${field}" WHERE a."${field}" IS NOT NULL AND u."personal_info"->'name'->>'full_name' IS NOT NULL ORDER BY value ASC `); return R.success(res, "Field values retrieved.", rows.map((r) => r.value).filter(Boolean)); } if (dateFields.includes(field)) { const results = await Asset.findAll({ attributes: [[Sequelize.fn("DISTINCT", Sequelize.fn("DATE", Sequelize.col(field))), "value"]], where: { [field]: { [Op.ne]: null } }, order: [[Sequelize.fn("DATE", Sequelize.col(field)), "DESC"]], raw: true, }); return R.success(res, "Field values retrieved.", results.map((r) => r.value).filter(Boolean)); } const results = await Asset.findAll({ attributes: [[Sequelize.fn("DISTINCT", Sequelize.col(field)), "value"]], where: { [field]: { [Op.ne]: null } }, raw: true, }); return R.success(res, "Field values retrieved.", results.map((r) => r.value).filter(Boolean).sort()); } catch (err) { console.error("[ASSET][GET FIELD VALUES]", err); return R.error(res, "Could not retrieve field values.", 500); } };