// 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 s3 = require("../../services/s3.service"); const mediaToken = require("../../services/mediaToken.service"); const uploadProgress = require("../../services/uploadProgress.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 { getFieldValues } = require("../../utils/fieldValues.util"); const logActivity = require('../../utils/logActivity.util'); const { Op } = require('sequelize'); // ─── Helpers ────────────────────────────────────────────────────────────────── const notDeleted = { deletedAt: null }; // List queries keep storage_key selected (unlike adminExclude) so // attachStreamTokens can sign a stream token server-side without a second // query — it's deleted from every row before the response is sent. const LIST_QUERY_EXCLUDE = adminExclude.filter((f) => f !== "storage_key"); // ─── In-memory list cache (no Redis yet) ─────────────────────────────────────── // Short TTL just to absorb bursts of identical GET /admin/assets calls — e.g. // AssetPickerSheet being opened/closed repeatedly with the same filters — so // Postgres isn't re-queried on every toggle. Cleared on any mutation below. // Single-process only; fine for one instance, won't stay consistent across // multiple app instances without a shared store like Redis. const LIST_CACHE_TTL_MS = 20_000; const listCache = new Map(); // queryKey -> { result, expiresAt } function listCacheKey(req) { return JSON.stringify({ page: req.query.page, limit: req.query.limit, filters: req.query.filters, sort: req.query.sort, }); } function invalidateListCache() { listCache.clear(); } function resolveFileType(mimeType = "") { if (mimeType.startsWith("image/")) return "image"; if (mimeType.startsWith("video/")) return "video"; if (mimeType.startsWith("audio/")) return "audio"; if (mimeType.startsWith("application/") || mimeType.startsWith("text/")) return "document"; } 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}`; } // ─── Provider resolver ──────────────────────────────────────────────────────── // // Returns the correct service module based on storage_provider. // Both chibi and s3 expose the same interface: uploadFile / deleteFile. // function getProvider(storageProvider) { if (storageProvider === "s3") return s3; if (storageProvider === "chibisafe") return chibi; return null; // local / other — no remote provider needed } // ─── rollbackUploads ────────────────────────────────────────────────────────── // // Best-effort cleanup after a failed DB transaction. // uploads: [{ key, provider }] // async function rollbackUploads(uploads = []) { for (const { key, provider } of uploads) { if (!key || !provider) continue; const svc = getProvider(provider); if (!svc) continue; try { await svc.deleteFile(key); } catch (err) { console.error(`[ASSET][ROLLBACK] Failed to delete "${key}" from "${provider}":`, err.message); } } } // ─── uploadToProvider ───────────────────────────────────────────────────────── // // Uploads a single file to the resolved provider. // Returns { file_url, storage_key, mime_type, extension, checksum, file_type } // async function uploadToProvider(file, ownerType, storageProvider) { const mime_type = file.mimetype; const extension = resolveExtension(file.originalname); const checksum = resolveChecksum(file.buffer); const file_type = resolveFileType(mime_type); const svc = getProvider(storageProvider); const result = await svc.uploadFile({ buffer: file.buffer, originalname: file.originalname, mimetype: mime_type, ownerType, }); return { file_url: result.url, storage_key: result.uuid, // chibisafe UUID or S3 key — both stored as storage_key in DB mime_type, extension, checksum, file_type, }; } // ─── applyAssetUpdate ───────────────────────────────────────────────────────── 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.storage_key; } 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.storage_key; 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); } } } } // ─── deleteOldFile ──────────────────────────────────────────────────────────── async function deleteOldFile(storageProvider, oldStorageKey, newKey) { if (!oldStorageKey || oldStorageKey === newKey) return; const svc = getProvider(storageProvider); if (!svc) return; try { await svc.deleteFile(oldStorageKey); } catch (err) { console.warn(`[ASSET][CLEANUP] Old file cleanup failed for "${oldStorageKey}":`, err.message); } } // ─── Helper: hide S3 file_url from responses ────────────────────────────────── // // The raw S3 presigned/public URL is never sent to any browser. // Admin viewers request a short-lived stream token instead // (POST /api/admin/media/token → GET /api/client/media/stream/:token). // Chibisafe assets keep their file_url (CDN public URL, no proxy needed). // function redactS3Url(asset) { if (asset?.storage_provider === "s3") asset.file_url = null; return asset; } // ─── attachStreamTokens ───────────────────────────────────────────────────── // // Embeds a stream_token (+ presigned thumbnail_url) directly into each S3 row // so pickers/tables reading the list can render thumbnails immediately instead // of firing a second POST /admin/media/tokens round-trip and waiting on it. // storage_key is kept out of the DB attribute exclude list (unlike the rest of // adminExclude) purely so it's available here to sign the token — it's still // stripped from every row before the response goes out. // // Operates on shallow copies: `result.data` is shared with listCache, and // mutating those rows in place would delete storage_key from the cached // objects, breaking token issuance for the next request that hits the cache. // async function attachStreamTokens(rows, req) { const ip = mediaToken.resolveIp(req); const userId = req.user?.user_id; return Promise.all(rows.map(async (original) => { const row = { ...original }; const eligible = row.storage_provider === "s3" && mediaToken.SUPPORTED_TYPES.includes(row.file_type); if (eligible) { const { token, thumbnail_url } = await mediaToken.issueForAsset(row, userId, ip); row.stream_token = token; if (thumbnail_url) row.thumbnail_url = thumbnail_url; } delete row.storage_key; return row; })); } // ─── GET ALL ────────────────────────────────────────────────────────────────── exports.getAssets = async (req, res) => { try { const key = listCacheKey(req); const cached = listCache.get(key); let result; if (cached && Date.now() < cached.expiresAt) { result = cached.result; } else { result = await paginate(Asset, req, { excludeAttributes: LIST_QUERY_EXCLUDE, jsonbSchemas, computedAttributes, context: "list", auditOptions: { mdl_Users, parentAlias: 'Asset' }, findOptions: { where: { deletedAt: null } }, }); result.data = result.data.map(redactS3Url); listCache.set(key, { result, expiresAt: Date.now() + LIST_CACHE_TTL_MS }); } const data = await attachStreamTokens(result.data, req); return R.success(res, "Assets retrieved.", { ...result, data }); } 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 }, // storage_key stays selected here (unlike the list query) so it's // available below to sign a stream token — stripped before the response. attributes: { exclude: ["storage_bucket"] }, include: [ { model: mdl_Users, as: "creator", attributes: ["user_id", "email", "personal_info"], foreignKey: "createdBy" }, { model: mdl_Users, as: "updater", attributes: ["user_id", "email", "personal_info"], foreignKey: "updatedBy" }, ], }); if (!asset) return R.error(res, "Asset not found.", 404); const json = asset.toJSON(); // Falls back to email when full_name hasn't been filled in — better than // surfacing the raw numeric user_id in the admin UI. if (json.creator) { json.creator = { user_id: json.creator.user_id, full_name: json.creator.personal_info?.name?.full_name || json.creator.email || null, }; } if (json.updater) { json.updater = { user_id: json.updater.user_id, full_name: json.updater.personal_info?.name?.full_name || json.updater.email || null, }; } if (json.storage_provider === "s3" && mediaToken.SUPPORTED_TYPES.includes(json.file_type)) { const ip = mediaToken.resolveIp(req); const { token, thumbnail_url } = await mediaToken.issueForAsset(json, req.user?.user_id, ip); json.stream_token = token; if (thumbnail_url) json.thumbnail_url = thumbnail_url; } delete json.storage_key; redactS3Url(json); 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 (shared core) ────────────────────────────────────────────────────── // // ┌─────────────────────────────────────────────────────────────────────────┐ // │ TRANSACTION STRATEGY │ // │ │ // │ Phase 1 — SLOW WORK (outside transaction): │ // │ • Input validation │ // │ • ffprobe metadata extraction │ // │ • Provider upload (chibi or s3) → track for rollback │ // │ • Thumbnail upload → track for rollback │ // │ │ // │ Phase 2 — FAST WORK (transaction open milliseconds only): │ // │ • BEGIN → Asset.create() → COMMIT │ // │ │ // │ On any error: │ // │ • ROLLBACK transaction (if opened) │ // │ • rollbackUploads([{ key, provider }]) to clean orphans │ // └─────────────────────────────────────────────────────────────────────────┘ // // Shared by the single-file POST / and the multi-file POST /batch routes. // `requireVideoThumbnail` is false for batch uploads — a bulk drop has no // per-file thumbnail step, so videos land with thumbnail_url null and pick // one up later via the existing "thumbnail-only" path in updateAsset(). // async function createAssetFromUpload({ file, thumbFile, body, user, requireVideoThumbnail = true, uploadId }) { const uploadedFiles = []; // [{ key, provider }] try { if (!file) throw Object.assign(new Error("No file uploaded."), { status: 400 }); const { display_name, description, is_public = false, // TEMPORARY: defaulting to "s3" instead of "chibisafe" — zrok tunnel is // dropping Chibisafe uploads (502s). Revert this default once off zrok/VPS migration. storage_provider = "s3", storage_bucket, storage_key, createdBy, } = body; if (!createdBy) throw Object.assign(new Error("createdBy is required."), { status: 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; const usesProvider = ["chibisafe", "s3"].includes(storage_provider); if (file_type === "video" && requireVideoThumbnail && !thumbFile) { throw Object.assign(new Error("A thumbnail image is required for video uploads."), { status: 400 }); } if (usesProvider && !file.buffer) { throw Object.assign(new Error("File buffer is required. Ensure multer uses memoryStorage."), { status: 400 }); } // ── Phase 1b: Upload main file ──────────────────────────────────────────── let file_url = null; let storage_key_resolved = null; if (usesProvider) { const svc = getProvider(storage_provider); // Real Express -> Garage progress, main file only (not the thumbnail — // it's small enough that tracking it wouldn't add anything useful). // Relayed live to the browser over SSE; see uploadProgress.service.js. const onProgress = (storage_provider === "s3" && uploadId) ? ({ loaded, total }) => uploadProgress.publish(uploadId, { phase: "storing", loaded, total, pct: total ? Math.round((loaded / total) * 100) : 0, }) : undefined; const result = await svc.uploadFile({ buffer: file.buffer, originalname: file.originalname, mimetype: mime_type, ownerType: file_type, onProgress, }); file_url = result.url; storage_key_resolved = result.uuid; uploadedFiles.push({ key: storage_key_resolved, provider: storage_provider }); } else { file_url = storage_provider === "local" ? `/uploads/${file.filename}` : body.file_url; if (!file_url) throw Object.assign(new Error("file_url is required for non-local storage."), { status: 400 }); } // ── Phase 1c: ffprobe + thumbnail ───────────────────────────────────────── let width = null, height = null, resolution = null; let duration = null, frame_rate = null, bitrate = null; let video_codec = null, audio_codec = null; let thumbnail_url = null; if (file_type === "video" || file_type === "audio") { const meta = await extractVideoMeta({ buffer: file.buffer, extension: extension || (file_type === "video" ? "mp4" : "mp3") }); 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 (usesProvider && thumbFile) { if (!thumbFile.buffer) { await rollbackUploads(uploadedFiles); throw Object.assign(new Error("Thumbnail buffer is required."), { status: 400 }); } const baseName = file.originalname.replace(/\.[^.]+$/, ""); const svc = getProvider(storage_provider); const thumbResult = await svc.uploadFile({ buffer: thumbFile.buffer, originalname: `thumb_${baseName}.${resolveExtension(thumbFile.originalname) || "jpg"}`, mimetype: thumbFile.mimetype, ownerType: "thumbnail", }); thumbnail_url = thumbResult.url; uploadedFiles.push({ key: thumbResult.uuid, provider: storage_provider }); } else if (!usesProvider) { thumbnail_url = thumbFile?.filename ? `/uploads/${thumbFile.filename}` : null; } } else { const parsedWidth = body.width ? parseInt(body.width) : null; const parsedHeight = body.height ? parseInt(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 || (storage_provider === "s3" ? process.env.S3_BUCKET : null) || null, storage_key: storage_key_resolved || storage_key || file.filename || null, is_public, createdBy, }, { transaction: t }); await t.commit(); logActivity(user?.user_id, 'upload_asset', { entityType: 'asset', entityId: asset.asset_id, details: { display_name: asset.display_name, file_type: asset.file_type } }); return asset; } catch (dbErr) { try { await t.rollback(); } catch { /* connection gone */ } await rollbackUploads(uploadedFiles); throw dbErr; } } catch (err) { await rollbackUploads(uploadedFiles); throw err; } } exports.uploadAsset = async (req, res) => { const { uploadId } = req.body; try { const file = req.files?.file?.[0]; const thumbFile = req.files?.thumbnail?.[0]; const asset = await createAssetFromUpload({ file, thumbFile, body: req.body, user: req.user, uploadId }); invalidateListCache(); if (uploadId) uploadProgress.complete(uploadId, { phase: "done", pct: 100 }); return R.success(res, "Asset uploaded.", { data: asset }, 201); } catch (err) { console.error("[ASSET][UPLOAD]", err); if (uploadId) uploadProgress.complete(uploadId, { phase: "error" }); if (err.status) return R.error(res, err.message, err.status, { detail: err.chibiBody }); return R.error(res, "Internal server error.", 500); } }; // ─── UPLOAD PROGRESS (SSE) ────────────────────────────────────────────────── // // Client opens this before POSTing the file, correlated by a client-generated // uploadId sent as a form field on the upload request itself. Streams the // real Express -> Garage httpUploadProgress events from s3.service.js's // Upload — not a simulated or estimated number. // exports.streamUploadProgress = (req, res) => { uploadProgress.subscribe(req.params.uploadId, res); }; // ─── UPLOAD (batch) ───────────────────────────────────────────────────────── // // Accepts multiple files under the "files" field in one multipart request, // all sharing the same is_public / storage_provider / createdBy. Each file // is uploaded independently — one failing (bad codec, DB constraint, etc.) // does not roll back the others. display_name defaults to the filename // (minus extension) since there's no per-file metadata step in bulk mode. // Videos land without a thumbnail (see createAssetFromUpload) — add one // later via the existing "replace thumbnail" path on PATCH /:assetId. // exports.uploadAssetsBatch = async (req, res) => { const files = req.files ?? []; if (!files.length) return R.error(res, "No files uploaded.", 400); // TEMPORARY: defaulting to "s3" instead of "chibisafe" — zrok tunnel is // dropping Chibisafe uploads (502s). Revert this default once off zrok/VPS migration. const { display_name, description, is_public = false, storage_provider = "s3", createdBy } = req.body; if (!createdBy) return R.error(res, "createdBy is required.", 400); const results = []; for (const file of files) { try { const baseName = file.originalname.replace(/\.[^.]+$/, ""); const asset = await createAssetFromUpload({ file, thumbFile: null, body: { display_name: files.length === 1 ? (display_name || baseName) : baseName, description, is_public, storage_provider, createdBy, }, user: req.user, requireVideoThumbnail: false, }); results.push({ originalname: file.originalname, success: true, data: asset }); } catch (err) { console.error("[ASSET][UPLOAD BATCH]", file.originalname, err.stack || err); results.push({ originalname: file.originalname, success: false, message: err.status ? err.message : "Internal server error." }); } } invalidateListCache(); const createdCount = results.filter((r) => r.success).length; return R.success(res, `${createdCount} of ${files.length} asset(s) uploaded.`, { results }, 201); }; // ─── UPDATE ─────────────────────────────────────────────────────────────────── exports.updateAsset = async (req, res) => { let newUpload = null; // { key, provider } 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); const storageProvider = asset.storage_provider; const usesProvider = ["chibisafe", "s3"].includes(storageProvider); const oldStorageKey = isVideo ? asset.thumbnail_storage_key : asset.storage_key; // ── Phase 1: Upload ─────────────────────────────────────────────────────── let uploaded = null; if (file && usesProvider) { const ownerType = isVideo ? "thumbnail" : resolveFileType(file.mimetype); uploaded = await uploadToProvider(file, ownerType, storageProvider); newUpload = { key: uploaded.storage_key, provider: storageProvider }; } // ── 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 (newUpload) await rollbackUploads([newUpload]); throw dbErr; } // ── Phase 3: Cleanup old file ───────────────────────────────────────────── if (uploaded) await deleteOldFile(storageProvider, oldStorageKey, uploaded.storage_key); invalidateListCache(); logActivity(req.user?.user_id, 'update_asset', { entityType: 'asset', entityId: Number(assetId) }); return R.success(res, "Asset updated.", { data: asset }); } catch (err) { if (newUpload) await rollbackUploads([newUpload]); console.error("[ASSET][UPDATE]", 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(); invalidateListCache(); logActivity(req.user?.user_id, 'archive_asset', { entityType: 'asset', entityId: Number(assetId) }); 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 } } }); invalidateListCache(); logActivity(req.user?.user_id, 'bulk_archive_assets', { entityType: 'asset', details: { ids: activeIds, count: activeIds.length } }); 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 }); invalidateListCache(); logActivity(req.user?.user_id, 'restore_asset', { entityType: 'asset', entityId: Number(assetId) }); 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 }); invalidateListCache(); logActivity(req.user?.user_id, 'bulk_restore_assets', { entityType: 'asset', details: { ids: archivedIds, count: archivedIds.length } }); 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); } }; // ─── PERMANENT DELETE (single) ───────────────────────────────────────────────── exports.permanentlyDeleteAsset = 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 must be archived before it can be permanently deleted.", 400); const { storage_provider, storage_key, thumbnail_storage_key } = asset; await asset.destroy({ force: true }); const svc = getProvider(storage_provider); if (svc) { if (storage_key) { try { await svc.deleteFile(storage_key); } catch (err) { console.warn(`[ASSET][PERMANENT DELETE] File cleanup failed for "${storage_key}":`, err.message); } } if (thumbnail_storage_key) { try { await svc.deleteFile(thumbnail_storage_key); } catch (err) { console.warn(`[ASSET][PERMANENT DELETE] Thumbnail cleanup failed for "${thumbnail_storage_key}":`, err.message); } } } invalidateListCache(); logActivity(req.user?.user_id, 'permanently_delete_asset', { entityType: 'asset', entityId: Number(assetId) }); return R.success(res, "Asset permanently deleted."); } catch (err) { console.error("[ASSET][PERMANENT DELETE]", err); return R.error(res, "Internal server error.", 500); } }; // ─── PERMANENT DELETE (bulk) ─────────────────────────────────────────────────── exports.permanentlyDeleteAssets = 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 must be archived before they can be permanently deleted.", 400); const archivedIds = archivedAssets.map((a) => a.asset_id); await Asset.destroy({ where: { asset_id: { [Op.in]: archivedIds } }, force: true }); for (const asset of archivedAssets) { const svc = getProvider(asset.storage_provider); if (!svc) continue; if (asset.storage_key) { try { await svc.deleteFile(asset.storage_key); } catch (err) { console.warn(`[ASSET][PERMANENT DELETE] File cleanup failed for "${asset.storage_key}":`, err.message); } } if (asset.thumbnail_storage_key) { try { await svc.deleteFile(asset.thumbnail_storage_key); } catch (err) { console.warn(`[ASSET][PERMANENT DELETE] Thumbnail cleanup failed for "${asset.thumbnail_storage_key}":`, err.message); } } } invalidateListCache(); logActivity(req.user?.user_id, 'bulk_permanently_delete_assets', { entityType: 'asset', details: { ids: archivedIds, count: archivedIds.length } }); return R.success(res, `${archivedIds.length} asset(s) permanently deleted.`, { deleted_ids: archivedIds, skipped_ids: ids.filter((id) => !archivedIds.includes(id)), }); } catch (err) { console.error("[ASSET][BULK PERMANENT DELETE]", err); return R.error(res, "Internal server error.", 500); } }; // ─── ARCHIVED LIST ──────────────────────────────────────────────────────────── 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 } } }, }); result.data = result.data.map(redactS3Url); 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 = getFieldValues(Asset, "ASSET");