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 } });
|
const advertisement = await Advertisement.findOne({ where: { advertisement_id: advertisementId, ...notDeleted } });
|
||||||
if (!advertisement) return R.error(res, "Advertisement not found.", 404);
|
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();
|
await advertisement.destroy();
|
||||||
logActivity(req.user?.user_id, 'archive_advertisement', { entityType: 'advertisement', entityId: Number(advertisementId) });
|
logActivity(req.user?.user_id, 'archive_advertisement', { entityType: 'advertisement', entityId: Number(advertisementId) });
|
||||||
return R.success(res, "Advertisement archived.");
|
return R.success(res, "Advertisement archived.");
|
||||||
@@ -337,7 +341,11 @@ exports.archiveAdvertisements = async (req, res) => {
|
|||||||
|
|
||||||
const activeIds = advertisements.map((a) => a.advertisement_id);
|
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 } } });
|
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 } });
|
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.
|
// available below to sign a stream token — stripped before the response.
|
||||||
attributes: { exclude: ["storage_bucket"] },
|
attributes: { exclude: ["storage_bucket"] },
|
||||||
include: [
|
include: [
|
||||||
{ model: mdl_Users, as: "creator", attributes: ["user_id", "personal_info"], foreignKey: "createdBy" },
|
{ model: mdl_Users, as: "creator", attributes: ["user_id", "email", "personal_info"], foreignKey: "createdBy" },
|
||||||
{ model: mdl_Users, as: "updater", attributes: ["user_id", "personal_info"], foreignKey: "updatedBy" },
|
{ 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();
|
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) {
|
if (json.creator) {
|
||||||
json.creator = {
|
json.creator = {
|
||||||
user_id: json.creator.user_id,
|
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) {
|
if (json.updater) {
|
||||||
json.updater = {
|
json.updater = {
|
||||||
user_id: json.updater.user_id,
|
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,
|
display_name,
|
||||||
description,
|
description,
|
||||||
is_public = false,
|
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_bucket,
|
||||||
storage_key,
|
storage_key,
|
||||||
createdBy,
|
createdBy,
|
||||||
@@ -406,8 +410,8 @@ async function createAssetFromUpload({ file, thumbFile, body, user, requireVideo
|
|||||||
let video_codec = null, audio_codec = null;
|
let video_codec = null, audio_codec = null;
|
||||||
let thumbnail_url = null;
|
let thumbnail_url = null;
|
||||||
|
|
||||||
if (file_type === "video") {
|
if (file_type === "video" || file_type === "audio") {
|
||||||
const meta = await extractVideoMeta({ buffer: file.buffer, extension: extension || "mp4" });
|
const meta = await extractVideoMeta({ buffer: file.buffer, extension: extension || (file_type === "video" ? "mp4" : "mp3") });
|
||||||
width = meta.width;
|
width = meta.width;
|
||||||
height = meta.height;
|
height = meta.height;
|
||||||
resolution = meta.resolution;
|
resolution = meta.resolution;
|
||||||
@@ -436,26 +440,6 @@ async function createAssetFromUpload({ file, thumbFile, body, user, requireVideo
|
|||||||
thumbnail_url = thumbFile?.filename ? `/uploads/${thumbFile.filename}` : null;
|
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 {
|
} else {
|
||||||
const parsedWidth = body.width ? parseInt(body.width) : null;
|
const parsedWidth = body.width ? parseInt(body.width) : null;
|
||||||
const parsedHeight = body.height ? parseInt(body.height) : null;
|
const parsedHeight = body.height ? parseInt(body.height) : null;
|
||||||
@@ -554,7 +538,9 @@ exports.uploadAssetsBatch = async (req, res) => {
|
|||||||
const files = req.files ?? [];
|
const files = req.files ?? [];
|
||||||
if (!files.length) return R.error(res, "No files uploaded.", 400);
|
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);
|
if (!createdBy) return R.error(res, "createdBy is required.", 400);
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|||||||
@@ -1320,12 +1320,12 @@ exports.getLessonByUuid = async (req, res) => {
|
|||||||
model: Unit,
|
model: Unit,
|
||||||
as: "units",
|
as: "units",
|
||||||
where: notDeleted, required: false,
|
where: notDeleted, required: false,
|
||||||
attributes: ["unit_id", "uuid", "title"],
|
attributes: ["unit_id", "uuid", "title", "duration_seconds"],
|
||||||
through: { attributes: ["order_index"] },
|
through: { attributes: ["order_index"] },
|
||||||
include: [{
|
include: [{
|
||||||
model: Course, as: "courses",
|
model: Course, as: "courses",
|
||||||
where: notDeleted, required: false,
|
where: notDeleted, required: false,
|
||||||
attributes: ["course_id", "title", "subscription"],
|
attributes: ["course_id", "title", "subscription", "duration_seconds"],
|
||||||
through: { attributes: [] },
|
through: { attributes: [] },
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
@@ -1360,7 +1360,7 @@ exports.getLessonByUuid = async (req, res) => {
|
|||||||
objectives: plain.objectives ?? [],
|
objectives: plain.objectives ?? [],
|
||||||
status: progress?.status ?? "not_started",
|
status: progress?.status ?? "not_started",
|
||||||
completed_at: progress?.completed_at ?? null,
|
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 ?? [],
|
units: plain.units ?? [],
|
||||||
};
|
};
|
||||||
return R.success(res, "Lesson retrieved.", data);
|
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
|
// routes/admin/assets.routes.js
|
||||||
|
|
||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const multer = require('multer');
|
const { upload, handleUpload } = require('../../middleware/asset_upload.middleware');
|
||||||
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');
|
||||||
|
|
||||||
@@ -16,12 +15,12 @@ router.get('/upload-progress/:uploadId', controller.streamUploadProgress);
|
|||||||
|
|
||||||
// ─── Collection ───────────────────────────────────────────────────────────────
|
// ─── Collection ───────────────────────────────────────────────────────────────
|
||||||
router.get('/', controller.getAssets);
|
router.get('/', controller.getAssets);
|
||||||
router.post('/', upload.fields([{ name: 'file', maxCount: 1 }, { name: 'thumbnail', maxCount: 1 }]), controller.uploadAsset);
|
router.post('/', handleUpload(upload.fields([{ name: 'file', maxCount: 1 }, { name: 'thumbnail', maxCount: 1 }])), controller.uploadAsset);
|
||||||
router.post('/batch', upload.array('files', 20), controller.uploadAssetsBatch);
|
router.post('/batch', handleUpload(upload.array('files', 20)), controller.uploadAssetsBatch);
|
||||||
|
|
||||||
// ─── Dynamic routes last ──────────────────────────────────────────────────────
|
// ─── Dynamic routes last ──────────────────────────────────────────────────────
|
||||||
router.get('/:assetId', controller.getAsset);
|
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.patch('/:assetId/restore', sensitiveOpsLimiter, controller.restoreAsset);
|
||||||
router.delete('/:assetId', sensitiveOpsLimiter, controller.archiveAsset);
|
router.delete('/:assetId', sensitiveOpsLimiter, controller.archiveAsset);
|
||||||
router.delete('/:assetId/permanent', sensitiveOpsLimiter, controller.permanentlyDeleteAsset);
|
router.delete('/:assetId/permanent', sensitiveOpsLimiter, controller.permanentlyDeleteAsset);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// services/ffprobe.service.js
|
// 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.
|
// Thumbnail is provided by the client as a separate uploaded file — not generated here.
|
||||||
//
|
//
|
||||||
// Dependencies:
|
// Dependencies:
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ function stripHtml(html) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Estimate seconds for a single lesson block
|
* 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) {
|
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
|
// Derive word count from HTML content on the fly
|
||||||
const getWordCount = (html) => {
|
const getWordCount = (html) => {
|
||||||
@@ -32,7 +34,8 @@ function estimateBlockDuration(block) {
|
|||||||
return 60;
|
return 60;
|
||||||
|
|
||||||
case "video":
|
case "video":
|
||||||
return videoDuration;
|
case "audio":
|
||||||
|
return mediaDuration;
|
||||||
|
|
||||||
case "text-image":
|
case "text-image":
|
||||||
case "text_image":
|
case "text_image":
|
||||||
@@ -40,7 +43,7 @@ function estimateBlockDuration(block) {
|
|||||||
|
|
||||||
case "text-video":
|
case "text-video":
|
||||||
case "text_video":
|
case "text_video":
|
||||||
return readingSecs(block.content?.body) + videoDuration;
|
return readingSecs(block.content?.body) + mediaDuration;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return 0;
|
return 0;
|
||||||
@@ -104,8 +107,8 @@ async function recomputeDurations(lessonId) {
|
|||||||
(sum, block) => sum + estimateBlockDuration(block), 0
|
(sum, block) => sum + estimateBlockDuration(block), 0
|
||||||
);
|
);
|
||||||
|
|
||||||
// Guard against NaN before DB write
|
// Guard against NaN/fractional values before DB write (duration_seconds is INTEGER)
|
||||||
const safeLessonSecs = isNaN(lessonSecs) ? 0 : lessonSecs;
|
const safeLessonSecs = isNaN(lessonSecs) ? 0 : Math.round(lessonSecs);
|
||||||
await Lesson.update({ duration_seconds: safeLessonSecs }, { where: { lesson_id: lessonId } });
|
await Lesson.update({ duration_seconds: safeLessonSecs }, { where: { lesson_id: lessonId } });
|
||||||
|
|
||||||
// 2. Every unit that contains this lesson
|
// 2. Every unit that contains this lesson
|
||||||
|
|||||||
@@ -27,9 +27,12 @@ function safeParseJSON(value, fallback = []) {
|
|||||||
function auditInclude(mdl_Users, parentAlias = 'User') {
|
function auditInclude(mdl_Users, parentAlias = 'User') {
|
||||||
const tableName = mdl_Users.getTableName();
|
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) =>
|
const fullNameSubquery = (foreignKey) =>
|
||||||
Sequelize.literal(`(
|
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
|
FROM "${tableName}" AS u
|
||||||
WHERE u."user_id" = "${parentAlias}"."${foreignKey}"
|
WHERE u."user_id" = "${parentAlias}"."${foreignKey}"
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
|
|||||||
Reference in New Issue
Block a user