This commit is contained in:
rgrgogu
2026-05-14 12:59:42 +08:00
parent 06c4286c4a
commit c3663572ae
12 changed files with 570 additions and 536 deletions
+345 -339
View File
@@ -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;
}
// ─── 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);
}
};
@@ -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,
+38 -36
View File
@@ -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 },
},
});