diff --git a/controllers/admin/assets.controller.js b/controllers/admin/assets.controller.js index 9fee3d5..819140f 100644 --- a/controllers/admin/assets.controller.js +++ b/controllers/admin/assets.controller.js @@ -1,30 +1,29 @@ // controllers/admin/assets.controller.js -const path = require("path"); -const crypto = require("crypto"); -const { Op } = require("sequelize"); -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 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("image/")) return "image"; + if (mimeType.startsWith("video/")) return "video"; if (mimeType.startsWith("application/") || mimeType.startsWith("text/")) return "document"; - return "other"; + return "image"; } function resolveExtension(originalName = "") { @@ -41,13 +40,81 @@ function resolveResolution(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"; + 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. @@ -71,6 +138,8 @@ exports.getAssets = async (req, res) => { excludeAttributes: adminExclude, jsonbSchemas, computedAttributes, + context: "list", + auditOptions: { mdl_Users, parentAlias: 'Asset' }, findOptions: { where: { deletedAt: null }, }, @@ -94,11 +163,43 @@ exports.getAsset = async (req, res) => { 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); - return R.success(res, "Asset retrieved.", { data: asset }); + // ── 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); @@ -129,14 +230,12 @@ exports.getAsset = async (req, res) => { // Expects multer.fields([{ name: "file", maxCount: 1 }, { name: "thumbnail", maxCount: 1 }]) exports.uploadAsset = async (req, res) => { - // Tracks Chibisafe UUIDs uploaded during Phase 1 so they can be cleaned - // up if Phase 2 (DB insert) fails. const uploadedChibiUuids = []; try { // ── Phase 1a: Validate inputs ───────────────────────────────────────────── - const file = req.files?.file?.[0]; + const file = req.files?.file?.[0]; const thumbFile = req.files?.thumbnail?.[0]; if (!file) return R.error(res, "No file uploaded.", 400); @@ -144,24 +243,21 @@ exports.uploadAsset = async (req, res) => { const { display_name, description, - owner_type, - owner_id, - is_public = false, - access_level = "private", + is_public = false, storage_provider = "local", storage_bucket, storage_key, - uploadedBy, + createdBy, } = req.body; - if (!uploadedBy) { - return R.error(res, "uploadedBy is required.", 400); + 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; + 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); @@ -172,23 +268,18 @@ exports.uploadAsset = async (req, res) => { } // ── Phase 1b: Upload main file to Chibisafe ─────────────────────────────── - // Heavy I/O — done BEFORE opening any DB transaction. - let file_url = null; + let file_url = null; let chibi_uuid = null; if (storage_provider === "chibisafe") { - // owner_type is the single source of truth for album routing. - // The service maps: avatar→avatars, video→videos, document→documents, - // thumbnail→thumbnails, image / anything else → no album. const chibiResult = await chibi.uploadFile({ - buffer: file.buffer, + buffer: file.buffer, originalname: file.originalname, - mimetype: mime_type, - ownerType: owner_type || "", + mimetype: mime_type, }); - file_url = chibiResult.url; + file_url = chibiResult.url; chibi_uuid = chibiResult.uuid; uploadedChibiUuids.push(chibi_uuid); @@ -203,48 +294,44 @@ exports.uploadAsset = async (req, res) => { } // ── Phase 1c: ffprobe + thumbnail upload ────────────────────────────────── - // Also heavy — done BEFORE the DB transaction. - 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 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") { - // ffprobe — CPU-bound, can take a few seconds const meta = await extractVideoMeta({ - buffer: file.buffer, + 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; + 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; - // Thumbnail upload — another network call, done outside the transaction if (storage_provider === "chibisafe") { if (!thumbFile.buffer) { - // Clean up the already-uploaded main file before returning await rollbackChibiUploads(uploadedChibiUuids); return R.error(res, "Thumbnail buffer is required. Ensure multer uses memoryStorage.", 400); } - const baseName = file.originalname.replace(/\.[^.]+$/, ""); + const baseName = file.originalname.replace(/\.[^.]+$/, ""); const thumbResult = await chibi.uploadFile({ - buffer: thumbFile.buffer, + buffer: thumbFile.buffer, originalname: `thumb_${baseName}.${resolveExtension(thumbFile.originalname) || "jpg"}`, - mimetype: thumbFile.mimetype, - ownerType: "thumbnail", // always routes to the thumbnails album + mimetype: thumbFile.mimetype, + ownerType: "thumbnail", }); thumbnail_url = thumbResult.url; @@ -257,22 +344,22 @@ exports.uploadAsset = async (req, res) => { } } else { - const parsedWidth = req.body.width ? parseInt(req.body.width) : null; + const parsedWidth = req.body.width ? parseInt(req.body.width) : null; const parsedHeight = req.body.height ? parseInt(req.body.height) : null; - width = parsedWidth; - height = parsedHeight; + width = parsedWidth; + height = parsedHeight; resolution = resolveResolution(parsedWidth, parsedHeight); } - // ── Phase 2: DB insert — transaction is open for milliseconds only ──────── + // ── 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, + original_name: file.originalname, + display_name: display_name || file.originalname, file_url, - file_size: file.size, + file_size: file.size, mime_type, extension, checksum, @@ -288,23 +375,20 @@ exports.uploadAsset = async (req, res) => { thumbnail_url, description, storage_provider, - storage_bucket: storage_bucket || null, - storage_key: chibi_uuid || storage_key || file.filename, + storage_bucket: storage_bucket || null, + storage_key: chibi_uuid || storage_key || file.filename, is_public, - access_level, - owner_type: owner_type || null, - owner_id: owner_id || null, - uploadedBy, + createdBy, }, { transaction: t }); await t.commit(); + return R.success(res, "Asset uploaded.", { data: asset }, 201); } catch (dbErr) { - // DB failed — rollback and clean up the Chibisafe uploads try { await t.rollback(); } catch { /* connection already gone */ } await rollbackChibiUploads(uploadedChibiUuids); - throw dbErr; // re-throw to outer catch for logging + response + throw dbErr; } } catch (err) { @@ -318,305 +402,227 @@ exports.uploadAsset = async (req, res) => { } }; -// ─── UPDATE THUMBNAIL ───────────────────────────────────────────────────────── -// -// Accepts a "thumbnail" file upload (multipart/form-data). -// Strategy (same phase split as uploadAsset): -// Phase 1 — upload new thumbnail to Chibisafe, delete old one (outside transaction) -// Phase 2 — update asset.thumbnail_url in DB (transaction open milliseconds only) - -exports.updateThumbnail = async (req, res) => { - let newThumbUuid = null; +// ─── ARCHIVE (single) ───────────────────────────────────────────────────────── +exports.archiveAsset = async (req, res) => { try { const { assetId } = req.params; - const thumbFile = req.files?.thumbnail?.[0] ?? req.file; - - if (!thumbFile) { - return R.error(res, "No thumbnail file uploaded. Include it as the 'thumbnail' field.", 400); - } - - if (!thumbFile.buffer) { - return R.error(res, "Thumbnail buffer is required. Ensure multer uses memoryStorage.", 400); - } - - // ── Phase 1: Fetch asset + upload new thumbnail ─────────────────────────── - // Done outside the transaction so the connection isn't held during I/O. + 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 oldThumbUuid = asset.storage_provider === "chibisafe" - ? asset.thumbnail_storage_key ?? null // store separately if you have it, - : null; // otherwise skip deletion - - // Upload new thumbnail to Chibisafe thumbnails album - let thumbnail_url = null; - - if (asset.storage_provider === "chibisafe") { - const baseName = asset.original_name.replace(/\.[^.]+$/, ""); - const thumbExt = resolveExtension(thumbFile.originalname) || "jpg"; - const thumbResult = await chibi.uploadFile({ - buffer: thumbFile.buffer, - originalname: `thumb_${baseName}.${thumbExt}`, - mimetype: thumbFile.mimetype, - ownerType: "thumbnail", - }); - thumbnail_url = thumbResult.url; - newThumbUuid = thumbResult.uuid; - } else { - thumbnail_url = thumbFile.filename - ? `/uploads/${thumbFile.filename}` - : null; - } - - if (!thumbnail_url) { - return R.error(res, "Could not resolve thumbnail URL.", 500); - } - - // ── Phase 2: DB update (transaction open milliseconds only) ─────────────── - - const t = await sequelize.transaction(); - try { - asset.thumbnail_url = thumbnail_url; - await asset.save({ transaction: t }); - await t.commit(); - } catch (dbErr) { - try { await t.rollback(); } catch { /* gone */ } - // DB failed — delete the just-uploaded thumbnail from Chibisafe - if (newThumbUuid) await rollbackChibiUploads([newThumbUuid]); - throw dbErr; - } - - // ── Phase 3: Delete old thumbnail from Chibisafe (best-effort) ─────────── - // Done AFTER commit so a failed cleanup never blocks the success response. - if (oldThumbUuid) { - try { - await chibi.deleteFile(oldThumbUuid); - } catch (cleanupErr) { - console.warn("[ASSET][UPDATE THUMBNAIL] Old thumbnail cleanup failed:", cleanupErr.message); - } - } - - return R.success(res, "Thumbnail updated.", { data: asset }); + await asset.update({ deletedBy: req.body.deletedBy ?? null }); + await asset.destroy(); + return R.success(res, "Asset archived."); } catch (err) { - if (newThumbUuid) await rollbackChibiUploads([newThumbUuid]); - console.error("[ASSET][UPDATE THUMBNAIL]", err); + console.error("[ASSET][ARCHIVE]", err); return R.error(res, "Internal server error.", 500); } }; -// ─── UPDATE METADATA ────────────────────────────────────────────────────────── +// ─── ARCHIVE (bulk) ─────────────────────────────────────────────────────────── -exports.updateAsset = async (req, res) => { - const t = await sequelize.transaction(); - try { - const { assetId } = req.params; - if (!assetId || assetId === "undefined") { - await t.rollback(); - return R.error(res, "Invalid asset ID.", 400); - } - - if (req.files?.file || req.file) { - await t.rollback(); - return R.error(res, "File uploads are not allowed on this endpoint. Use POST /assets to upload a new file.", 400); - } - - const asset = await Asset.findOne({ - where: { asset_id: assetId, ...notDeleted }, - transaction: t, - }); - if (!asset) { - await t.rollback(); - return R.error(res, "Asset not found.", 404); - } - - const allowed = [ - "display_name", "description", - "owner_type", "owner_id", - "is_public", "access_level", - "thumbnail_url", - "width", "height", "duration", - "frame_rate", "bitrate", - "video_codec", "audio_codec", - ]; - - allowed.forEach((field) => { - if (req.body[field] !== undefined) asset[field] = req.body[field]; - }); - - if (req.body.width || req.body.height) { - asset.resolution = resolveResolution(asset.width, asset.height); - } - - await asset.save({ transaction: t }); - await t.commit(); - return R.success(res, "Asset updated.", { data: asset }); - } catch (err) { - try { await t.rollback(); } catch { /* already rolled back */ } - console.error("[ASSET][UPDATE]", err); - return R.error(res, "Internal server error.", 500); - } -}; - -// ─── SOFT DELETE (single) ───────────────────────────────────────────────────── - -exports.deleteAsset = async (req, res) => { - const t = await sequelize.transaction(); - try { - const { assetId } = req.params; - if (!assetId || assetId === "undefined") { - await t.rollback(); - return R.error(res, "Invalid asset ID.", 400); - } - - const asset = await Asset.findOne({ - where: { asset_id: assetId, ...notDeleted }, - transaction: t, - lock: t.LOCK.UPDATE, - }); - if (!asset) { - await t.rollback(); - return R.error(res, "Asset not found.", 404); - } - - // Archive on Chibisafe before the DB update (best-effort, non-fatal) - if (asset.storage_provider === "chibisafe" && asset.storage_key) { - try { - await chibi.archiveFiles(asset.storage_key); - } catch (chibiErr) { - console.error("[ASSET][DELETE][CHIBI ARCHIVE]", chibiErr.message); - } - } - - asset.deletedAt = new Date(); - asset.deletedBy = req.body.deletedBy ?? null; - await asset.save({ transaction: t }); - - await t.commit(); - return R.success(res, "Asset deleted."); - } catch (err) { - try { await t.rollback(); } catch { /* already rolled back */ } - console.error("[ASSET][DELETE]", err); - return R.error(res, "Internal server error.", 500); - } -}; - -// ─── SOFT DELETE (bulk) ─────────────────────────────────────────────────────── - -exports.deleteAssets = async (req, res) => { - const t = await sequelize.transaction(); +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); - if (!Array.isArray(ids) || !ids.length) { - await t.rollback(); - 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 chibiAssets = await Asset.findAll({ - where: { - asset_id: { [Op.in]: ids }, - storage_provider: "chibisafe", - storage_key: { [Op.not]: null }, - ...notDeleted, - }, - attributes: ["storage_key"], - transaction: t, - }); + const activeIds = assets.map((a) => a.asset_id); - // Archive on Chibisafe (best-effort, non-fatal) - if (chibiAssets.length) { - try { - await chibi.archiveFiles(chibiAssets.map((a) => a.storage_key)); - } catch (chibiErr) { - console.error("[ASSET][BULK DELETE][CHIBI ARCHIVE]", chibiErr.message); - } - } - - const [count] = await Asset.update( - { deletedAt: new Date(), deletedBy: deletedBy ?? null }, - { - where: { asset_id: { [Op.in]: ids }, ...notDeleted }, - transaction: t, - }, + await Asset.update( + { deletedBy: deletedBy ?? null }, + { where: { asset_id: { [Op.in]: activeIds } } } ); + await Asset.destroy({ where: { asset_id: { [Op.in]: activeIds } } }); - await t.commit(); - return R.success(res, `${count} asset(s) deleted.`); + return R.success(res, `${activeIds.length} asset(s) archived.`, { + archived_ids: activeIds, + skipped_ids: ids.filter((id) => !activeIds.includes(id)), + }); } catch (err) { - try { await t.rollback(); } catch { /* already rolled back */ } - console.error("[ASSET][BULK DELETE]", err); + console.error("[ASSET][BULK ARCHIVE]", err); return R.error(res, "Internal server error.", 500); } }; // ─── RESTORE (single) ───────────────────────────────────────────────────────── -// -// Clears deletedAt/deletedBy in DB, then moves the file on Chibisafe from the -// archived album back to the album it belongs to based on owner_type: -// avatar → avatars album -// video → videos album -// document → documents album -// image → no album (general images have no dedicated album) -// -// The Chibisafe move is best-effort — a failed move won't block the restore. exports.restoreAsset = async (req, res) => { - const t = await sequelize.transaction(); try { const { assetId } = req.params; const asset = await Asset.findOne({ - where: { asset_id: assetId, deletedAt: { [Op.not]: null } }, - transaction: t, - lock: t.LOCK.UPDATE, + where: { asset_id: assetId }, + paranoid: false, }); - if (!asset) { - await t.rollback(); - return R.error(res, "Asset not found or not deleted.", 404); - } + if (!asset) return R.error(res, "Asset not found.", 404); + if (!asset.deletedAt) return R.error(res, "Asset is not archived.", 400); - asset.deletedAt = null; - asset.deletedBy = null; - await asset.save({ transaction: t }); - - await t.commit(); - - // ── Move file back to its home album on Chibisafe (best-effort) ─────────── - // Done AFTER commit so a failed move never rolls back the restore. - if (asset.storage_provider === "chibisafe" && asset.storage_key) { - const homeAlbumUuid = chibi.ALBUMS[ownerTypeToAlbumKey(asset.owner_type)]; - if (homeAlbumUuid) { - try { - await chibi.addFilesToAlbum(asset.storage_key, homeAlbumUuid); - } catch (chibiErr) { - console.warn("[ASSET][RESTORE] Failed to move file back to home album:", chibiErr.message); - } - } - } + await asset.restore(); + await asset.update({ deletedBy: null }); return R.success(res, "Asset restored.", { data: asset }); } catch (err) { - try { await t.rollback(); } catch { /* already rolled back */ } console.error("[ASSET][RESTORE]", err); return R.error(res, "Internal server error.", 500); } }; -/** - * Maps asset owner_type to the ALBUMS key in chibisafe.service. - * Returns null for types that have no dedicated album (e.g. "image"). - * - * @param {string} ownerType - * @returns {string|null} - */ -function ownerTypeToAlbumKey(ownerType) { - const map = { - avatar: "avatars", - video: "videos", - document: "documents", - }; - return map[ownerType] ?? null; -} \ No newline at end of file +// ─── 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); + } +}; \ No newline at end of file diff --git a/controllers/admin/user_groups.controller.js b/controllers/admin/user_groups.controller.js index 883e981..9aabd02 100644 --- a/controllers/admin/user_groups.controller.js +++ b/controllers/admin/user_groups.controller.js @@ -28,6 +28,7 @@ exports.getGroups = async (req, res) => { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas, computedAttributes: groupComputed, + context: "list", auditOptions: { mdl_Users, parentAlias: 'UserGroup' }, }); @@ -50,6 +51,7 @@ exports.getGroup = async (req, res) => { jsonbSchemas: usersSchemas, jsonbColumn: 'personal_info', auditOptions: { mdl_Users, parentAlias: 'User' }, + context: "list", findOptions: { include: [{ model: mdl_UserGroupMembers, @@ -217,6 +219,7 @@ exports.getArchivedGroups = async (req, res) => { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas, computedAttributes: groupComputed, + context: "archived", auditOptions: { mdl_Users, parentAlias: 'UserGroup' }, findOptions: { paranoid: false, diff --git a/controllers/admin/users.controller.js b/controllers/admin/users.controller.js index 50db432..91b7c5a 100644 --- a/controllers/admin/users.controller.js +++ b/controllers/admin/users.controller.js @@ -11,18 +11,18 @@ const { Op, Sequelize } = require('sequelize'); const bcrypt = require('bcryptjs'); const crypto = require('crypto'); -const mdl_Users = require('../../models/users/users.mdl'); +const mdl_Users = require('../../models/users/users.mdl'); const mdl_UserSessions = require('../../models/users/user_sessions.mdl'); const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl'); const sendEmail = require('../../services/email.service'); -const R = require('../../utils/response.util'); -const { paginate } = require('../../utils/paginate.util'); +const R = require('../../utils/response.util'); +const { paginate } = require('../../utils/paginate.util'); const { enrichPersonalInfo } = require('../../utils/personalInfo.util'); const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas } = require('../../models/users/users.attributes'); -const EXCLUDED = ['password', 'otp_code', 'otp_expires_at', 'must_change_password', 'password_expires_at']; +const EXCLUDED = ['password', 'otp_code', 'otp_expires_at', 'must_change_password', 'password_expires_at']; const auditByFields = ['createdBy', 'updatedBy', 'deletedBy']; // ─── GET ALL ────────────────────────────────────────────────────────────────── @@ -31,9 +31,10 @@ exports.getUsers = async (req, res) => { try { const result = await paginate(mdl_Users, req, { excludeAttributes: usersExclude, - jsonbSchemas: usersSchemas, - jsonbColumn: 'personal_info', - auditOptions: { mdl_Users, parentAlias: 'User' }, + jsonbSchemas: usersSchemas, + jsonbColumn: 'personal_info', + context: "list", + auditOptions: { mdl_Users, parentAlias: 'User' }, }); return R.success(res, 'Users retrieved.', result); @@ -54,7 +55,7 @@ exports.getUser = async (req, res) => { attributes: { exclude: EXCLUDED }, include: [{ model: mdl_UserGroups, - as: 'groups', + as: 'groups', through: { attributes: [] }, }], }); @@ -82,23 +83,23 @@ exports.addStaffUser = async (req, res) => { const existing = await mdl_Users.findOne({ where: { email } }); if (existing) return R.error(res, 'Email is already in use.', 409); - const plainPassword = crypto.randomBytes(8).toString('base64url').slice(0, 12); - const hashed = await bcrypt.hash(plainPassword, 12); + const plainPassword = crypto.randomBytes(8).toString('base64url').slice(0, 12); + const hashed = await bcrypt.hash(plainPassword, 12); const passwordExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); - const enriched = enrichPersonalInfo(personal_info); - const fullName = enriched?.name?.full_name ?? email; + const enriched = enrichPersonalInfo(personal_info); + const fullName = enriched?.name?.full_name ?? email; const user = await mdl_Users.create({ email, - password: hashed, - password_expires_at: passwordExpiresAt, + password: hashed, + password_expires_at: passwordExpiresAt, must_change_password: true, - acc_type: 'staff', - reg_type: 'system', - is_active: true, - is_verified: true, - createdBy: req.user.user_id, - personal_info: enriched, + acc_type: 'staff', + reg_type: 'system', + is_active: true, + is_verified: true, + createdBy: req.user.user_id, + personal_info: enriched, }); await sendEmail({ @@ -108,8 +109,8 @@ exports.addStaffUser = async (req, res) => { }); return R.success(res, 'Staff user created successfully.', { - user_id: user.user_id, - email: user.email, + user_id: user.user_id, + email: user.email, acc_type: user.acc_type, }, 201); } catch (err) { @@ -205,7 +206,7 @@ exports.bulkDeactivateUsers = async (req, res) => { return R.success(res, `${activeIds.length} user(s) deactivated successfully.`, { deactivated_ids: activeIds, - skipped_ids: ids.filter((id) => !activeIds.includes(id)), + skipped_ids: ids.filter((id) => !activeIds.includes(id)), }); } catch (err) { console.error('[ADMIN][BULK DEACTIVATE USERS]', err); @@ -220,7 +221,7 @@ exports.restoreUser = async (req, res) => { const user = await mdl_Users.findOne({ where: { user_id: req.params.id }, paranoid: false, }); - if (!user) return R.error(res, 'User not found.', 404); + if (!user) return R.error(res, 'User not found.', 404); if (!user.deletedAt) return R.error(res, 'User is not deactivated.', 400); await user.restore(); @@ -258,7 +259,7 @@ exports.bulkRestoreUsers = async (req, res) => { return R.success(res, `${deletedIds.length} user(s) restored successfully.`, { restored_ids: deletedIds, - skipped_ids: ids.filter((id) => !deletedIds.includes(id)), + skipped_ids: ids.filter((id) => !deletedIds.includes(id)), }); } catch (err) { console.error('[ADMIN][BULK RESTORE USERS]', err); @@ -271,9 +272,9 @@ exports.bulkRestoreUsers = async (req, res) => { exports.getUserSessions = async (req, res) => { try { const sessions = await mdl_UserSessions.findAll({ - where: { user_id: req.params.id }, + where: { user_id: req.params.id }, attributes: { exclude: ['refresh_token_hash'] }, - order: [['createdAt', 'DESC']], + order: [['createdAt', 'DESC']], }); return R.success(res, 'Sessions retrieved.', sessions); } catch (err) { @@ -288,7 +289,7 @@ exports.terminateSession = async (req, res) => { if (!session) return R.error(res, 'Session not found.', 404); await session.update({ - is_active: false, + is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id }, }); @@ -332,7 +333,7 @@ exports.getUserFieldValues = async (req, res) => { 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, + raw: true, }); return R.success(res, 'Field values retrieved.', results.map((r) => r.value).filter(Boolean)); } @@ -340,14 +341,14 @@ exports.getUserFieldValues = async (req, res) => { const results = await mdl_Users.findAll({ attributes: [[Sequelize.fn('DISTINCT', Sequelize.col(field)), 'value']], where: { [field]: { [Op.ne]: null } }, - raw: true, + raw: true, }); return R.success(res, 'Field values retrieved.', results.map((r) => r.value).filter(Boolean).sort()); } // ─── JSONB dot-notation ─────────────────────────────────────────────── const [column, ...pathParts] = field.split('.'); - const keys = [...pathParts]; + const keys = [...pathParts]; const lastKey = keys.pop(); const jsonbPath = keys.length ? `"${column}"->${keys.map((k) => `'${k}'`).join('->')}->>'${lastKey}'` @@ -356,7 +357,7 @@ exports.getUserFieldValues = async (req, res) => { const results = await mdl_Users.findAll({ attributes: [[Sequelize.literal(`DISTINCT ${jsonbPath}`), 'value']], where: Sequelize.literal(`${jsonbPath} IS NOT NULL`), - raw: true, + raw: true, }); return R.success(res, 'Field values retrieved.', results.map((r) => r.value).filter(Boolean).sort()); @@ -372,12 +373,13 @@ exports.getArchivedUsers = async (req, res) => { try { const result = await paginate(mdl_Users, req, { excludeAttributes: usersExclude, - jsonbSchemas: usersSchemas, - jsonbColumn: 'personal_info', - auditOptions: { mdl_Users, parentAlias: 'User' }, + jsonbSchemas: usersSchemas, + jsonbColumn: 'personal_info', + context: "archived", + auditOptions: { mdl_Users, parentAlias: 'User' }, findOptions: { paranoid: false, - where: { deletedAt: { [Op.ne]: null }, is_active: false }, + where: { deletedAt: { [Op.ne]: null }, is_active: false }, }, }); diff --git a/models/assets/assets.attributes.js b/models/assets/assets.attributes.js index a56fc9b..3425251 100644 --- a/models/assets/assets.attributes.js +++ b/models/assets/assets.attributes.js @@ -7,7 +7,6 @@ const excludeAttributes = [ "checksum", // internal integrity hash, not useful to clients "storage_bucket", // internal storage config "storage_key", // internal Chibisafe / S3 key - "deletedBy", // exposed via audit subquery as a name instead ]; // Admins see everything except the base excludes diff --git a/models/assets/assets.mdl.js b/models/assets/assets.mdl.js index 9df2fed..dbda467 100644 --- a/models/assets/assets.mdl.js +++ b/models/assets/assets.mdl.js @@ -1,84 +1,77 @@ // models/Asset.js const { DataTypes } = require("sequelize"); -const sequelize = require("../../config/db.config"); +const sequelize = require("../../config/db.config"); +const mdl_Users = require("../users/users.mdl") const Asset = sequelize.define("Asset", { // ─── Identity ───────────────────────────────────────────────────────────── - asset_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, - uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true }, + asset_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: "Asset ID", order: 0, hidden: true }, + uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, label: "UUID", order: 0, hidden: true }, // ─── File info ──────────────────────────────────────────────────────────── - original_name: { type: DataTypes.STRING(255), allowNull: false }, - display_name: { type: DataTypes.STRING(255), allowNull: false }, - file_url: { type: DataTypes.STRING(512), allowNull: false }, - file_size: { type: DataTypes.BIGINT, allowNull: false }, - mime_type: { type: DataTypes.STRING(100), allowNull: false }, - extension: { type: DataTypes.STRING(20) }, - checksum: { type: DataTypes.STRING(64) }, + original_name: { type: DataTypes.STRING(255), allowNull: false, label: "Original Name", order: 0, hidden: true }, + display_name: { type: DataTypes.STRING(255), allowNull: false, label: "Name", order: 0 }, + file_url: { type: DataTypes.STRING(512), allowNull: false, label: "File URL", order: 0, hidden: true }, + file_size: { type: DataTypes.BIGINT, allowNull: false, label: "File Size", order: 0, hidden: true }, + mime_type: { type: DataTypes.STRING(100), allowNull: false, label: "MIME Type", order: 0, hidden: true }, + extension: { type: DataTypes.STRING(20), label: "File Type", order: 0 }, + checksum: { type: DataTypes.STRING(64), label: "Checksum", order: 0, hidden: true }, // ─── Classification ─────────────────────────────────────────────────────── file_type: { - type: DataTypes.ENUM("image", "video", "document", "other"), - allowNull: false, - defaultValue: "other", + type: DataTypes.ENUM("avatar", "document", "video", "image"), + allowNull: false, + defaultValue: "image", label: "Classification", order: 0 }, // ─── Image & video dimensions ───────────────────────────────────────────── - width: { type: DataTypes.INTEGER }, - height: { type: DataTypes.INTEGER }, + width: { type: DataTypes.INTEGER, label: "", order: 0, hidden: true }, + height: { type: DataTypes.INTEGER, label: "", order: 0, hidden: true }, // ─── Video-specific ─────────────────────────────────────────────────────── - duration: { type: DataTypes.FLOAT }, // seconds - resolution: { type: DataTypes.STRING(20) }, // "1080p", "720p", "4K" - frame_rate: { type: DataTypes.FLOAT }, // fps - bitrate: { type: DataTypes.BIGINT }, // bps - video_codec: { type: DataTypes.STRING(50) }, // "H.264", "H.265" - audio_codec: { type: DataTypes.STRING(50) }, // "AAC", "MP3" - thumbnail_url: { type: DataTypes.STRING(512) }, // face of the video / doc preview + duration: { type: DataTypes.FLOAT, label: "", order: 0, hidden: true }, // seconds + resolution: { type: DataTypes.STRING(20), label: "", order: 0, hidden: true }, // "1080p", "720p", "4K" + frame_rate: { type: DataTypes.FLOAT, label: "", order: 0, hidden: true }, // fps + bitrate: { type: DataTypes.BIGINT, label: "", order: 0, hidden: true }, // bps + video_codec: { type: DataTypes.STRING(50), label: "", order: 0, hidden: true }, // "H.264", "H.265" + audio_codec: { type: DataTypes.STRING(50), label: "", order: 0, hidden: true }, // "AAC", "MP3" + thumbnail_url: { type: DataTypes.STRING(512), label: "", order: 0, hidden: true }, // face of the video / doc preview // ─── Description ────────────────────────────────────────────────────────── - description: { type: DataTypes.TEXT }, + description: { type: DataTypes.TEXT, label: "Description", hidden: true }, // ─── Storage ────────────────────────────────────────────────────────────── storage_provider: { - type: DataTypes.ENUM("local", "s3", "gcs", "cloudinary", "chibisafe", "other"), - defaultValue: "local", + type: DataTypes.ENUM("local", "s3", "gcs", "cloudinary", "chibisafe", "other"), + defaultValue: "local", label: "Storage Provider", order: 0 }, - storage_bucket: { type: DataTypes.STRING(255) }, - storage_key: { type: DataTypes.STRING(512) }, + storage_bucket: { type: DataTypes.STRING(255), label: "", order: 0, hidden: true }, + storage_key: { type: DataTypes.STRING(512), label: "", order: 0, hidden: true }, // ─── Access control ─────────────────────────────────────────────────────── is_public: { - type: DataTypes.BOOLEAN, - defaultValue: false, - }, - access_level: { - type: DataTypes.ENUM("public", "private", "restricted"), - defaultValue: "private", + type: DataTypes.BOOLEAN, + defaultValue: false, label: "Access Type", order: 0 }, - // ─── Polymorphic ownership ──────────────────────────────────────────────── - owner_type: { type: DataTypes.STRING(100) }, // avatar, document, video, image - owner_id: { type: DataTypes.BIGINT }, - - // ─── Who did what ───────────────────────────────────────────────────────── - uploadedBy: { type: DataTypes.BIGINT, allowNull: false }, - deletedBy: { type: DataTypes.BIGINT, allowNull: true }, - - // ─── Soft delete ────────────────────────────────────────────────────────── - deletedAt: { type: DataTypes.DATE, allowNull: true, defaultValue: null }, + // ── Audit trails ──────────────────────────────────────────────────────────── + createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" }, + updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" }, + deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" }, }, { - tableName: "assets", + tableName: "assets", timestamps: true, // createdAt, updatedAt - paranoid: false, + paranoid: true, indexes: [ - { fields: ["uuid"] }, - { fields: ["owner_type", "owner_id"] }, - { fields: ["uploadedBy"] }, - { fields: ["file_type"] }, - { fields: ["deletedAt"] }, + { fields: ["uuid"] }, + { fields: ["createdBy"] }, + { fields: ["file_type"] }, + { fields: ["deletedAt"] }, ], }); +Asset.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" }); +Asset.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" }); + module.exports = Asset; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 86cc89b..5265f7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "star-auth-system", "version": "1.0.0", "dependencies": { + "axios": "^1.16.0", "bcryptjs": "^2.4.3", "cookie-parser": "^1.4.6", "cors": "^2.8.5", @@ -187,6 +188,17 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/axios": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -908,6 +920,26 @@ "node": ">=18" } }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -1969,6 +2001,15 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", diff --git a/package.json b/package.json index ad21f34..7f0648c 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev": "nodemon server.js" }, "dependencies": { + "axios": "^1.16.0", "bcryptjs": "^2.4.3", "cookie-parser": "^1.4.6", "cors": "^2.8.5", diff --git a/routes/admin/assets.routes.js b/routes/admin/assets.routes.js index 18ead7f..78dc44c 100644 --- a/routes/admin/assets.routes.js +++ b/routes/admin/assets.routes.js @@ -6,14 +6,20 @@ const upload = multer({ storage: multer.memoryStorage() }); const controller = require('../../controllers/admin/assets.controller'); const { sensitiveOpsLimiter } = require('../../middleware/rateLimiter.middleware'); +// ─── Static routes first ────────────────────────────────────────────────────── +router.get('/archived', controller.getArchivedAssets); +router.get('/field-values', controller.getAssetFieldValues); // ← must be before /:assetId +router.delete('/bulk', sensitiveOpsLimiter, controller.archiveAssets); +router.patch('/bulk-restore', controller.restoreAssets); +// ─── Collection ─────────────────────────────────────────────────────────────── router.get('/', controller.getAssets); +router.post('/', upload.fields([{ name: 'file', maxCount: 1 }, { name: 'thumbnail', maxCount: 1 }]), controller.uploadAsset); + +// ─── Dynamic routes last ────────────────────────────────────────────────────── router.get('/:assetId', controller.getAsset); -router.post( "/upload", upload.fields([ { name: "file", maxCount: 1 }, { name: "thumbnail", maxCount: 1 }, ]), controller.uploadAsset, ); -router.put('/:assetId', sensitiveOpsLimiter, controller.updateAsset); -router.patch('/:assetId/thumbnail', sensitiveOpsLimiter, upload.fields([{ name: 'thumbnail', maxCount: 1 }]), controller.updateThumbnail); -router.delete('/bulk', sensitiveOpsLimiter, controller.deleteAssets); -router.delete('/:assetId', sensitiveOpsLimiter, controller.deleteAsset); +router.patch('/:assetId', sensitiveOpsLimiter, upload.fields([{ name: 'file', maxCount: 1 }]), controller.updateAsset); router.patch('/:assetId/restore', sensitiveOpsLimiter, controller.restoreAsset); +router.delete('/:assetId', sensitiveOpsLimiter, controller.archiveAsset); module.exports = router; \ No newline at end of file diff --git a/server.js b/server.js index 701772f..c5a34be 100644 --- a/server.js +++ b/server.js @@ -17,29 +17,29 @@ ***********************************************************************************************************************************************************************/ require('dotenv').config(); -const express = require('express'); -const cors = require('cors'); -const cookieParser = require('cookie-parser'); -const session = require('express-session'); -const passport = require('./config/passport.config'); -const sequelize = require('./config/db.config'); +const express = require('express'); +const cors = require('cors'); +const cookieParser = require('cookie-parser'); +const session = require('express-session'); +const passport = require('./config/passport.config'); +const sequelize = require('./config/db.config'); // ── Middleware ────────────────────────────────────────────────────────────────── -const { globalLimiter } = require('./middleware/rateLimiter.middleware'); +const { globalLimiter } = require('./middleware/rateLimiter.middleware'); const { csrfErrorHandler } = require('./middleware/csrf.middleware'); // ── Routes ───────────────────────────────────────────────────────────────────── -const authRoutes = require('./routes/auth.routes'); +const authRoutes = require('./routes/auth.routes'); const clientRoutes = require('./routes/client/client.routes'); -const staffRoutes = require('./routes/staff/staff.routes'); -const adminRoutes = require('./routes/admin/admin.routes'); +const staffRoutes = require('./routes/staff/staff.routes'); +const adminRoutes = require('./routes/admin/admin.routes'); // ── Models (ensure associations are loaded) ──────────────────────────────────── require('./models/users/users.mdl'); require('./models/users/user_sessions.mdl'); require('./models/users/user_groups.mdl'); -const app = express(); +const app = express(); const PORT = process.env.PORT || 3000; // ────────────────────────────────────────────────────────────────────────────── @@ -50,11 +50,26 @@ app.set('trust proxy', 1); // Required for rate-limiter behind proxies/load bala const allowedOrigins = (process.env.ALLOWED_ORIGINS || process.env.APP_URL || '*').split(','); app.use(cors({ - origin: (origin, callback) => { + origin: (origin, callback) => { if (!origin || allowedOrigins.includes(origin)) return callback(null, true); callback(new Error(`CORS: origin ${origin} not allowed`)); }, credentials: true, + preflightContinue: false, // cors() handles OPTIONS and stops — don't pass to next() + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: [ + 'Content-Type', + 'Authorization', + 'Accept', + 'Origin', + 'X-Requested-With', + 'csrf-token', + 'x-csrf-token', + 'CSRF-Token', + 'X-CSRF-Token', + ], + optionsSuccessStatus: 204, + maxAge: 86400, })); app.use(express.json()); @@ -63,10 +78,10 @@ app.use(cookieParser()); // Session — used only by csurf; JWT handles auth state app.use(session({ - secret: process.env.SESSION_SECRET || 'change-me', - resave: false, + secret: process.env.SESSION_SECRET || 'change-me', + resave: false, saveUninitialized: false, - cookie: { secure: process.env.NODE_ENV === 'production', httpOnly: true, sameSite: 'strict' }, + cookie: { secure: process.env.NODE_ENV === 'production', httpOnly: true, sameSite: 'strict' }, })); app.use(passport.initialize()); @@ -77,10 +92,10 @@ app.use(globalLimiter); // ────────────────────────────────────────────────────────────────────────────── // Routes // ────────────────────────────────────────────────────────────────────────────── -app.use('/api/auth', authRoutes); +app.use('/api/auth', authRoutes); app.use('/api/client', clientRoutes); app.use('/api/staff', staffRoutes); -app.use('/api/admin', adminRoutes); +app.use('/api/admin', adminRoutes); // Health check app.get('/api/health', (req, res) => { diff --git a/services/chibisafe.service.js b/services/chibisafe.service.js index 392f7d4..ef24e0a 100644 --- a/services/chibisafe.service.js +++ b/services/chibisafe.service.js @@ -12,7 +12,7 @@ // CHIBISAFE_ALBUM_ARCHIVED – album UUID used as the "trash" / archived album const FormData = require("form-data"); -const fetch = require("node-fetch"); // npm i node-fetch@2 (CJS-compatible) +const axios = require("axios"); // ─── Config ─────────────────────────────────────────────────────────────────── @@ -24,38 +24,23 @@ const ALBUMS = { videos: process.env.CHIBISAFE_ALBUM_VIDEOS || null, documents: process.env.CHIBISAFE_ALBUM_DOCUMENTS || null, thumbnails: process.env.CHIBISAFE_ALBUM_THUMBNAILS || null, + images: process.env.CHIBISAFE_ALBUM_IMAGES || null, // ← new archived: process.env.CHIBISAFE_ALBUM_ARCHIVED || null, }; // ─── Internal helpers ───────────────────────────────────────────────────────── -/** - * Resolve which Chibisafe album UUID should receive a file based on owner_type. - * owner_type is the single source of truth for album routing: - * - * "avatar" → avatars album (profile pictures) - * "video" → videos album (course/content videos) - * "document" → documents album (pdf, docx, ppt, txt…) - * "thumbnail" → thumbnails album (video cover images) - * "image" → no album (general-purpose images) - * anything else / null → no album - * - * @param {string} ownerType – value of the asset's owner_type field - * @returns {string|null} - */ function resolveAlbumUuid(ownerType = "") { switch (ownerType) { case "avatar": return ALBUMS.avatars; case "video": return ALBUMS.videos; case "document": return ALBUMS.documents; case "thumbnail": return ALBUMS.thumbnails; - default: return null; // "image" and unknowns → no album + case "image": return ALBUMS.images; + default: return null; } } -/** - * Build default headers for every Chibisafe request. - */ function baseHeaders(extra = {}) { return { "x-api-key": API_KEY, @@ -63,44 +48,41 @@ function baseHeaders(extra = {}) { }; } +// ─── Axios instance ─────────────────────────────────────────────────────────── + +const chibi = axios.create({ + baseURL: BASE_URL, + maxBodyLength: Infinity, // ← required for large file uploads + maxContentLength: Infinity, +}); + /** - * Thin fetch wrapper that throws a descriptive error on non-2xx. + * Thin axios wrapper that throws a descriptive error on non-2xx. */ -async function chibiRequest(path, options = {}) { - const url = `${BASE_URL}${path}`; - const res = await fetch(url, options); - - let body; +async function chibiRequest(path, { method = "GET", headers = {}, data } = {}) { try { - body = await res.json(); - } catch { - body = {}; + const res = await chibi.request({ + url: path, + method, + headers, + data, + }); + return res.data; + } catch (err) { + const status = err.response?.status; + const body = err.response?.data ?? {}; + const msg = body?.message || body?.error || err.message; + const friendly = new Error(`[Chibisafe] ${status ?? "?"} – ${msg}`); + friendly.status = status; + friendly.chibiBody = body; + throw friendly; } - - if (!res.ok) { - const msg = body?.message || body?.error || res.statusText; - const err = new Error(`[Chibisafe] ${res.status} – ${msg}`); - err.status = res.status; - err.chibiBody = body; - throw err; - } - - return body; } // ─── Public API ─────────────────────────────────────────────────────────────── /** * Upload a file to Chibisafe, optionally straight into a typed album. - * - * @param {object} opts - * @param {Buffer} opts.buffer – raw file bytes - * @param {string} opts.originalname – original filename (for Content-Disposition) - * @param {string} opts.mimetype – MIME type - * @param {string} opts.ownerType – asset owner_type value used to resolve the album - * ("avatar" | "video" | "document" | "thumbnail" | "image") - * - * @returns {Promise<{ uuid: string, url: string, name: string }>} */ async function uploadFile({ buffer, originalname, mimetype, ownerType = "" }) { if (!BASE_URL || !API_KEY) { @@ -115,21 +97,16 @@ async function uploadFile({ buffer, originalname, mimetype, ownerType = "" }) { contentType: mimetype, }); - const headers = { - ...baseHeaders(), - ...form.getHeaders(), - // Pass the album UUID at upload time so the file lands in the right album - // in a single round-trip (official Chibisafe header). - ...(albumUuid ? { albumuuid: albumUuid } : {}), - }; - const data = await chibiRequest("/api/upload", { method: "POST", - headers, - body: form, + headers: { + ...baseHeaders(), + ...form.getHeaders(), // ← axios needs these + ...(albumUuid ? { albumuuid: albumUuid } : {}), + }, + data: form, }); - // Chibisafe returns: { name, uuid, url, ... } return { uuid: data.uuid, url: data.url, @@ -139,10 +116,6 @@ async function uploadFile({ buffer, originalname, mimetype, ownerType = "" }) { /** * Permanently delete one file from Chibisafe by its UUID. - * Used for rollback cleanup when a DB transaction fails after a successful upload. - * - * @param {string} uuid – Chibisafe file UUID - * @returns {Promise} */ async function deleteFile(uuid) { await chibiRequest(`/api/file/${uuid}`, { @@ -152,11 +125,7 @@ async function deleteFile(uuid) { } /** - * Move one or more files into the "archived" album (soft-delete equivalent). - * Preserves the file on Chibisafe but keeps it out of active albums. - * - * @param {string|string[]} uuids – Chibisafe file UUID(s) - * @returns {Promise} + * Move one or more files into the "archived" album. */ async function archiveFiles(uuids) { if (!ALBUMS.archived) { @@ -172,20 +141,15 @@ async function archiveFiles(uuids) { ...baseHeaders(), "Content-Type": "application/json", }, - body: JSON.stringify({ + data: { files: ids, albumUuid: ALBUMS.archived, - }), + }, }); } /** * Move one or more files into a specific album by UUID. - * Used internally; you can also call it directly for custom album operations. - * - * @param {string|string[]} uuids - * @param {string} albumUuid - * @returns {Promise} */ async function addFilesToAlbum(uuids, albumUuid) { const ids = Array.isArray(uuids) ? uuids : [uuids]; @@ -196,10 +160,10 @@ async function addFilesToAlbum(uuids, albumUuid) { ...baseHeaders(), "Content-Type": "application/json", }, - body: JSON.stringify({ + data: { files: ids, albumUuid, - }), + }, }); } diff --git a/utils/modelToAttributes.util.js b/utils/modelToAttributes.util.js index 12dd1c8..3442838 100644 --- a/utils/modelToAttributes.util.js +++ b/utils/modelToAttributes.util.js @@ -89,7 +89,7 @@ function flattenJsonb(jsonbSchema = {}, prefix = "", exclude = []) { * @param {string[]} exclude - field names to exclude (e.g. ["password", "otp_code"]) * @returns {Array} attributes array */ -function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLabels = {} } = {}) { +function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLabels = {}, context = "" } = {}) { const rawAttrs = model.rawAttributes || model.tableAttributes; const attributes = []; @@ -113,14 +113,14 @@ function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLa // Ordered audit sequence const auditSequence = [ - { field: 'updatedAt', type: 'date' }, - { field: 'modifiedAt', type: 'date' }, - { field: 'updatedBy', type: 'text' }, - { field: 'createdAt', type: 'date' }, - { field: 'createdBy', type: 'text' }, - { field: 'deletedAt', type: 'date' }, - { field: 'deletedBy', type: 'text' }, - ]; + { field: "updatedAt", type: "date", hiddenOnList: false, hiddenOnArchived: true }, + { field: "modifiedAt", type: "date", hiddenOnList: false, hiddenOnArchived: true }, + { field: "updatedBy", type: "text", hiddenOnList: false, hiddenOnArchived: true }, + { field: "createdAt", type: "date", hiddenOnList: false, hiddenOnArchived: true }, + { field: "createdBy", type: "text", hiddenOnList: false, hiddenOnArchived: true }, + { field: "deletedAt", type: "date", hiddenOnList: true, hiddenOnArchived: false }, + { field: "deletedBy", type: "text", hiddenOnList: true, hiddenOnArchived: false }, +]; // ── Normal fields (excluding audit) ───────────────────────────────────────── for (const [field, def] of Object.entries(rawAttrs)) { @@ -148,15 +148,18 @@ function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLa } // ── Audit fields in correct sequence ──────────────────────────────────────── - for (const { field, type, order } of auditSequence) { + for (const { field, type, order, hiddenOnList, hiddenOnArchived } of auditSequence) { if (exclude.includes(field)) continue; if (!rawAttrs[field]) continue; // skip if field doesn't exist on model + const isArchived = context === "archived"; + attributes.push({ name: defaultTimestampLabels[field], type, field, order, + hidden: isArchived ? hiddenOnArchived : hiddenOnList, options: resolveOptions(rawAttrs[field]?.type), }); } diff --git a/utils/paginate.util.js b/utils/paginate.util.js index adab292..91e60e0 100644 --- a/utils/paginate.util.js +++ b/utils/paginate.util.js @@ -81,7 +81,8 @@ async function paginate(model, req, { jsonbColumn = null, findOptions = {}, auditOptions = null, // ← { mdl_Users, parentAlias }, - computedAttributes = [] + computedAttributes = [], + context = "list", } = {}) { const page = Math.max(PAGE_START, parseInt(req.query.page, 10) || PAGE_START); const limit = Math.min(parseInt(req.query.limit, 10) || PAGE_SIZE, MAX_LIMIT); @@ -94,7 +95,7 @@ async function paginate(model, req, { const jsonbExclude = excludeAttributes.filter((f) => f.includes('.')); const jsonbAttr = jsonbColumn ? excludeJsonbPaths(jsonbColumn, jsonbExclude) : null; - const attributes = modelToAttributes(model, { exclude: excludeAttributes, jsonbSchemas }); + const attributes = modelToAttributes(model, { exclude: excludeAttributes, jsonbSchemas, context }); const ALLOWED_FIELDS = attributes.map((a) => a.field); const { where, order } = buildQuery(filters, sort, ALLOWED_FIELDS);