// controllers/admin/assets.controller.js const path = require("path"); const fs = require("fs"); 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 { extractVideoMeta } = require("../../services/ffprobe.service"); const ffmpegSvc = require("../../services/ffmpeg.service"); const assetTranscode = require("../../services/assetTranscode.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 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); } } } // ─── finalizeReplacementUpload ───────────────────────────────────────────────── // // Used by updateAsset() when replacing an asset's file (or a video's // thumbnail): the browser already PUT the new file straight to storage via a // presigned URL (see presignAssetUpload) — this reads back what actually // landed there (HeadObjectCommand, no download) instead of ever buffering the // file through this backend, the same strategy finalizeAssetFromStorage() // uses for brand-new assets. // Returns { file_url, storage_key, mime_type, extension, checksum, file_type, originalname } // async function finalizeReplacementUpload(storage_key, original_name, mimetype, storageProvider) { const svc = getProvider(storageProvider); if (!svc || !svc.getFileMetadata) { throw Object.assign(new Error("Presigned uploads are only supported for S3 storage."), { status: 400 }); } const meta = await svc.getFileMetadata(storage_key); const mime_type = meta.mimetype || mimetype || "application/octet-stream"; return { file_url: await svc.buildPublicUrl(storage_key), storage_key, mime_type, extension: resolveExtension(original_name || storage_key), checksum: meta.checksum, file_type: resolveFileType(mime_type), originalname: original_name || storage_key, }; } // ─── 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, "Files retrieved.", { ...result, data }); } catch (err) { console.error("[ASSET][GET ALL]", err); return R.error(res, "Could not retrieve files.", 500); } }; // ─── GET ONE ────────────────────────────────────────────────────────────────── exports.getAsset = async (req, res) => { try { const { assetId } = req.params; if (!assetId || assetId === "undefined") return R.error(res, "Invalid file 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, "File 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, "File retrieved.", { data: json }); } catch (err) { console.error("[ASSET][GET ONE]", err); return R.error(res, "Internal server error.", 500); } }; // ─── UPLOAD (shared core) ────────────────────────────────────────────────────── // // ┌─────────────────────────────────────────────────────────────────────────┐ // │ PRESIGNED-UPLOAD STRATEGY │ // │ │ // │ The browser already PUT the file's bytes straight to storage via a │ // │ presigned URL (see presignAssetUpload below) — this backend never │ // │ buffers or even touches them (this is what removes the old 500MB │ // │ multer-memoryStorage RAM ceiling entirely, regardless of file size). │ // │ Finalizing an asset from an already-uploaded object is just: │ // │ • HeadObjectCommand → real file_size/mime_type/checksum (=ETag) │ // │ • ffprobe by URL → video/audio metadata only, no download │ // │ • BEGIN → Asset.create() → COMMIT │ // │ │ // │ On any error: rollbackUploads([{ key, provider }]) deletes the │ // │ already-uploaded object(s) — same cleanup as before, just always │ // │ covering both file + thumbnail upfront, since both already exist in │ // │ storage by the time this runs (the browser uploaded them first). │ // └─────────────────────────────────────────────────────────────────────────┘ // // Thumbnails are optional for both video and audio — a video/audio asset can // land with thumbnail_url null and pick one up later via the existing // "thumbnail-only" path in updateAsset(). // async function finalizeAssetFromStorage({ storage_key, thumbnail_storage_key, original_name, body, user }) { const { display_name, description, is_public = false, storage_provider = "s3", storage_bucket, createdBy, } = body; const uploadedFiles = [{ key: storage_key, provider: storage_provider }]; if (thumbnail_storage_key) uploadedFiles.push({ key: thumbnail_storage_key, provider: storage_provider }); try { if (!storage_key) throw Object.assign(new Error("storage_key is required."), { status: 400 }); if (!createdBy) throw Object.assign(new Error("createdBy is required."), { status: 400 }); const svc = getProvider(storage_provider); if (!svc || !svc.getFileMetadata) { throw Object.assign(new Error("Presigned uploads are only supported for S3 storage."), { status: 400 }); } let meta; try { meta = await svc.getFileMetadata(storage_key); } catch { throw Object.assign(new Error("Uploaded file not found in storage — the upload may have failed or expired."), { status: 400 }); } // S3's own Content-Type is authoritative when present, but browsers only // send one automatically when the File object's own .type is non-empty — // fall back to whatever the client reported at presign time, and finally // to a generic default, rather than ever letting a NOT NULL column see // null here (mime_type also drives file_type below, so a null here would // misclassify the asset entirely, not just leave a field blank). const mime_type = meta.mimetype || body.mimetype || "application/octet-stream"; const file_type = resolveFileType(mime_type); const extension = resolveExtension(original_name || storage_key); const file_url = await svc.buildPublicUrl(storage_key); // ── ffprobe (video/audio only) ──────────────────────────────────────────── 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 probeUrl = await svc.getSignedDownloadUrl(storage_key); const videoMeta = await extractVideoMeta({ url: probeUrl }); width = videoMeta.width; height = videoMeta.height; resolution = videoMeta.resolution; duration = videoMeta.duration; frame_rate = videoMeta.frame_rate; bitrate = videoMeta.bitrate; video_codec = videoMeta.video_codec; audio_codec = videoMeta.audio_codec; if (thumbnail_storage_key) { thumbnail_url = await svc.buildPublicUrl(thumbnail_storage_key); } else if (file_type === "video") { // No client-provided thumbnail — grab a frame from the video itself so // the asset doesn't sit with no preview at all in every picker/library // grid. Best-effort: a failure here must not fail the whole upload. let framePath = null; try { framePath = await ffmpegSvc.extractFrameThumbnail(probeUrl, duration); const uploaded = await svc.uploadStream({ stream: fs.createReadStream(framePath), originalname: `${(original_name || "thumb").replace(/\.[^.]+$/, "")}.jpg`, mimetype: "image/jpeg", ownerType: "thumbnail", // → thumbnails/ prefix, same as manually-uploaded thumbnails }); thumbnail_storage_key = uploaded.uuid; thumbnail_url = uploaded.url; uploadedFiles.push({ key: thumbnail_storage_key, provider: storage_provider }); // rollback cleanup on later failure } catch (err) { console.warn(`[ASSET][THUMBNAIL] Auto-generate failed for "${storage_key}":`, err.message); // leave thumbnail_url null — same fallback as before, admin can add one manually later } finally { if (framePath) fs.promises.unlink(framePath).catch(() => {}); } } } 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); } // ── DB insert ────────────────────────────────────────────────────────────── // .mov/.mkv videos load slowly in-browser (moov/Cues index at the end of // the file) — flag them for the background remux job (see // assetTranscode.service.js) fired below, right after commit. const needsTranscode = storage_provider === "s3" && file_type === "video" && ffmpegSvc.needsRemux(extension); const t = await sequelize.transaction(); try { const asset = await Asset.create({ original_name: original_name || storage_key, display_name: display_name || original_name || storage_key, file_url, file_size: meta.size, mime_type, extension, checksum: meta.checksum, file_type, width, height, resolution, duration, frame_rate, bitrate, video_codec, audio_codec, thumbnail_url, thumbnail_storage_key, description, storage_provider, storage_bucket: storage_bucket || (storage_provider === "s3" ? process.env.S3_BUCKET : null) || null, storage_key, is_public, createdBy, transcode_status: needsTranscode ? "pending" : "none", }, { 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 } }); if (needsTranscode) { assetTranscode.transcodeAsset(asset).catch((err) => { console.error("[ASSET][TRANSCODE] Background remux failed to start:", err.message); }); } return asset; } catch (dbErr) { try { await t.rollback(); } catch { /* connection gone */ } await rollbackUploads(uploadedFiles); throw dbErr; } } catch (err) { await rollbackUploads(uploadedFiles); throw err; } } // ─── PRESIGN UPLOAD ───────────────────────────────────────────────────────── // // Mints a short-lived presigned PUT URL so the browser can upload the file's // bytes directly to storage — this backend never buffers them. Called once // for the main file, and once more for a thumbnail if the admin picked one // (see s3.service.js#presignUpload for the key-naming convention). // exports.presignAssetUpload = async (req, res) => { try { const { filename, mimetype, file_type, size = 0, storage_provider = "s3" } = req.body; if (!filename) return R.error(res, "filename is required.", 400); const svc = getProvider(storage_provider); if (!svc || !svc.presignUpload) { return R.error(res, "Presigned uploads are only supported for S3 storage.", 400); } const ownerType = file_type || resolveFileType(mimetype || "") || "document"; // Result is either { key, uploadUrl } or, above the single-PUT ceiling, // { key, multipart: true, uploadId, partSize, parts } — see // s3.service.js#presignUpload. The client branches on `multipart`. const presigned = await svc.presignUpload(filename, ownerType, Number(size) || 0); return R.success(res, "Presigned URL generated.", presigned); } catch (err) { console.error("[ASSET][PRESIGN]", err); return R.error(res, "Could not generate upload URL.", 500); } }; // ─── COMPLETE / ABORT MULTIPART ───────────────────────────────────────────── // // Only used above presignAssetUpload's single-PUT ceiling (see // s3.service.js's MULTIPART_THRESHOLD) — the browser PUTs every part // directly, then calls complete-multipart with the ETags each part's PUT // response returned. abort-multipart is the failure-path cleanup (a part // exhausted its retries, or the admin cancelled) so an abandoned multipart // upload doesn't linger as orphaned storage forever. // exports.completeMultipartAssetUpload = async (req, res) => { try { const { storage_key, uploadId, parts, storage_provider = "s3" } = req.body; if (!storage_key || !uploadId || !Array.isArray(parts) || !parts.length) { return R.error(res, "storage_key, uploadId, and parts are required.", 400); } const svc = getProvider(storage_provider); if (!svc || !svc.completeMultipartUpload) { return R.error(res, "Multipart uploads are only supported for S3 storage.", 400); } await svc.completeMultipartUpload(storage_key, uploadId, parts); return R.success(res, "Multipart upload completed.", {}); } catch (err) { console.error("[ASSET][COMPLETE MULTIPART]", err); return R.error(res, "Could not complete multipart upload.", 500); } }; exports.abortMultipartAssetUpload = async (req, res) => { try { const { storage_key, uploadId, storage_provider = "s3" } = req.body; if (!storage_key || !uploadId) return R.error(res, "storage_key and uploadId are required.", 400); const svc = getProvider(storage_provider); if (svc?.abortMultipartUpload) { try { await svc.abortMultipartUpload(storage_key, uploadId); } catch (err) { // Best-effort, same tolerance as rollbackUploads() — an already-gone // or already-completed upload isn't worth failing the request over. console.error(`[ASSET][ABORT MULTIPART] Failed to abort "${storage_key}":`, err.message); } } return R.success(res, "Multipart upload aborted.", {}); } catch (err) { console.error("[ASSET][ABORT MULTIPART]", err); return R.error(res, "Could not abort multipart upload.", 500); } }; // ─── UPLOAD (finalize) ────────────────────────────────────────────────────── // // Called once the browser's direct-to-storage PUT(s) have completed. Body is // plain JSON — no file bytes here, just the storage key(s) presignAssetUpload // handed back plus asset metadata. Also the endpoint the bulk queue // (UploadQueueContext.jsx) calls once per file, reusing this single-asset // path instead of a separate batch endpoint. // exports.uploadAsset = async (req, res) => { try { const { storage_key, thumbnail_storage_key, original_name } = req.body; const asset = await finalizeAssetFromStorage({ storage_key, thumbnail_storage_key, original_name, body: req.body, user: req.user }); invalidateListCache(); return R.success(res, "File uploaded.", { data: asset }, 201); } 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); } }; // ─── 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 file ID.", 400); const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } }); if (!asset) return R.error(res, "File not found.", 404); // Browser already PUT the replacement file straight to storage via a // presigned URL (see presignAssetUpload) — this is plain JSON, no // multer/file buffer involved, same pattern as POST /admin/assets. const { storage_key, original_name, mimetype } = req.body; const isVideo = asset.file_type === "video"; const isDocument = asset.file_type === "document"; if (isDocument && storage_key) return R.error(res, "Document files cannot be replaced.", 400); if (isVideo && storage_key && !req.body.is_thumbnail) return R.error(res, "Video files cannot be replaced. Upload a new file instead.", 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 (storage_key && usesProvider) { uploaded = await finalizeReplacementUpload(storage_key, original_name, mimetype, storageProvider); newUpload = { key: uploaded.storage_key, provider: storageProvider }; } // ── Phase 2: DB update ──────────────────────────────────────────────────── const t = await sequelize.transaction(); try { await applyAssetUpdate(asset, uploaded, 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, "File 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 file ID.", 400); const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } }); if (!asset) return R.error(res, "File 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, "File 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 files 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} file(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, "File not found.", 404); if (!asset.deletedAt) return R.error(res, "File 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, "File 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 files found.", 404); const archivedAssets = assets.filter((a) => a.deletedAt); if (!archivedAssets.length) return R.error(res, "All selected files 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} file(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, "File not found.", 404); if (!asset.deletedAt) return R.error(res, "File 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, "File 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 files found.", 404); const archivedAssets = assets.filter((a) => a.deletedAt); if (!archivedAssets.length) return R.error(res, "All selected files 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} file(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 files retrieved.", result); } catch (err) { console.error("[ASSET][GET ARCHIVED]", err); return R.error(res, "Could not retrieve archived files.", 500); } }; exports.getAssetFieldValues = getFieldValues(Asset, "ASSET");