mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -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 } });
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user