mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
Adjusted
This commit is contained in:
@@ -1,30 +1,29 @@
|
|||||||
// controllers/admin/assets.controller.js
|
// controllers/admin/assets.controller.js
|
||||||
|
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const crypto = require("crypto");
|
const crypto = require("crypto");
|
||||||
const { Op } = require("sequelize");
|
const sequelize = require("../../config/db.config");
|
||||||
const sequelize = require("../../config/db.config");
|
const Asset = require("../../models/assets/assets.mdl");
|
||||||
const Asset = require("../../models/assets/assets.mdl");
|
const chibi = require("../../services/chibisafe.service");
|
||||||
const chibi = require("../../services/chibisafe.service");
|
const { extractVideoMeta } = require("../../services/ffprobe.service");
|
||||||
const { extractVideoMeta } = require("../../services/ffprobe.service");
|
const R = require('../../utils/response.util');
|
||||||
const R = require('../../utils/response.util');
|
const { paginate } = require("../../utils/paginate.util");
|
||||||
const { paginate } = require("../../utils/paginate.util");
|
const { adminExclude, jsonbSchemas, computedAttributes, } = require("../../models/assets/assets.attributes");
|
||||||
const {
|
const mdl_Users = require('../../models/users/users.mdl');
|
||||||
adminExclude,
|
|
||||||
jsonbSchemas,
|
const { Op, Sequelize } = require('sequelize');
|
||||||
computedAttributes,
|
const auditByFields = ['createdBy', 'updatedBy', 'deletedBy'];
|
||||||
} = require("../../models/assets/assets.attributes");
|
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const notDeleted = { deletedAt: null };
|
const notDeleted = { deletedAt: null };
|
||||||
|
|
||||||
function resolveFileType(mimeType = "") {
|
function resolveFileType(mimeType = "") {
|
||||||
if (mimeType.startsWith("image/")) return "image";
|
if (mimeType.startsWith("image/")) return "image";
|
||||||
if (mimeType.startsWith("video/")) return "video";
|
if (mimeType.startsWith("video/")) return "video";
|
||||||
if (mimeType.startsWith("application/") || mimeType.startsWith("text/"))
|
if (mimeType.startsWith("application/") || mimeType.startsWith("text/"))
|
||||||
return "document";
|
return "document";
|
||||||
return "other";
|
return "image";
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveExtension(originalName = "") {
|
function resolveExtension(originalName = "") {
|
||||||
@@ -41,13 +40,81 @@ function resolveResolution(width, height) {
|
|||||||
if (h >= 2160) return "4K";
|
if (h >= 2160) return "4K";
|
||||||
if (h >= 1440) return "1440p";
|
if (h >= 1440) return "1440p";
|
||||||
if (h >= 1080) return "1080p";
|
if (h >= 1080) return "1080p";
|
||||||
if (h >= 720) return "720p";
|
if (h >= 720) return "720p";
|
||||||
if (h >= 480) return "480p";
|
if (h >= 480) return "480p";
|
||||||
if (h >= 360) return "360p";
|
if (h >= 360) return "360p";
|
||||||
if (h >= 240) return "240p";
|
if (h >= 240) return "240p";
|
||||||
return `${width}x${height}`;
|
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.
|
* Best-effort cleanup of Chibisafe files after a failed transaction.
|
||||||
* Never throws — the original error is what matters.
|
* Never throws — the original error is what matters.
|
||||||
@@ -71,6 +138,8 @@ exports.getAssets = async (req, res) => {
|
|||||||
excludeAttributes: adminExclude,
|
excludeAttributes: adminExclude,
|
||||||
jsonbSchemas,
|
jsonbSchemas,
|
||||||
computedAttributes,
|
computedAttributes,
|
||||||
|
context: "list",
|
||||||
|
auditOptions: { mdl_Users, parentAlias: 'Asset' },
|
||||||
findOptions: {
|
findOptions: {
|
||||||
where: { deletedAt: null },
|
where: { deletedAt: null },
|
||||||
},
|
},
|
||||||
@@ -94,11 +163,43 @@ exports.getAsset = async (req, res) => {
|
|||||||
|
|
||||||
const asset = await Asset.findOne({
|
const asset = await Asset.findOne({
|
||||||
where: { asset_id: assetId, ...notDeleted },
|
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);
|
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) {
|
} catch (err) {
|
||||||
console.error("[ASSET][GET ONE]", err);
|
console.error("[ASSET][GET ONE]", err);
|
||||||
return R.error(res, "Internal server error.", 500);
|
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 }])
|
// Expects multer.fields([{ name: "file", maxCount: 1 }, { name: "thumbnail", maxCount: 1 }])
|
||||||
|
|
||||||
exports.uploadAsset = async (req, res) => {
|
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 = [];
|
const uploadedChibiUuids = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ── Phase 1a: Validate inputs ─────────────────────────────────────────────
|
// ── Phase 1a: Validate inputs ─────────────────────────────────────────────
|
||||||
|
|
||||||
const file = req.files?.file?.[0];
|
const file = req.files?.file?.[0];
|
||||||
const thumbFile = req.files?.thumbnail?.[0];
|
const thumbFile = req.files?.thumbnail?.[0];
|
||||||
|
|
||||||
if (!file) return R.error(res, "No file uploaded.", 400);
|
if (!file) return R.error(res, "No file uploaded.", 400);
|
||||||
@@ -144,24 +243,21 @@ exports.uploadAsset = async (req, res) => {
|
|||||||
const {
|
const {
|
||||||
display_name,
|
display_name,
|
||||||
description,
|
description,
|
||||||
owner_type,
|
is_public = false,
|
||||||
owner_id,
|
|
||||||
is_public = false,
|
|
||||||
access_level = "private",
|
|
||||||
storage_provider = "local",
|
storage_provider = "local",
|
||||||
storage_bucket,
|
storage_bucket,
|
||||||
storage_key,
|
storage_key,
|
||||||
uploadedBy,
|
createdBy,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (!uploadedBy) {
|
if (!createdBy) {
|
||||||
return R.error(res, "uploadedBy is required.", 400);
|
return R.error(res, "createdBy is required.", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mime_type = file.mimetype;
|
const mime_type = file.mimetype;
|
||||||
const file_type = resolveFileType(mime_type);
|
const file_type = resolveFileType(mime_type);
|
||||||
const extension = resolveExtension(file.originalname);
|
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) {
|
if (file_type === "video" && !thumbFile) {
|
||||||
return R.error(res, "A thumbnail image is required for video uploads. Include it as the 'thumbnail' field.", 400);
|
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 ───────────────────────────────
|
// ── 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;
|
let chibi_uuid = null;
|
||||||
|
|
||||||
if (storage_provider === "chibisafe") {
|
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({
|
const chibiResult = await chibi.uploadFile({
|
||||||
buffer: file.buffer,
|
buffer: file.buffer,
|
||||||
originalname: file.originalname,
|
originalname: file.originalname,
|
||||||
mimetype: mime_type,
|
mimetype: mime_type,
|
||||||
ownerType: owner_type || "",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
file_url = chibiResult.url;
|
file_url = chibiResult.url;
|
||||||
chibi_uuid = chibiResult.uuid;
|
chibi_uuid = chibiResult.uuid;
|
||||||
uploadedChibiUuids.push(chibi_uuid);
|
uploadedChibiUuids.push(chibi_uuid);
|
||||||
|
|
||||||
@@ -203,48 +294,44 @@ exports.uploadAsset = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Phase 1c: ffprobe + thumbnail upload ──────────────────────────────────
|
// ── Phase 1c: ffprobe + thumbnail upload ──────────────────────────────────
|
||||||
// Also heavy — done BEFORE the DB transaction.
|
|
||||||
|
|
||||||
let width = null;
|
let width = null;
|
||||||
let height = null;
|
let height = null;
|
||||||
let resolution = null;
|
let resolution = null;
|
||||||
let duration = null;
|
let duration = null;
|
||||||
let frame_rate = null;
|
let frame_rate = null;
|
||||||
let bitrate = null;
|
let bitrate = null;
|
||||||
let video_codec = null;
|
let video_codec = null;
|
||||||
let audio_codec = null;
|
let audio_codec = null;
|
||||||
let thumbnail_url = null;
|
let thumbnail_url = null;
|
||||||
|
|
||||||
if (file_type === "video") {
|
if (file_type === "video") {
|
||||||
// ffprobe — CPU-bound, can take a few seconds
|
|
||||||
const meta = await extractVideoMeta({
|
const meta = await extractVideoMeta({
|
||||||
buffer: file.buffer,
|
buffer: file.buffer,
|
||||||
extension: extension || "mp4",
|
extension: extension || "mp4",
|
||||||
});
|
});
|
||||||
|
|
||||||
width = meta.width;
|
width = meta.width;
|
||||||
height = meta.height;
|
height = meta.height;
|
||||||
resolution = meta.resolution;
|
resolution = meta.resolution;
|
||||||
duration = meta.duration;
|
duration = meta.duration;
|
||||||
frame_rate = meta.frame_rate;
|
frame_rate = meta.frame_rate;
|
||||||
bitrate = meta.bitrate;
|
bitrate = meta.bitrate;
|
||||||
video_codec = meta.video_codec;
|
video_codec = meta.video_codec;
|
||||||
audio_codec = meta.audio_codec;
|
audio_codec = meta.audio_codec;
|
||||||
|
|
||||||
// Thumbnail upload — another network call, done outside the transaction
|
|
||||||
if (storage_provider === "chibisafe") {
|
if (storage_provider === "chibisafe") {
|
||||||
if (!thumbFile.buffer) {
|
if (!thumbFile.buffer) {
|
||||||
// Clean up the already-uploaded main file before returning
|
|
||||||
await rollbackChibiUploads(uploadedChibiUuids);
|
await rollbackChibiUploads(uploadedChibiUuids);
|
||||||
return R.error(res, "Thumbnail buffer is required. Ensure multer uses memoryStorage.", 400);
|
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({
|
const thumbResult = await chibi.uploadFile({
|
||||||
buffer: thumbFile.buffer,
|
buffer: thumbFile.buffer,
|
||||||
originalname: `thumb_${baseName}.${resolveExtension(thumbFile.originalname) || "jpg"}`,
|
originalname: `thumb_${baseName}.${resolveExtension(thumbFile.originalname) || "jpg"}`,
|
||||||
mimetype: thumbFile.mimetype,
|
mimetype: thumbFile.mimetype,
|
||||||
ownerType: "thumbnail", // always routes to the thumbnails album
|
ownerType: "thumbnail",
|
||||||
});
|
});
|
||||||
|
|
||||||
thumbnail_url = thumbResult.url;
|
thumbnail_url = thumbResult.url;
|
||||||
@@ -257,22 +344,22 @@ exports.uploadAsset = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} 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;
|
const parsedHeight = req.body.height ? parseInt(req.body.height) : null;
|
||||||
width = parsedWidth;
|
width = parsedWidth;
|
||||||
height = parsedHeight;
|
height = parsedHeight;
|
||||||
resolution = resolveResolution(parsedWidth, parsedHeight);
|
resolution = resolveResolution(parsedWidth, parsedHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Phase 2: DB insert — transaction is open for milliseconds only ────────
|
// ── Phase 2: DB insert ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const t = await sequelize.transaction();
|
const t = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
const asset = await Asset.create({
|
const asset = await Asset.create({
|
||||||
original_name: file.originalname,
|
original_name: file.originalname,
|
||||||
display_name: display_name || file.originalname,
|
display_name: display_name || file.originalname,
|
||||||
file_url,
|
file_url,
|
||||||
file_size: file.size,
|
file_size: file.size,
|
||||||
mime_type,
|
mime_type,
|
||||||
extension,
|
extension,
|
||||||
checksum,
|
checksum,
|
||||||
@@ -288,23 +375,20 @@ exports.uploadAsset = async (req, res) => {
|
|||||||
thumbnail_url,
|
thumbnail_url,
|
||||||
description,
|
description,
|
||||||
storage_provider,
|
storage_provider,
|
||||||
storage_bucket: storage_bucket || null,
|
storage_bucket: storage_bucket || null,
|
||||||
storage_key: chibi_uuid || storage_key || file.filename,
|
storage_key: chibi_uuid || storage_key || file.filename,
|
||||||
is_public,
|
is_public,
|
||||||
access_level,
|
createdBy,
|
||||||
owner_type: owner_type || null,
|
|
||||||
owner_id: owner_id || null,
|
|
||||||
uploadedBy,
|
|
||||||
}, { transaction: t });
|
}, { transaction: t });
|
||||||
|
|
||||||
await t.commit();
|
await t.commit();
|
||||||
|
|
||||||
return R.success(res, "Asset uploaded.", { data: asset }, 201);
|
return R.success(res, "Asset uploaded.", { data: asset }, 201);
|
||||||
|
|
||||||
} catch (dbErr) {
|
} catch (dbErr) {
|
||||||
// DB failed — rollback and clean up the Chibisafe uploads
|
|
||||||
try { await t.rollback(); } catch { /* connection already gone */ }
|
try { await t.rollback(); } catch { /* connection already gone */ }
|
||||||
await rollbackChibiUploads(uploadedChibiUuids);
|
await rollbackChibiUploads(uploadedChibiUuids);
|
||||||
throw dbErr; // re-throw to outer catch for logging + response
|
throw dbErr;
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -318,305 +402,227 @@ exports.uploadAsset = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── UPDATE THUMBNAIL ─────────────────────────────────────────────────────────
|
// ─── ARCHIVE (single) ─────────────────────────────────────────────────────────
|
||||||
//
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
|
exports.archiveAsset = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { assetId } = req.params;
|
const { assetId } = req.params;
|
||||||
const thumbFile = req.files?.thumbnail?.[0] ?? req.file;
|
if (!assetId || assetId === "undefined") return R.error(res, "Invalid asset ID.", 400);
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } });
|
const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } });
|
||||||
if (!asset) return R.error(res, "Asset not found.", 404);
|
if (!asset) return R.error(res, "Asset not found.", 404);
|
||||||
|
|
||||||
const oldThumbUuid = asset.storage_provider === "chibisafe"
|
await asset.update({ deletedBy: req.body.deletedBy ?? null });
|
||||||
? asset.thumbnail_storage_key ?? null // store separately if you have it,
|
await asset.destroy();
|
||||||
: 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 });
|
|
||||||
|
|
||||||
|
return R.success(res, "Asset archived.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (newThumbUuid) await rollbackChibiUploads([newThumbUuid]);
|
console.error("[ASSET][ARCHIVE]", err);
|
||||||
console.error("[ASSET][UPDATE THUMBNAIL]", err);
|
|
||||||
return R.error(res, "Internal server error.", 500);
|
return R.error(res, "Internal server error.", 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── UPDATE METADATA ──────────────────────────────────────────────────────────
|
// ─── ARCHIVE (bulk) ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
exports.updateAsset = async (req, res) => {
|
exports.archiveAssets = 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();
|
|
||||||
try {
|
try {
|
||||||
const { ids, deletedBy } = req.body;
|
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) {
|
const assets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids }, ...notDeleted } });
|
||||||
await t.rollback();
|
if (!assets.length) return R.error(res, "No assets found.", 404);
|
||||||
return R.error(res, "ids must be a non-empty array.", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const chibiAssets = await Asset.findAll({
|
const activeIds = assets.map((a) => a.asset_id);
|
||||||
where: {
|
|
||||||
asset_id: { [Op.in]: ids },
|
|
||||||
storage_provider: "chibisafe",
|
|
||||||
storage_key: { [Op.not]: null },
|
|
||||||
...notDeleted,
|
|
||||||
},
|
|
||||||
attributes: ["storage_key"],
|
|
||||||
transaction: t,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Archive on Chibisafe (best-effort, non-fatal)
|
await Asset.update(
|
||||||
if (chibiAssets.length) {
|
{ deletedBy: deletedBy ?? null },
|
||||||
try {
|
{ where: { asset_id: { [Op.in]: activeIds } } }
|
||||||
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.destroy({ where: { asset_id: { [Op.in]: activeIds } } });
|
||||||
|
|
||||||
await t.commit();
|
return R.success(res, `${activeIds.length} asset(s) archived.`, {
|
||||||
return R.success(res, `${count} asset(s) deleted.`);
|
archived_ids: activeIds,
|
||||||
|
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
try { await t.rollback(); } catch { /* already rolled back */ }
|
console.error("[ASSET][BULK ARCHIVE]", err);
|
||||||
console.error("[ASSET][BULK DELETE]", err);
|
|
||||||
return R.error(res, "Internal server error.", 500);
|
return R.error(res, "Internal server error.", 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── RESTORE (single) ─────────────────────────────────────────────────────────
|
// ─── 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) => {
|
exports.restoreAsset = async (req, res) => {
|
||||||
const t = await sequelize.transaction();
|
|
||||||
try {
|
try {
|
||||||
const { assetId } = req.params;
|
const { assetId } = req.params;
|
||||||
|
|
||||||
const asset = await Asset.findOne({
|
const asset = await Asset.findOne({
|
||||||
where: { asset_id: assetId, deletedAt: { [Op.not]: null } },
|
where: { asset_id: assetId },
|
||||||
transaction: t,
|
paranoid: false,
|
||||||
lock: t.LOCK.UPDATE,
|
|
||||||
});
|
});
|
||||||
if (!asset) {
|
if (!asset) return R.error(res, "Asset not found.", 404);
|
||||||
await t.rollback();
|
if (!asset.deletedAt) return R.error(res, "Asset is not archived.", 400);
|
||||||
return R.error(res, "Asset not found or not deleted.", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
asset.deletedAt = null;
|
await asset.restore();
|
||||||
asset.deletedBy = null;
|
await asset.update({ 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return R.success(res, "Asset restored.", { data: asset });
|
return R.success(res, "Asset restored.", { data: asset });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
try { await t.rollback(); } catch { /* already rolled back */ }
|
|
||||||
console.error("[ASSET][RESTORE]", err);
|
console.error("[ASSET][RESTORE]", err);
|
||||||
return R.error(res, "Internal server error.", 500);
|
return R.error(res, "Internal server error.", 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// ─── RESTORE (bulk) ───────────────────────────────────────────────────────────
|
||||||
* Maps asset owner_type to the ALBUMS key in chibisafe.service.
|
|
||||||
* Returns null for types that have no dedicated album (e.g. "image").
|
exports.restoreAssets = async (req, res) => {
|
||||||
*
|
try {
|
||||||
* @param {string} ownerType
|
const { ids } = req.body;
|
||||||
* @returns {string|null}
|
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
|
||||||
*/
|
|
||||||
function ownerTypeToAlbumKey(ownerType) {
|
const assets = await Asset.findAll({
|
||||||
const map = {
|
where: { asset_id: { [Op.in]: ids } },
|
||||||
avatar: "avatars",
|
paranoid: false,
|
||||||
video: "videos",
|
});
|
||||||
document: "documents",
|
if (!assets.length) return R.error(res, "No assets found.", 404);
|
||||||
};
|
|
||||||
return map[ownerType] ?? null;
|
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,
|
excludeAttributes: groupExclude,
|
||||||
jsonbSchemas: groupSchemas,
|
jsonbSchemas: groupSchemas,
|
||||||
computedAttributes: groupComputed,
|
computedAttributes: groupComputed,
|
||||||
|
context: "list",
|
||||||
auditOptions: { mdl_Users, parentAlias: 'UserGroup' },
|
auditOptions: { mdl_Users, parentAlias: 'UserGroup' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ exports.getGroup = async (req, res) => {
|
|||||||
jsonbSchemas: usersSchemas,
|
jsonbSchemas: usersSchemas,
|
||||||
jsonbColumn: 'personal_info',
|
jsonbColumn: 'personal_info',
|
||||||
auditOptions: { mdl_Users, parentAlias: 'User' },
|
auditOptions: { mdl_Users, parentAlias: 'User' },
|
||||||
|
context: "list",
|
||||||
findOptions: {
|
findOptions: {
|
||||||
include: [{
|
include: [{
|
||||||
model: mdl_UserGroupMembers,
|
model: mdl_UserGroupMembers,
|
||||||
@@ -217,6 +219,7 @@ exports.getArchivedGroups = async (req, res) => {
|
|||||||
excludeAttributes: groupExclude,
|
excludeAttributes: groupExclude,
|
||||||
jsonbSchemas: groupSchemas,
|
jsonbSchemas: groupSchemas,
|
||||||
computedAttributes: groupComputed,
|
computedAttributes: groupComputed,
|
||||||
|
context: "archived",
|
||||||
auditOptions: { mdl_Users, parentAlias: 'UserGroup' },
|
auditOptions: { mdl_Users, parentAlias: 'UserGroup' },
|
||||||
findOptions: {
|
findOptions: {
|
||||||
paranoid: false,
|
paranoid: false,
|
||||||
|
|||||||
@@ -11,18 +11,18 @@ const { Op, Sequelize } = require('sequelize');
|
|||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const crypto = require('crypto');
|
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_UserSessions = require('../../models/users/user_sessions.mdl');
|
||||||
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||||
|
|
||||||
const sendEmail = require('../../services/email.service');
|
const sendEmail = require('../../services/email.service');
|
||||||
const R = require('../../utils/response.util');
|
const R = require('../../utils/response.util');
|
||||||
const { paginate } = require('../../utils/paginate.util');
|
const { paginate } = require('../../utils/paginate.util');
|
||||||
const { enrichPersonalInfo } = require('../../utils/personalInfo.util');
|
const { enrichPersonalInfo } = require('../../utils/personalInfo.util');
|
||||||
|
|
||||||
const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas } = require('../../models/users/users.attributes');
|
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'];
|
const auditByFields = ['createdBy', 'updatedBy', 'deletedBy'];
|
||||||
|
|
||||||
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
||||||
@@ -31,9 +31,10 @@ exports.getUsers = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const result = await paginate(mdl_Users, req, {
|
const result = await paginate(mdl_Users, req, {
|
||||||
excludeAttributes: usersExclude,
|
excludeAttributes: usersExclude,
|
||||||
jsonbSchemas: usersSchemas,
|
jsonbSchemas: usersSchemas,
|
||||||
jsonbColumn: 'personal_info',
|
jsonbColumn: 'personal_info',
|
||||||
auditOptions: { mdl_Users, parentAlias: 'User' },
|
context: "list",
|
||||||
|
auditOptions: { mdl_Users, parentAlias: 'User' },
|
||||||
});
|
});
|
||||||
|
|
||||||
return R.success(res, 'Users retrieved.', result);
|
return R.success(res, 'Users retrieved.', result);
|
||||||
@@ -54,7 +55,7 @@ exports.getUser = async (req, res) => {
|
|||||||
attributes: { exclude: EXCLUDED },
|
attributes: { exclude: EXCLUDED },
|
||||||
include: [{
|
include: [{
|
||||||
model: mdl_UserGroups,
|
model: mdl_UserGroups,
|
||||||
as: 'groups',
|
as: 'groups',
|
||||||
through: { attributes: [] },
|
through: { attributes: [] },
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
@@ -82,23 +83,23 @@ exports.addStaffUser = async (req, res) => {
|
|||||||
const existing = await mdl_Users.findOne({ where: { email } });
|
const existing = await mdl_Users.findOne({ where: { email } });
|
||||||
if (existing) return R.error(res, 'Email is already in use.', 409);
|
if (existing) return R.error(res, 'Email is already in use.', 409);
|
||||||
|
|
||||||
const plainPassword = crypto.randomBytes(8).toString('base64url').slice(0, 12);
|
const plainPassword = crypto.randomBytes(8).toString('base64url').slice(0, 12);
|
||||||
const hashed = await bcrypt.hash(plainPassword, 12);
|
const hashed = await bcrypt.hash(plainPassword, 12);
|
||||||
const passwordExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
const passwordExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||||
const enriched = enrichPersonalInfo(personal_info);
|
const enriched = enrichPersonalInfo(personal_info);
|
||||||
const fullName = enriched?.name?.full_name ?? email;
|
const fullName = enriched?.name?.full_name ?? email;
|
||||||
|
|
||||||
const user = await mdl_Users.create({
|
const user = await mdl_Users.create({
|
||||||
email,
|
email,
|
||||||
password: hashed,
|
password: hashed,
|
||||||
password_expires_at: passwordExpiresAt,
|
password_expires_at: passwordExpiresAt,
|
||||||
must_change_password: true,
|
must_change_password: true,
|
||||||
acc_type: 'staff',
|
acc_type: 'staff',
|
||||||
reg_type: 'system',
|
reg_type: 'system',
|
||||||
is_active: true,
|
is_active: true,
|
||||||
is_verified: true,
|
is_verified: true,
|
||||||
createdBy: req.user.user_id,
|
createdBy: req.user.user_id,
|
||||||
personal_info: enriched,
|
personal_info: enriched,
|
||||||
});
|
});
|
||||||
|
|
||||||
await sendEmail({
|
await sendEmail({
|
||||||
@@ -108,8 +109,8 @@ exports.addStaffUser = async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return R.success(res, 'Staff user created successfully.', {
|
return R.success(res, 'Staff user created successfully.', {
|
||||||
user_id: user.user_id,
|
user_id: user.user_id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
acc_type: user.acc_type,
|
acc_type: user.acc_type,
|
||||||
}, 201);
|
}, 201);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -205,7 +206,7 @@ exports.bulkDeactivateUsers = async (req, res) => {
|
|||||||
|
|
||||||
return R.success(res, `${activeIds.length} user(s) deactivated successfully.`, {
|
return R.success(res, `${activeIds.length} user(s) deactivated successfully.`, {
|
||||||
deactivated_ids: activeIds,
|
deactivated_ids: activeIds,
|
||||||
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ADMIN][BULK DEACTIVATE USERS]', err);
|
console.error('[ADMIN][BULK DEACTIVATE USERS]', err);
|
||||||
@@ -220,7 +221,7 @@ exports.restoreUser = async (req, res) => {
|
|||||||
const user = await mdl_Users.findOne({
|
const user = await mdl_Users.findOne({
|
||||||
where: { user_id: req.params.id }, paranoid: false,
|
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);
|
if (!user.deletedAt) return R.error(res, 'User is not deactivated.', 400);
|
||||||
|
|
||||||
await user.restore();
|
await user.restore();
|
||||||
@@ -258,7 +259,7 @@ exports.bulkRestoreUsers = async (req, res) => {
|
|||||||
|
|
||||||
return R.success(res, `${deletedIds.length} user(s) restored successfully.`, {
|
return R.success(res, `${deletedIds.length} user(s) restored successfully.`, {
|
||||||
restored_ids: deletedIds,
|
restored_ids: deletedIds,
|
||||||
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
|
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ADMIN][BULK RESTORE USERS]', err);
|
console.error('[ADMIN][BULK RESTORE USERS]', err);
|
||||||
@@ -271,9 +272,9 @@ exports.bulkRestoreUsers = async (req, res) => {
|
|||||||
exports.getUserSessions = async (req, res) => {
|
exports.getUserSessions = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const sessions = await mdl_UserSessions.findAll({
|
const sessions = await mdl_UserSessions.findAll({
|
||||||
where: { user_id: req.params.id },
|
where: { user_id: req.params.id },
|
||||||
attributes: { exclude: ['refresh_token_hash'] },
|
attributes: { exclude: ['refresh_token_hash'] },
|
||||||
order: [['createdAt', 'DESC']],
|
order: [['createdAt', 'DESC']],
|
||||||
});
|
});
|
||||||
return R.success(res, 'Sessions retrieved.', sessions);
|
return R.success(res, 'Sessions retrieved.', sessions);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -288,7 +289,7 @@ exports.terminateSession = async (req, res) => {
|
|||||||
if (!session) return R.error(res, 'Session not found.', 404);
|
if (!session) return R.error(res, 'Session not found.', 404);
|
||||||
|
|
||||||
await session.update({
|
await session.update({
|
||||||
is_active: false,
|
is_active: false,
|
||||||
logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id },
|
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']],
|
attributes: [[Sequelize.fn('DISTINCT', Sequelize.fn('DATE', Sequelize.col(field))), 'value']],
|
||||||
where: { [field]: { [Op.ne]: null } },
|
where: { [field]: { [Op.ne]: null } },
|
||||||
order: [[Sequelize.fn('DATE', Sequelize.col(field)), 'DESC']],
|
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));
|
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({
|
const results = await mdl_Users.findAll({
|
||||||
attributes: [[Sequelize.fn('DISTINCT', Sequelize.col(field)), 'value']],
|
attributes: [[Sequelize.fn('DISTINCT', Sequelize.col(field)), 'value']],
|
||||||
where: { [field]: { [Op.ne]: null } },
|
where: { [field]: { [Op.ne]: null } },
|
||||||
raw: true,
|
raw: true,
|
||||||
});
|
});
|
||||||
return R.success(res, 'Field values retrieved.', results.map((r) => r.value).filter(Boolean).sort());
|
return R.success(res, 'Field values retrieved.', results.map((r) => r.value).filter(Boolean).sort());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── JSONB dot-notation ───────────────────────────────────────────────
|
// ─── JSONB dot-notation ───────────────────────────────────────────────
|
||||||
const [column, ...pathParts] = field.split('.');
|
const [column, ...pathParts] = field.split('.');
|
||||||
const keys = [...pathParts];
|
const keys = [...pathParts];
|
||||||
const lastKey = keys.pop();
|
const lastKey = keys.pop();
|
||||||
const jsonbPath = keys.length
|
const jsonbPath = keys.length
|
||||||
? `"${column}"->${keys.map((k) => `'${k}'`).join('->')}->>'${lastKey}'`
|
? `"${column}"->${keys.map((k) => `'${k}'`).join('->')}->>'${lastKey}'`
|
||||||
@@ -356,7 +357,7 @@ exports.getUserFieldValues = async (req, res) => {
|
|||||||
const results = await mdl_Users.findAll({
|
const results = await mdl_Users.findAll({
|
||||||
attributes: [[Sequelize.literal(`DISTINCT ${jsonbPath}`), 'value']],
|
attributes: [[Sequelize.literal(`DISTINCT ${jsonbPath}`), 'value']],
|
||||||
where: Sequelize.literal(`${jsonbPath} IS NOT NULL`),
|
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());
|
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 {
|
try {
|
||||||
const result = await paginate(mdl_Users, req, {
|
const result = await paginate(mdl_Users, req, {
|
||||||
excludeAttributes: usersExclude,
|
excludeAttributes: usersExclude,
|
||||||
jsonbSchemas: usersSchemas,
|
jsonbSchemas: usersSchemas,
|
||||||
jsonbColumn: 'personal_info',
|
jsonbColumn: 'personal_info',
|
||||||
auditOptions: { mdl_Users, parentAlias: 'User' },
|
context: "archived",
|
||||||
|
auditOptions: { mdl_Users, parentAlias: 'User' },
|
||||||
findOptions: {
|
findOptions: {
|
||||||
paranoid: false,
|
paranoid: false,
|
||||||
where: { deletedAt: { [Op.ne]: null }, is_active: false },
|
where: { deletedAt: { [Op.ne]: null }, is_active: false },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ const excludeAttributes = [
|
|||||||
"checksum", // internal integrity hash, not useful to clients
|
"checksum", // internal integrity hash, not useful to clients
|
||||||
"storage_bucket", // internal storage config
|
"storage_bucket", // internal storage config
|
||||||
"storage_key", // internal Chibisafe / S3 key
|
"storage_key", // internal Chibisafe / S3 key
|
||||||
"deletedBy", // exposed via audit subquery as a name instead
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Admins see everything except the base excludes
|
// Admins see everything except the base excludes
|
||||||
|
|||||||
+43
-50
@@ -1,84 +1,77 @@
|
|||||||
// models/Asset.js
|
// models/Asset.js
|
||||||
const { DataTypes } = require("sequelize");
|
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", {
|
const Asset = sequelize.define("Asset", {
|
||||||
|
|
||||||
// ─── Identity ─────────────────────────────────────────────────────────────
|
// ─── Identity ─────────────────────────────────────────────────────────────
|
||||||
asset_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: 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 },
|
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, label: "UUID", order: 0, hidden: true },
|
||||||
|
|
||||||
// ─── File info ────────────────────────────────────────────────────────────
|
// ─── File info ────────────────────────────────────────────────────────────
|
||||||
original_name: { type: DataTypes.STRING(255), allowNull: false },
|
original_name: { type: DataTypes.STRING(255), allowNull: false, label: "Original Name", order: 0, hidden: true },
|
||||||
display_name: { type: DataTypes.STRING(255), allowNull: false },
|
display_name: { type: DataTypes.STRING(255), allowNull: false, label: "Name", order: 0 },
|
||||||
file_url: { type: DataTypes.STRING(512), allowNull: false },
|
file_url: { type: DataTypes.STRING(512), allowNull: false, label: "File URL", order: 0, hidden: true },
|
||||||
file_size: { type: DataTypes.BIGINT, allowNull: false },
|
file_size: { type: DataTypes.BIGINT, allowNull: false, label: "File Size", order: 0, hidden: true },
|
||||||
mime_type: { type: DataTypes.STRING(100), allowNull: false },
|
mime_type: { type: DataTypes.STRING(100), allowNull: false, label: "MIME Type", order: 0, hidden: true },
|
||||||
extension: { type: DataTypes.STRING(20) },
|
extension: { type: DataTypes.STRING(20), label: "File Type", order: 0 },
|
||||||
checksum: { type: DataTypes.STRING(64) },
|
checksum: { type: DataTypes.STRING(64), label: "Checksum", order: 0, hidden: true },
|
||||||
|
|
||||||
// ─── Classification ───────────────────────────────────────────────────────
|
// ─── Classification ───────────────────────────────────────────────────────
|
||||||
file_type: {
|
file_type: {
|
||||||
type: DataTypes.ENUM("image", "video", "document", "other"),
|
type: DataTypes.ENUM("avatar", "document", "video", "image"),
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
defaultValue: "other",
|
defaultValue: "image", label: "Classification", order: 0
|
||||||
},
|
},
|
||||||
|
|
||||||
// ─── Image & video dimensions ─────────────────────────────────────────────
|
// ─── Image & video dimensions ─────────────────────────────────────────────
|
||||||
width: { type: DataTypes.INTEGER },
|
width: { type: DataTypes.INTEGER, label: "", order: 0, hidden: true },
|
||||||
height: { type: DataTypes.INTEGER },
|
height: { type: DataTypes.INTEGER, label: "", order: 0, hidden: true },
|
||||||
|
|
||||||
// ─── Video-specific ───────────────────────────────────────────────────────
|
// ─── Video-specific ───────────────────────────────────────────────────────
|
||||||
duration: { type: DataTypes.FLOAT }, // seconds
|
duration: { type: DataTypes.FLOAT, label: "", order: 0, hidden: true }, // seconds
|
||||||
resolution: { type: DataTypes.STRING(20) }, // "1080p", "720p", "4K"
|
resolution: { type: DataTypes.STRING(20), label: "", order: 0, hidden: true }, // "1080p", "720p", "4K"
|
||||||
frame_rate: { type: DataTypes.FLOAT }, // fps
|
frame_rate: { type: DataTypes.FLOAT, label: "", order: 0, hidden: true }, // fps
|
||||||
bitrate: { type: DataTypes.BIGINT }, // bps
|
bitrate: { type: DataTypes.BIGINT, label: "", order: 0, hidden: true }, // bps
|
||||||
video_codec: { type: DataTypes.STRING(50) }, // "H.264", "H.265"
|
video_codec: { type: DataTypes.STRING(50), label: "", order: 0, hidden: true }, // "H.264", "H.265"
|
||||||
audio_codec: { type: DataTypes.STRING(50) }, // "AAC", "MP3"
|
audio_codec: { type: DataTypes.STRING(50), label: "", order: 0, hidden: true }, // "AAC", "MP3"
|
||||||
thumbnail_url: { type: DataTypes.STRING(512) }, // face of the video / doc preview
|
thumbnail_url: { type: DataTypes.STRING(512), label: "", order: 0, hidden: true }, // face of the video / doc preview
|
||||||
|
|
||||||
// ─── Description ──────────────────────────────────────────────────────────
|
// ─── Description ──────────────────────────────────────────────────────────
|
||||||
description: { type: DataTypes.TEXT },
|
description: { type: DataTypes.TEXT, label: "Description", hidden: true },
|
||||||
|
|
||||||
// ─── Storage ──────────────────────────────────────────────────────────────
|
// ─── Storage ──────────────────────────────────────────────────────────────
|
||||||
storage_provider: {
|
storage_provider: {
|
||||||
type: DataTypes.ENUM("local", "s3", "gcs", "cloudinary", "chibisafe", "other"),
|
type: DataTypes.ENUM("local", "s3", "gcs", "cloudinary", "chibisafe", "other"),
|
||||||
defaultValue: "local",
|
defaultValue: "local", label: "Storage Provider", order: 0
|
||||||
},
|
},
|
||||||
storage_bucket: { type: DataTypes.STRING(255) },
|
storage_bucket: { type: DataTypes.STRING(255), label: "", order: 0, hidden: true },
|
||||||
storage_key: { type: DataTypes.STRING(512) },
|
storage_key: { type: DataTypes.STRING(512), label: "", order: 0, hidden: true },
|
||||||
|
|
||||||
// ─── Access control ───────────────────────────────────────────────────────
|
// ─── Access control ───────────────────────────────────────────────────────
|
||||||
is_public: {
|
is_public: {
|
||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
defaultValue: false,
|
defaultValue: false, label: "Access Type", order: 0
|
||||||
},
|
|
||||||
access_level: {
|
|
||||||
type: DataTypes.ENUM("public", "private", "restricted"),
|
|
||||||
defaultValue: "private",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ─── Polymorphic ownership ────────────────────────────────────────────────
|
// ── Audit trails ────────────────────────────────────────────────────────────
|
||||||
owner_type: { type: DataTypes.STRING(100) }, // avatar, document, video, image
|
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" },
|
||||||
owner_id: { type: DataTypes.BIGINT },
|
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" },
|
||||||
|
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" },
|
||||||
// ─── 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 },
|
|
||||||
}, {
|
}, {
|
||||||
tableName: "assets",
|
tableName: "assets",
|
||||||
timestamps: true, // createdAt, updatedAt
|
timestamps: true, // createdAt, updatedAt
|
||||||
paranoid: false,
|
paranoid: true,
|
||||||
indexes: [
|
indexes: [
|
||||||
{ fields: ["uuid"] },
|
{ fields: ["uuid"] },
|
||||||
{ fields: ["owner_type", "owner_id"] },
|
{ fields: ["createdBy"] },
|
||||||
{ fields: ["uploadedBy"] },
|
{ fields: ["file_type"] },
|
||||||
{ fields: ["file_type"] },
|
{ fields: ["deletedAt"] },
|
||||||
{ fields: ["deletedAt"] },
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Asset.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
|
||||||
|
Asset.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
|
||||||
|
|
||||||
module.exports = Asset;
|
module.exports = Asset;
|
||||||
Generated
+41
@@ -8,6 +8,7 @@
|
|||||||
"name": "star-auth-system",
|
"name": "star-auth-system",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"axios": "^1.16.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
@@ -187,6 +188,17 @@
|
|||||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/balanced-match": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
@@ -908,6 +920,26 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/form-data": {
|
||||||
"version": "4.0.5",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||||
@@ -1969,6 +2001,15 @@
|
|||||||
"node": ">= 0.10"
|
"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": {
|
"node_modules/pstree.remy": {
|
||||||
"version": "1.1.8",
|
"version": "1.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"dev": "nodemon server.js"
|
"dev": "nodemon server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"axios": "^1.16.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
@@ -6,14 +6,20 @@ const upload = multer({ storage: multer.memoryStorage() });
|
|||||||
const controller = require('../../controllers/admin/assets.controller');
|
const controller = require('../../controllers/admin/assets.controller');
|
||||||
const { sensitiveOpsLimiter } = require('../../middleware/rateLimiter.middleware');
|
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.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.get('/:assetId', controller.getAsset);
|
||||||
router.post( "/upload", upload.fields([ { name: "file", maxCount: 1 }, { name: "thumbnail", maxCount: 1 }, ]), controller.uploadAsset, );
|
router.patch('/:assetId', sensitiveOpsLimiter, upload.fields([{ name: 'file', maxCount: 1 }]), controller.updateAsset);
|
||||||
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/restore', sensitiveOpsLimiter, controller.restoreAsset);
|
router.patch('/:assetId/restore', sensitiveOpsLimiter, controller.restoreAsset);
|
||||||
|
router.delete('/:assetId', sensitiveOpsLimiter, controller.archiveAsset);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -17,29 +17,29 @@
|
|||||||
***********************************************************************************************************************************************************************/
|
***********************************************************************************************************************************************************************/
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const cookieParser = require('cookie-parser');
|
const cookieParser = require('cookie-parser');
|
||||||
const session = require('express-session');
|
const session = require('express-session');
|
||||||
const passport = require('./config/passport.config');
|
const passport = require('./config/passport.config');
|
||||||
const sequelize = require('./config/db.config');
|
const sequelize = require('./config/db.config');
|
||||||
|
|
||||||
// ── Middleware ──────────────────────────────────────────────────────────────────
|
// ── Middleware ──────────────────────────────────────────────────────────────────
|
||||||
const { globalLimiter } = require('./middleware/rateLimiter.middleware');
|
const { globalLimiter } = require('./middleware/rateLimiter.middleware');
|
||||||
const { csrfErrorHandler } = require('./middleware/csrf.middleware');
|
const { csrfErrorHandler } = require('./middleware/csrf.middleware');
|
||||||
|
|
||||||
// ── Routes ─────────────────────────────────────────────────────────────────────
|
// ── Routes ─────────────────────────────────────────────────────────────────────
|
||||||
const authRoutes = require('./routes/auth.routes');
|
const authRoutes = require('./routes/auth.routes');
|
||||||
const clientRoutes = require('./routes/client/client.routes');
|
const clientRoutes = require('./routes/client/client.routes');
|
||||||
const staffRoutes = require('./routes/staff/staff.routes');
|
const staffRoutes = require('./routes/staff/staff.routes');
|
||||||
const adminRoutes = require('./routes/admin/admin.routes');
|
const adminRoutes = require('./routes/admin/admin.routes');
|
||||||
|
|
||||||
// ── Models (ensure associations are loaded) ────────────────────────────────────
|
// ── Models (ensure associations are loaded) ────────────────────────────────────
|
||||||
require('./models/users/users.mdl');
|
require('./models/users/users.mdl');
|
||||||
require('./models/users/user_sessions.mdl');
|
require('./models/users/user_sessions.mdl');
|
||||||
require('./models/users/user_groups.mdl');
|
require('./models/users/user_groups.mdl');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
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(',');
|
const allowedOrigins = (process.env.ALLOWED_ORIGINS || process.env.APP_URL || '*').split(',');
|
||||||
|
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: (origin, callback) => {
|
origin: (origin, callback) => {
|
||||||
if (!origin || allowedOrigins.includes(origin)) return callback(null, true);
|
if (!origin || allowedOrigins.includes(origin)) return callback(null, true);
|
||||||
callback(new Error(`CORS: origin ${origin} not allowed`));
|
callback(new Error(`CORS: origin ${origin} not allowed`));
|
||||||
},
|
},
|
||||||
credentials: true,
|
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());
|
app.use(express.json());
|
||||||
@@ -63,10 +78,10 @@ app.use(cookieParser());
|
|||||||
|
|
||||||
// Session — used only by csurf; JWT handles auth state
|
// Session — used only by csurf; JWT handles auth state
|
||||||
app.use(session({
|
app.use(session({
|
||||||
secret: process.env.SESSION_SECRET || 'change-me',
|
secret: process.env.SESSION_SECRET || 'change-me',
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: 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());
|
app.use(passport.initialize());
|
||||||
@@ -77,10 +92,10 @@ app.use(globalLimiter);
|
|||||||
// ──────────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
// Routes
|
// Routes
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/client', clientRoutes);
|
app.use('/api/client', clientRoutes);
|
||||||
app.use('/api/staff', staffRoutes);
|
app.use('/api/staff', staffRoutes);
|
||||||
app.use('/api/admin', adminRoutes);
|
app.use('/api/admin', adminRoutes);
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
// CHIBISAFE_ALBUM_ARCHIVED – album UUID used as the "trash" / archived album
|
// CHIBISAFE_ALBUM_ARCHIVED – album UUID used as the "trash" / archived album
|
||||||
|
|
||||||
const FormData = require("form-data");
|
const FormData = require("form-data");
|
||||||
const fetch = require("node-fetch"); // npm i node-fetch@2 (CJS-compatible)
|
const axios = require("axios");
|
||||||
|
|
||||||
// ─── Config ───────────────────────────────────────────────────────────────────
|
// ─── Config ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -24,38 +24,23 @@ const ALBUMS = {
|
|||||||
videos: process.env.CHIBISAFE_ALBUM_VIDEOS || null,
|
videos: process.env.CHIBISAFE_ALBUM_VIDEOS || null,
|
||||||
documents: process.env.CHIBISAFE_ALBUM_DOCUMENTS || null,
|
documents: process.env.CHIBISAFE_ALBUM_DOCUMENTS || null,
|
||||||
thumbnails: process.env.CHIBISAFE_ALBUM_THUMBNAILS || null,
|
thumbnails: process.env.CHIBISAFE_ALBUM_THUMBNAILS || null,
|
||||||
|
images: process.env.CHIBISAFE_ALBUM_IMAGES || null, // ← new
|
||||||
archived: process.env.CHIBISAFE_ALBUM_ARCHIVED || null,
|
archived: process.env.CHIBISAFE_ALBUM_ARCHIVED || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
// ─── 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 = "") {
|
function resolveAlbumUuid(ownerType = "") {
|
||||||
switch (ownerType) {
|
switch (ownerType) {
|
||||||
case "avatar": return ALBUMS.avatars;
|
case "avatar": return ALBUMS.avatars;
|
||||||
case "video": return ALBUMS.videos;
|
case "video": return ALBUMS.videos;
|
||||||
case "document": return ALBUMS.documents;
|
case "document": return ALBUMS.documents;
|
||||||
case "thumbnail": return ALBUMS.thumbnails;
|
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 = {}) {
|
function baseHeaders(extra = {}) {
|
||||||
return {
|
return {
|
||||||
"x-api-key": API_KEY,
|
"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 = {}) {
|
async function chibiRequest(path, { method = "GET", headers = {}, data } = {}) {
|
||||||
const url = `${BASE_URL}${path}`;
|
|
||||||
const res = await fetch(url, options);
|
|
||||||
|
|
||||||
let body;
|
|
||||||
try {
|
try {
|
||||||
body = await res.json();
|
const res = await chibi.request({
|
||||||
} catch {
|
url: path,
|
||||||
body = {};
|
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 ───────────────────────────────────────────────────────────────
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload a file to Chibisafe, optionally straight into a typed album.
|
* 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 = "" }) {
|
async function uploadFile({ buffer, originalname, mimetype, ownerType = "" }) {
|
||||||
if (!BASE_URL || !API_KEY) {
|
if (!BASE_URL || !API_KEY) {
|
||||||
@@ -115,21 +97,16 @@ async function uploadFile({ buffer, originalname, mimetype, ownerType = "" }) {
|
|||||||
contentType: mimetype,
|
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", {
|
const data = await chibiRequest("/api/upload", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers: {
|
||||||
body: form,
|
...baseHeaders(),
|
||||||
|
...form.getHeaders(), // ← axios needs these
|
||||||
|
...(albumUuid ? { albumuuid: albumUuid } : {}),
|
||||||
|
},
|
||||||
|
data: form,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Chibisafe returns: { name, uuid, url, ... }
|
|
||||||
return {
|
return {
|
||||||
uuid: data.uuid,
|
uuid: data.uuid,
|
||||||
url: data.url,
|
url: data.url,
|
||||||
@@ -139,10 +116,6 @@ async function uploadFile({ buffer, originalname, mimetype, ownerType = "" }) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Permanently delete one file from Chibisafe by its UUID.
|
* 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<void>}
|
|
||||||
*/
|
*/
|
||||||
async function deleteFile(uuid) {
|
async function deleteFile(uuid) {
|
||||||
await chibiRequest(`/api/file/${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).
|
* Move one or more files into the "archived" album.
|
||||||
* Preserves the file on Chibisafe but keeps it out of active albums.
|
|
||||||
*
|
|
||||||
* @param {string|string[]} uuids – Chibisafe file UUID(s)
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
*/
|
||||||
async function archiveFiles(uuids) {
|
async function archiveFiles(uuids) {
|
||||||
if (!ALBUMS.archived) {
|
if (!ALBUMS.archived) {
|
||||||
@@ -172,20 +141,15 @@ async function archiveFiles(uuids) {
|
|||||||
...baseHeaders(),
|
...baseHeaders(),
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
data: {
|
||||||
files: ids,
|
files: ids,
|
||||||
albumUuid: ALBUMS.archived,
|
albumUuid: ALBUMS.archived,
|
||||||
}),
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Move one or more files into a specific album by UUID.
|
* 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<void>}
|
|
||||||
*/
|
*/
|
||||||
async function addFilesToAlbum(uuids, albumUuid) {
|
async function addFilesToAlbum(uuids, albumUuid) {
|
||||||
const ids = Array.isArray(uuids) ? uuids : [uuids];
|
const ids = Array.isArray(uuids) ? uuids : [uuids];
|
||||||
@@ -196,10 +160,10 @@ async function addFilesToAlbum(uuids, albumUuid) {
|
|||||||
...baseHeaders(),
|
...baseHeaders(),
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
data: {
|
||||||
files: ids,
|
files: ids,
|
||||||
albumUuid,
|
albumUuid,
|
||||||
}),
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ function flattenJsonb(jsonbSchema = {}, prefix = "", exclude = []) {
|
|||||||
* @param {string[]} exclude - field names to exclude (e.g. ["password", "otp_code"])
|
* @param {string[]} exclude - field names to exclude (e.g. ["password", "otp_code"])
|
||||||
* @returns {Array} attributes array
|
* @returns {Array} attributes array
|
||||||
*/
|
*/
|
||||||
function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLabels = {} } = {}) {
|
function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLabels = {}, context = "" } = {}) {
|
||||||
const rawAttrs = model.rawAttributes || model.tableAttributes;
|
const rawAttrs = model.rawAttributes || model.tableAttributes;
|
||||||
const attributes = [];
|
const attributes = [];
|
||||||
|
|
||||||
@@ -113,14 +113,14 @@ function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLa
|
|||||||
|
|
||||||
// Ordered audit sequence
|
// Ordered audit sequence
|
||||||
const auditSequence = [
|
const auditSequence = [
|
||||||
{ field: 'updatedAt', type: 'date' },
|
{ field: "updatedAt", type: "date", hiddenOnList: false, hiddenOnArchived: true },
|
||||||
{ field: 'modifiedAt', type: 'date' },
|
{ field: "modifiedAt", type: "date", hiddenOnList: false, hiddenOnArchived: true },
|
||||||
{ field: 'updatedBy', type: 'text' },
|
{ field: "updatedBy", type: "text", hiddenOnList: false, hiddenOnArchived: true },
|
||||||
{ field: 'createdAt', type: 'date' },
|
{ field: "createdAt", type: "date", hiddenOnList: false, hiddenOnArchived: true },
|
||||||
{ field: 'createdBy', type: 'text' },
|
{ field: "createdBy", type: "text", hiddenOnList: false, hiddenOnArchived: true },
|
||||||
{ field: 'deletedAt', type: 'date' },
|
{ field: "deletedAt", type: "date", hiddenOnList: true, hiddenOnArchived: false },
|
||||||
{ field: 'deletedBy', type: 'text' },
|
{ field: "deletedBy", type: "text", hiddenOnList: true, hiddenOnArchived: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Normal fields (excluding audit) ─────────────────────────────────────────
|
// ── Normal fields (excluding audit) ─────────────────────────────────────────
|
||||||
for (const [field, def] of Object.entries(rawAttrs)) {
|
for (const [field, def] of Object.entries(rawAttrs)) {
|
||||||
@@ -148,15 +148,18 @@ function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLa
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Audit fields in correct sequence ────────────────────────────────────────
|
// ── 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 (exclude.includes(field)) continue;
|
||||||
if (!rawAttrs[field]) continue; // skip if field doesn't exist on model
|
if (!rawAttrs[field]) continue; // skip if field doesn't exist on model
|
||||||
|
|
||||||
|
const isArchived = context === "archived";
|
||||||
|
|
||||||
attributes.push({
|
attributes.push({
|
||||||
name: defaultTimestampLabels[field],
|
name: defaultTimestampLabels[field],
|
||||||
type,
|
type,
|
||||||
field,
|
field,
|
||||||
order,
|
order,
|
||||||
|
hidden: isArchived ? hiddenOnArchived : hiddenOnList,
|
||||||
options: resolveOptions(rawAttrs[field]?.type),
|
options: resolveOptions(rawAttrs[field]?.type),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ async function paginate(model, req, {
|
|||||||
jsonbColumn = null,
|
jsonbColumn = null,
|
||||||
findOptions = {},
|
findOptions = {},
|
||||||
auditOptions = null, // ← { mdl_Users, parentAlias },
|
auditOptions = null, // ← { mdl_Users, parentAlias },
|
||||||
computedAttributes = []
|
computedAttributes = [],
|
||||||
|
context = "list",
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const page = Math.max(PAGE_START, parseInt(req.query.page, 10) || PAGE_START);
|
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);
|
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 jsonbExclude = excludeAttributes.filter((f) => f.includes('.'));
|
||||||
const jsonbAttr = jsonbColumn ? excludeJsonbPaths(jsonbColumn, jsonbExclude) : null;
|
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 ALLOWED_FIELDS = attributes.map((a) => a.field);
|
||||||
|
|
||||||
const { where, order } = buildQuery(filters, sort, ALLOWED_FIELDS);
|
const { where, order } = buildQuery(filters, sort, ALLOWED_FIELDS);
|
||||||
|
|||||||
Reference in New Issue
Block a user