From c8dc2628a63add1885381c8f9cafa021b8cbc420 Mon Sep 17 00:00:00 2001 From: Kenneth Obsequio Date: Mon, 13 Jul 2026 21:27:37 +0800 Subject: [PATCH] try to deploy Signed-off-by: Kenneth Obsequio --- .../admin/advertisements.controller.js | 12 ++++- controllers/admin/assets.controller.js | 42 ++++++---------- controllers/client/courses.controller.js | 6 +-- ...13000001-restore-requires-review-column.js | 23 +++++++++ middleware/asset_upload.middleware.js | 50 +++++++++++++++++++ routes/admin/assets.routes.js | 9 ++-- services/ffprobe.service.js | 4 +- utils/duration.util.js | 15 +++--- utils/paginate.util.js | 5 +- 9 files changed, 120 insertions(+), 46 deletions(-) create mode 100644 database/migrations/20260713000001-restore-requires-review-column.js create mode 100644 middleware/asset_upload.middleware.js diff --git a/controllers/admin/advertisements.controller.js b/controllers/admin/advertisements.controller.js index 7ccc4f5..6a17be4 100644 --- a/controllers/admin/advertisements.controller.js +++ b/controllers/admin/advertisements.controller.js @@ -315,7 +315,11 @@ exports.archiveAdvertisement = async (req, res) => { const advertisement = await Advertisement.findOne({ where: { advertisement_id: advertisementId, ...notDeleted } }); if (!advertisement) return R.error(res, "Advertisement not found.", 404); - await advertisement.update({ deletedBy: req.body.deletedBy ?? null }); + // Freeze the derived status (e.g. "expired") onto the row before it goes + // paranoid — the archived list trusts this stored value as-is and never + // re-derives it, so "Remove Expired" would otherwise miss ads that had + // already lapsed at the moment an admin manually archived them. + await advertisement.update({ deletedBy: req.body.deletedBy ?? null, status: deriveStatus(advertisement) }); await advertisement.destroy(); logActivity(req.user?.user_id, 'archive_advertisement', { entityType: 'advertisement', entityId: Number(advertisementId) }); return R.success(res, "Advertisement archived."); @@ -337,7 +341,11 @@ exports.archiveAdvertisements = async (req, res) => { const activeIds = advertisements.map((a) => a.advertisement_id); - await Advertisement.update({ deletedBy: deletedBy ?? null }, { where: { advertisement_id: { [Op.in]: activeIds } } }); + // Same status-freeze as the single-archive path — resync each row's + // status right before it goes paranoid so "Remove Expired" can trust it. + await Promise.all(advertisements.map((a) => + a.update({ deletedBy: deletedBy ?? null, status: deriveStatus(a) }) + )); await Advertisement.destroy({ where: { advertisement_id: { [Op.in]: activeIds } } }); logActivity(req.user?.user_id, 'bulk_archive_advertisements', { entityType: 'advertisement', details: { ids: activeIds, count: activeIds.length } }); diff --git a/controllers/admin/assets.controller.js b/controllers/admin/assets.controller.js index 7b4151a..25c5a73 100644 --- a/controllers/admin/assets.controller.js +++ b/controllers/admin/assets.controller.js @@ -267,8 +267,8 @@ exports.getAsset = async (req, res) => { // available below to sign a stream token — stripped before the response. attributes: { exclude: ["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" }, + { model: mdl_Users, as: "creator", attributes: ["user_id", "email", "personal_info"], foreignKey: "createdBy" }, + { model: mdl_Users, as: "updater", attributes: ["user_id", "email", "personal_info"], foreignKey: "updatedBy" }, ], }); @@ -276,16 +276,18 @@ exports.getAsset = async (req, res) => { const json = asset.toJSON(); + // Falls back to email when full_name hasn't been filled in — better than + // surfacing the raw numeric user_id in the admin UI. if (json.creator) { json.creator = { user_id: json.creator.user_id, - full_name: json.creator.personal_info?.name?.full_name ?? null, + full_name: json.creator.personal_info?.name?.full_name || json.creator.email || null, }; } if (json.updater) { json.updater = { user_id: json.updater.user_id, - full_name: json.updater.personal_info?.name?.full_name ?? null, + full_name: json.updater.personal_info?.name?.full_name || json.updater.email || null, }; } @@ -339,7 +341,9 @@ async function createAssetFromUpload({ file, thumbFile, body, user, requireVideo display_name, description, is_public = false, - storage_provider = "chibisafe", + // TEMPORARY: defaulting to "s3" instead of "chibisafe" — zrok tunnel is + // dropping Chibisafe uploads (502s). Revert this default once off zrok/VPS migration. + storage_provider = "s3", storage_bucket, storage_key, createdBy, @@ -406,8 +410,8 @@ async function createAssetFromUpload({ file, thumbFile, body, user, requireVideo let video_codec = null, audio_codec = null; let thumbnail_url = null; - if (file_type === "video") { - const meta = await extractVideoMeta({ buffer: file.buffer, extension: extension || "mp4" }); + if (file_type === "video" || file_type === "audio") { + const meta = await extractVideoMeta({ buffer: file.buffer, extension: extension || (file_type === "video" ? "mp4" : "mp3") }); width = meta.width; height = meta.height; resolution = meta.resolution; @@ -436,26 +440,6 @@ async function createAssetFromUpload({ file, thumbFile, body, user, requireVideo thumbnail_url = thumbFile?.filename ? `/uploads/${thumbFile.filename}` : null; } - } else if (file_type === "audio" && thumbFile) { - if (usesProvider) { - if (!thumbFile.buffer) { - await rollbackUploads(uploadedFiles); - throw Object.assign(new Error("Thumbnail buffer is required."), { status: 400 }); - } - const baseName = file.originalname.replace(/\.[^.]+$/, ""); - const svc = getProvider(storage_provider); - const thumbResult = await svc.uploadFile({ - buffer: thumbFile.buffer, - originalname: `thumb_${baseName}.${resolveExtension(thumbFile.originalname) || "jpg"}`, - mimetype: thumbFile.mimetype, - ownerType: "thumbnail", - }); - thumbnail_url = thumbResult.url; - uploadedFiles.push({ key: thumbResult.uuid, provider: storage_provider }); - } else { - thumbnail_url = thumbFile?.filename ? `/uploads/${thumbFile.filename}` : null; - } - } else { const parsedWidth = body.width ? parseInt(body.width) : null; const parsedHeight = body.height ? parseInt(body.height) : null; @@ -554,7 +538,9 @@ 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; + // TEMPORARY: defaulting to "s3" instead of "chibisafe" — zrok tunnel is + // dropping Chibisafe uploads (502s). Revert this default once off zrok/VPS migration. + const { display_name, description, is_public = false, storage_provider = "s3", createdBy } = req.body; if (!createdBy) return R.error(res, "createdBy is required.", 400); const results = []; diff --git a/controllers/client/courses.controller.js b/controllers/client/courses.controller.js index b0c003d..0e5e2d4 100644 --- a/controllers/client/courses.controller.js +++ b/controllers/client/courses.controller.js @@ -1320,12 +1320,12 @@ exports.getLessonByUuid = async (req, res) => { model: Unit, as: "units", where: notDeleted, required: false, - attributes: ["unit_id", "uuid", "title"], + attributes: ["unit_id", "uuid", "title", "duration_seconds"], through: { attributes: ["order_index"] }, include: [{ model: Course, as: "courses", where: notDeleted, required: false, - attributes: ["course_id", "title", "subscription"], + attributes: ["course_id", "title", "subscription", "duration_seconds"], through: { attributes: [] }, }], }, @@ -1360,7 +1360,7 @@ exports.getLessonByUuid = async (req, res) => { objectives: plain.objectives ?? [], status: progress?.status ?? "not_started", completed_at: progress?.completed_at ?? null, - unit: firstUnit ? { unit_id: firstUnit.unit_id, uuid: firstUnit.uuid, title: firstUnit.title, course: firstUnit.courses?.[0] ?? null } : null, // back-compat singular field + unit: firstUnit ? { unit_id: firstUnit.unit_id, uuid: firstUnit.uuid, title: firstUnit.title, duration_seconds: firstUnit.duration_seconds, course: firstUnit.courses?.[0] ?? null } : null, // back-compat singular field units: plain.units ?? [], }; return R.success(res, "Lesson retrieved.", data); diff --git a/database/migrations/20260713000001-restore-requires-review-column.js b/database/migrations/20260713000001-restore-requires-review-column.js new file mode 100644 index 0000000..2d8556a --- /dev/null +++ b/database/migrations/20260713000001-restore-requires-review-column.js @@ -0,0 +1,23 @@ +'use strict'; + +// task_requirements.requires_review went missing from the live schema even +// though 20260709000001 is recorded as applied in SequelizeMeta (it was +// dropped out-of-band, outside the migration system). This restores it +// idempotently without re-touching `prompt`, which is still present. + +module.exports = { + async up(queryInterface, Sequelize) { + const table = await queryInterface.describeTable('task_requirements'); + if (!table.requires_review) { + await queryInterface.addColumn('task_requirements', 'requires_review', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }); + } + }, + + async down(queryInterface) { + await queryInterface.removeColumn('task_requirements', 'requires_review'); + }, +}; diff --git a/middleware/asset_upload.middleware.js b/middleware/asset_upload.middleware.js new file mode 100644 index 0000000..b90d95f --- /dev/null +++ b/middleware/asset_upload.middleware.js @@ -0,0 +1,50 @@ +/*********************************************************************************************************************************************************************** + * File Name: asset_upload.middleware.js + * Type of Program: Middleware + * Description: Multer config for admin asset uploads (image/video/audio/document). + * Uses memory storage — buffer is passed directly to S3. + * + * Previously this route had no `limits.fileSize` at all, so an + * oversized upload wasn't rejected cleanly — it either exhausted + * server memory buffering the whole file or blew up with a raw + * multer/Node error that never reached R.error's JSON envelope. + * The frontend's fallback then had nothing to read a `message` + * out of, so every failure surfaced as the same generic + * "Something went wrong" regardless of cause. + * + * Limits: + * fileSize: 500 MB (matches task_upload.middleware.js) + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 13, 2026 + ***********************************************************************************************************************************************************************/ +const multer = require('multer'); +const R = require('../utils/response.util'); + +const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: MAX_FILE_SIZE }, +}); + +// Wraps any multer middleware (fields/array/single) so oversized or malformed +// uploads always come back as a clean R.error JSON response the frontend can +// read a `message` out of, instead of an unhandled exception or Express's +// default HTML error page. +function handleUpload(multerMiddleware) { + return (req, res, next) => { + multerMiddleware(req, res, (err) => { + if (!err) return next(); + + if (err.code === 'LIMIT_FILE_SIZE') { + return R.error(res, `File exceeds the ${MAX_FILE_SIZE / (1024 * 1024)} MB size limit.`, 400); + } + + console.error('[MULTER ERROR][ASSETS]', err); + return R.error(res, err.message ?? 'File upload failed.', 400); + }); + }; +} + +module.exports = { upload, handleUpload, MAX_FILE_SIZE }; diff --git a/routes/admin/assets.routes.js b/routes/admin/assets.routes.js index 9e56fe8..9dd0ae4 100644 --- a/routes/admin/assets.routes.js +++ b/routes/admin/assets.routes.js @@ -1,8 +1,7 @@ // routes/admin/assets.routes.js const router = require('express').Router(); -const multer = require('multer'); -const upload = multer({ storage: multer.memoryStorage() }); +const { upload, handleUpload } = require('../../middleware/asset_upload.middleware'); const controller = require('../../controllers/admin/assets.controller'); const { sensitiveOpsLimiter } = require('../../middleware/rateLimiter.middleware'); @@ -16,12 +15,12 @@ router.get('/upload-progress/:uploadId', controller.streamUploadProgress); // ─── 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); +router.post('/', handleUpload(upload.fields([{ name: 'file', maxCount: 1 }, { name: 'thumbnail', maxCount: 1 }])), controller.uploadAsset); +router.post('/batch', handleUpload(upload.array('files', 20)), controller.uploadAssetsBatch); // ─── Dynamic routes last ────────────────────────────────────────────────────── router.get('/:assetId', controller.getAsset); -router.patch('/:assetId', sensitiveOpsLimiter, upload.fields([{ name: 'file', maxCount: 1 }]), controller.updateAsset); +router.patch('/:assetId', sensitiveOpsLimiter, handleUpload(upload.fields([{ name: 'file', maxCount: 1 }])), controller.updateAsset); router.patch('/:assetId/restore', sensitiveOpsLimiter, controller.restoreAsset); router.delete('/:assetId', sensitiveOpsLimiter, controller.archiveAsset); router.delete('/:assetId/permanent', sensitiveOpsLimiter, controller.permanentlyDeleteAsset); diff --git a/services/ffprobe.service.js b/services/ffprobe.service.js index 86ddc6d..d4dafe9 100644 --- a/services/ffprobe.service.js +++ b/services/ffprobe.service.js @@ -1,6 +1,8 @@ // services/ffprobe.service.js // -// Extracts video metadata only (dimensions, duration, codecs, bitrate, frame rate). +// Extracts media metadata (dimensions, duration, codecs, bitrate, frame rate). +// Also used for audio-only files — video-specific fields (width/height/ +// frame_rate/video_codec) simply resolve to null when there's no video stream. // Thumbnail is provided by the client as a separate uploaded file — not generated here. // // Dependencies: diff --git a/utils/duration.util.js b/utils/duration.util.js index 9764975..2c284b5 100644 --- a/utils/duration.util.js +++ b/utils/duration.util.js @@ -9,10 +9,12 @@ function stripHtml(html) { /** * Estimate seconds for a single lesson block - * Block shape: { type: 'text'|'image'|'video', content, word_count, video_duration_seconds } + * Block shape: { id, type, content }, where content.duration_seconds holds + * the probed media length for video/audio blocks (see AssetPickerSheet + * consumers — VideoBlock/AudioBlock/TextVideoBlock save it on select). */ function estimateBlockDuration(block) { - const videoDuration = Number(block.video_duration_seconds) || 0; + const mediaDuration = Math.round(Number(block.content?.duration_seconds) || 0); // Derive word count from HTML content on the fly const getWordCount = (html) => { @@ -32,7 +34,8 @@ function estimateBlockDuration(block) { return 60; case "video": - return videoDuration; + case "audio": + return mediaDuration; case "text-image": case "text_image": @@ -40,7 +43,7 @@ function estimateBlockDuration(block) { case "text-video": case "text_video": - return readingSecs(block.content?.body) + videoDuration; + return readingSecs(block.content?.body) + mediaDuration; default: return 0; @@ -104,8 +107,8 @@ async function recomputeDurations(lessonId) { (sum, block) => sum + estimateBlockDuration(block), 0 ); - // Guard against NaN before DB write - const safeLessonSecs = isNaN(lessonSecs) ? 0 : lessonSecs; + // Guard against NaN/fractional values before DB write (duration_seconds is INTEGER) + const safeLessonSecs = isNaN(lessonSecs) ? 0 : Math.round(lessonSecs); await Lesson.update({ duration_seconds: safeLessonSecs }, { where: { lesson_id: lessonId } }); // 2. Every unit that contains this lesson diff --git a/utils/paginate.util.js b/utils/paginate.util.js index 56b4332..45fb1f3 100644 --- a/utils/paginate.util.js +++ b/utils/paginate.util.js @@ -27,9 +27,12 @@ function safeParseJSON(value, fallback = []) { function auditInclude(mdl_Users, parentAlias = 'User') { const tableName = mdl_Users.getTableName(); + // Falls back to email when full_name hasn't been filled in (e.g. a + // freshly self-registered account) — better than surfacing the raw + // numeric user_id, which is meaningless in the admin UI. const fullNameSubquery = (foreignKey) => Sequelize.literal(`( - SELECT (u."personal_info"->>'name')::jsonb->>'full_name' + SELECT COALESCE(NULLIF((u."personal_info"->>'name')::jsonb->>'full_name', ''), u."email") FROM "${tableName}" AS u WHERE u."user_id" = "${parentAlias}"."${foreignKey}" LIMIT 1