diff --git a/controllers/admin/assets.controller.js b/controllers/admin/assets.controller.js index 6782813..6396085 100644 --- a/controllers/admin/assets.controller.js +++ b/controllers/admin/assets.controller.js @@ -304,7 +304,7 @@ exports.getAsset = async (req, res) => { } }; -// ─── UPLOAD ─────────────────────────────────────────────────────────────────── +// ─── UPLOAD (shared core) ────────────────────────────────────────────────────── // // ┌─────────────────────────────────────────────────────────────────────────┐ // │ TRANSACTION STRATEGY │ @@ -318,21 +318,21 @@ exports.getAsset = async (req, res) => { // │ Phase 2 — FAST WORK (transaction open milliseconds only): │ // │ • BEGIN → Asset.create() → COMMIT │ // │ │ -// │ On Phase 2 error: │ -// │ • ROLLBACK transaction │ +// │ On any error: │ +// │ • ROLLBACK transaction (if opened) │ // │ • rollbackUploads([{ key, provider }]) to clean orphans │ // └─────────────────────────────────────────────────────────────────────────┘ - -exports.uploadAsset = async (req, res) => { +// +// Shared by the single-file POST / and the multi-file POST /batch routes. +// `requireVideoThumbnail` is false for batch uploads — a bulk drop has no +// per-file thumbnail step, so videos land with thumbnail_url null and pick +// one up later via the existing "thumbnail-only" path in updateAsset(). +// +async function createAssetFromUpload({ file, thumbFile, body, user, requireVideoThumbnail = true }) { const uploadedFiles = []; // [{ key, provider }] try { - // ── Phase 1a: Validate ──────────────────────────────────────────────────── - - const file = req.files?.file?.[0]; - const thumbFile = req.files?.thumbnail?.[0]; - - if (!file) return R.error(res, "No file uploaded.", 400); + if (!file) throw Object.assign(new Error("No file uploaded."), { status: 400 }); const { display_name, @@ -342,22 +342,22 @@ exports.uploadAsset = async (req, res) => { storage_bucket, storage_key, createdBy, - } = req.body; + } = body; - if (!createdBy) return R.error(res, "createdBy is required.", 400); + if (!createdBy) throw Object.assign(new Error("createdBy is required."), { status: 400 }); - const mime_type = file.mimetype; - const file_type = resolveFileType(mime_type); - const extension = resolveExtension(file.originalname); - const checksum = file.buffer ? resolveChecksum(file.buffer) : null; - const usesProvider = ["chibisafe", "s3"].includes(storage_provider); + const mime_type = file.mimetype; + const file_type = resolveFileType(mime_type); + const extension = resolveExtension(file.originalname); + const checksum = file.buffer ? resolveChecksum(file.buffer) : null; + const usesProvider = ["chibisafe", "s3"].includes(storage_provider); - if (file_type === "video" && !thumbFile) { - return R.error(res, "A thumbnail image is required for video uploads.", 400); + if (file_type === "video" && requireVideoThumbnail && !thumbFile) { + throw Object.assign(new Error("A thumbnail image is required for video uploads."), { status: 400 }); } if (usesProvider && !file.buffer) { - return R.error(res, "File buffer is required. Ensure multer uses memoryStorage.", 400); + throw Object.assign(new Error("File buffer is required. Ensure multer uses memoryStorage."), { status: 400 }); } // ── Phase 1b: Upload main file ──────────────────────────────────────────── @@ -380,9 +380,9 @@ exports.uploadAsset = async (req, res) => { } else { file_url = storage_provider === "local" ? `/uploads/${file.filename}` - : req.body.file_url; + : body.file_url; - if (!file_url) return R.error(res, "file_url is required for non-local storage.", 400); + if (!file_url) throw Object.assign(new Error("file_url is required for non-local storage."), { status: 400 }); } // ── Phase 1c: ffprobe + thumbnail ───────────────────────────────────────── @@ -403,10 +403,10 @@ exports.uploadAsset = async (req, res) => { video_codec = meta.video_codec; audio_codec = meta.audio_codec; - if (usesProvider) { - if (!thumbFile?.buffer) { + if (usesProvider && thumbFile) { + if (!thumbFile.buffer) { await rollbackUploads(uploadedFiles); - return R.error(res, "Thumbnail buffer is required.", 400); + throw Object.assign(new Error("Thumbnail buffer is required."), { status: 400 }); } const baseName = file.originalname.replace(/\.[^.]+$/, ""); const svc = getProvider(storage_provider); @@ -418,7 +418,7 @@ exports.uploadAsset = async (req, res) => { }); thumbnail_url = thumbResult.url; uploadedFiles.push({ key: thumbResult.uuid, provider: storage_provider }); - } else { + } else if (!usesProvider) { thumbnail_url = thumbFile?.filename ? `/uploads/${thumbFile.filename}` : null; } @@ -426,7 +426,7 @@ exports.uploadAsset = async (req, res) => { if (usesProvider) { if (!thumbFile.buffer) { await rollbackUploads(uploadedFiles); - return R.error(res, "Thumbnail buffer is required.", 400); + throw Object.assign(new Error("Thumbnail buffer is required."), { status: 400 }); } const baseName = file.originalname.replace(/\.[^.]+$/, ""); const svc = getProvider(storage_provider); @@ -443,8 +443,8 @@ exports.uploadAsset = async (req, res) => { } } else { - const parsedWidth = req.body.width ? parseInt(req.body.width) : null; - const parsedHeight = req.body.height ? parseInt(req.body.height) : null; + const parsedWidth = body.width ? parseInt(body.width) : null; + const parsedHeight = body.height ? parseInt(body.height) : null; width = parsedWidth; height = parsedHeight; resolution = resolveResolution(parsedWidth, parsedHeight); @@ -481,9 +481,8 @@ exports.uploadAsset = async (req, res) => { }, { transaction: t }); await t.commit(); - invalidateListCache(); - logActivity(req.user?.user_id, 'upload_asset', { entityType: 'asset', entityId: asset.asset_id, details: { display_name: asset.display_name, file_type: asset.file_type } }); - return R.success(res, "Asset uploaded.", { data: asset }, 201); + logActivity(user?.user_id, 'upload_asset', { entityType: 'asset', entityId: asset.asset_id, details: { display_name: asset.display_name, file_type: asset.file_type } }); + return asset; } catch (dbErr) { try { await t.rollback(); } catch { /* connection gone */ } @@ -491,6 +490,21 @@ exports.uploadAsset = async (req, res) => { throw dbErr; } + } catch (err) { + await rollbackUploads(uploadedFiles); + throw err; + } +} + +exports.uploadAsset = async (req, res) => { + try { + const file = req.files?.file?.[0]; + const thumbFile = req.files?.thumbnail?.[0]; + + const asset = await createAssetFromUpload({ file, thumbFile, body: req.body, user: req.user }); + invalidateListCache(); + return R.success(res, "Asset uploaded.", { data: asset }, 201); + } catch (err) { console.error("[ASSET][UPLOAD]", err); if (err.status) return R.error(res, err.message, err.status, { detail: err.chibiBody }); @@ -498,6 +512,53 @@ exports.uploadAsset = async (req, res) => { } }; +// ─── UPLOAD (batch) ───────────────────────────────────────────────────────── +// +// Accepts multiple files under the "files" field in one multipart request, +// all sharing the same is_public / storage_provider / createdBy. Each file +// is uploaded independently — one failing (bad codec, DB constraint, etc.) +// does not roll back the others. display_name defaults to the filename +// (minus extension) since there's no per-file metadata step in bulk mode. +// Videos land without a thumbnail (see createAssetFromUpload) — add one +// later via the existing "replace thumbnail" path on PATCH /:assetId. +// +exports.uploadAssetsBatch = async (req, res) => { + const files = req.files ?? []; + if (!files.length) return R.error(res, "No files uploaded.", 400); + + const { display_name, description, is_public = false, storage_provider = "chibisafe", createdBy } = req.body; + if (!createdBy) return R.error(res, "createdBy is required.", 400); + + const results = []; + + for (const file of files) { + try { + const baseName = file.originalname.replace(/\.[^.]+$/, ""); + const asset = await createAssetFromUpload({ + file, + thumbFile: null, + body: { + display_name: files.length === 1 ? (display_name || baseName) : baseName, + description, + is_public, + storage_provider, + createdBy, + }, + user: req.user, + requireVideoThumbnail: false, + }); + results.push({ originalname: file.originalname, success: true, data: asset }); + } catch (err) { + console.error("[ASSET][UPLOAD BATCH]", file.originalname, err.stack || err); + results.push({ originalname: file.originalname, success: false, message: err.status ? err.message : "Internal server error." }); + } + } + + invalidateListCache(); + const createdCount = results.filter((r) => r.success).length; + return R.success(res, `${createdCount} of ${files.length} asset(s) uploaded.`, { results }, 201); +}; + // ─── UPDATE ─────────────────────────────────────────────────────────────────── exports.updateAsset = async (req, res) => { diff --git a/controllers/admin/dashboard.controller.js b/controllers/admin/dashboard.controller.js index a245f45..3824b03 100644 --- a/controllers/admin/dashboard.controller.js +++ b/controllers/admin/dashboard.controller.js @@ -77,23 +77,11 @@ exports.getGroupsDashboard = async (req, res) => { totalGroups, activeGroups, archivedGroups, - emptyGroups, memberCountBreakdown, ] = await Promise.all([ mdl_UserGroups.count({ paranoid: false }), mdl_UserGroups.count({ where: { is_active: true } }), mdl_UserGroups.count({ where: { deletedAt: { [Op.ne]: null } }, paranoid: false }), - mdl_UserGroups.count({ - where: { is_active: true }, - include: [{ - model: mdl_UserGroupMembers, - required: false, - where: { deletedAt: null }, - }], - having: literal('COUNT("UserGroupMembers"."user_id") = 0'), - group: ['UserGroup.group_id'], - subQuery: false, - }).catch(() => 0), mdl_UserGroupMembers.findAll({ attributes: [ 'group_id', @@ -121,8 +109,6 @@ exports.getGroupsDashboard = async (req, res) => { { key: 'total', label: 'Total Groups', value: totalGroups }, { key: 'active', label: 'Active Groups', value: activeGroups }, { key: 'inactive', label: 'Inactive Groups', value: totalGroups - archivedGroups - activeGroups }, - { key: 'archived', label: 'Archived', value: archivedGroups }, - { key: 'empty', label: 'Empty Groups', value: Array.isArray(emptyGroups) ? emptyGroups.length : emptyGroups }, ], breakdowns: [ { diff --git a/database/migrations/20260712000001-add-audio-to-assets-file-type-enum.js b/database/migrations/20260712000001-add-audio-to-assets-file-type-enum.js new file mode 100644 index 0000000..68aff02 --- /dev/null +++ b/database/migrations/20260712000001-add-audio-to-assets-file-type-enum.js @@ -0,0 +1,20 @@ +'use strict'; + +// The app has resolved/handled "audio" as a file_type since day one (see +// resolveFileType() in assets.controller.js, ViewAudioAsset.jsx, +// mediaToken.SUPPORTED_TYPES) but the enum itself was never extended past +// the original ('avatar', 'document', 'video', 'image') — every audio +// upload has been silently 500ing at Asset.create() with an invalid-enum +// error. Discovered while adding batch asset uploads, which will hit this +// path directly for any audio file dropped into a batch. + +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(`ALTER TYPE public.enum_assets_file_type ADD VALUE IF NOT EXISTS 'audio'`); + }, + + // Postgres has no ALTER TYPE ... DROP VALUE — reverting an enum value + // addition requires rebuilding the type, which isn't safe to do blindly + // in a down migration if any row already uses 'audio'. + async down() {}, +}; diff --git a/models/assets/assets.mdl.js b/models/assets/assets.mdl.js index 9e5506f..54d25af 100644 --- a/models/assets/assets.mdl.js +++ b/models/assets/assets.mdl.js @@ -20,7 +20,7 @@ const Asset = sequelize.define("Asset", { // ─── Classification ─────────────────────────────────────────────────────── file_type: { - type: DataTypes.ENUM("avatar", "document", "video", "image"), + type: DataTypes.ENUM("avatar", "document", "video", "image", "audio"), allowNull: false, defaultValue: "image", label: "Classification", order: 0 }, diff --git a/routes/admin/assets.routes.js b/routes/admin/assets.routes.js index 2b54e6e..262fecb 100644 --- a/routes/admin/assets.routes.js +++ b/routes/admin/assets.routes.js @@ -16,6 +16,7 @@ router.patch('/bulk-restore', controller.restoreAssets); // ─── Collection ─────────────────────────────────────────────────────────────── router.get('/', controller.getAssets); router.post('/', upload.fields([{ name: 'file', maxCount: 1 }, { name: 'thumbnail', maxCount: 1 }]), controller.uploadAsset); +router.post('/batch', upload.array('files', 20), controller.uploadAssetsBatch); // ─── Dynamic routes last ────────────────────────────────────────────────────── router.get('/:assetId', controller.getAsset);