This commit is contained in:
2026-07-20 08:17:30 +08:00
parent 1290219077
commit 72a792d585
5 changed files with 35 additions and 19 deletions
+4 -9
View File
@@ -337,11 +337,11 @@ exports.getAsset = async (req, res) => {
// └─────────────────────────────────────────────────────────────────────────┘ // └─────────────────────────────────────────────────────────────────────────┘
// //
// Shared by the single-file POST / and the multi-file POST /batch routes. // Shared by the single-file POST / and the multi-file POST /batch routes.
// `requireVideoThumbnail` is false for batch uploads — a bulk drop has no // Thumbnails are optional for both video and audio — a video/audio asset can
// per-file thumbnail step, so videos land with thumbnail_url null and pick // land with thumbnail_url null and pick one up later via the existing
// one up later via the existing "thumbnail-only" path in updateAsset(). // "thumbnail-only" path in updateAsset().
// //
async function createAssetFromUpload({ file, thumbFile, body, user, requireVideoThumbnail = true, uploadId }) { async function createAssetFromUpload({ file, thumbFile, body, user, uploadId }) {
const uploadedFiles = []; // [{ key, provider }] const uploadedFiles = []; // [{ key, provider }]
try { try {
@@ -367,10 +367,6 @@ async function createAssetFromUpload({ file, thumbFile, body, user, requireVideo
const checksum = file.buffer ? resolveChecksum(file.buffer) : null; const checksum = file.buffer ? resolveChecksum(file.buffer) : null;
const usesProvider = ["chibisafe", "s3"].includes(storage_provider); const usesProvider = ["chibisafe", "s3"].includes(storage_provider);
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) { if (usesProvider && !file.buffer) {
throw Object.assign(new Error("File buffer is required. Ensure multer uses memoryStorage."), { status: 400 }); throw Object.assign(new Error("File buffer is required. Ensure multer uses memoryStorage."), { status: 400 });
} }
@@ -575,7 +571,6 @@ exports.uploadAssetsBatch = async (req, res) => {
createdBy, createdBy,
}, },
user: req.user, user: req.user,
requireVideoThumbnail: false,
}); });
results.push({ originalname: file.originalname, success: true, data: asset }); results.push({ originalname: file.originalname, success: true, data: asset });
} catch (err) { } catch (err) {
+16
View File
@@ -55,6 +55,21 @@ async function recomputeParentDurations(lessonId) {
} }
const LESSON_LIST_COMPUTED = [ const LESSON_LIST_COMPUTED = [
{
// Not its own column — consumed by the Title cell on the frontend to
// prefix "(UNIT)" when a lesson is already attached to at least one Unit.
key: "unit_count",
label: "Unit Count",
type: "number",
hidden: true,
filterable: false,
literal: `(
SELECT CAST(COUNT(*) AS INTEGER)
FROM unit_lessons ul
JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL
WHERE ul.lesson_id = "Lesson"."lesson_id"
)`,
},
{ {
key: "course_count", key: "course_count",
label: "Affiliated", label: "Affiliated",
@@ -74,6 +89,7 @@ const LESSON_LIST_COMPUTED = [
label: "Course Status", label: "Course Status",
type: "text", type: "text",
order: 3, // 1: Title, 2: Affiliated, 3: Course Status — see lessons.mdl.js order: 3, // 1: Title, 2: Affiliated, 3: Course Status — see lessons.mdl.js
hidden: true,
filterable: false, filterable: false,
literal: `( literal: `(
CASE CASE
+4
View File
@@ -72,10 +72,13 @@ const UNIT_LIST_COMPUTED = [
)`, )`,
}, },
{ {
// Not its own column — consumed by the Title cell on the frontend to
// show a "Course" badge when a unit is already attached to at least one Course.
key: "course_count", key: "course_count",
label: "Affiliated", label: "Affiliated",
type: "number", type: "number",
order: 2, // 1: Title, 2: Affiliated, 3: Subscription, 4: Duration — see units.mdl.js order: 2, // 1: Title, 2: Affiliated, 3: Subscription, 4: Duration — see units.mdl.js
hidden: true,
literal: `( literal: `(
SELECT CAST(COUNT(*) AS INTEGER) SELECT CAST(COUNT(*) AS INTEGER)
FROM course_units cu FROM course_units cu
@@ -88,6 +91,7 @@ const UNIT_LIST_COMPUTED = [
label: "Course Status", label: "Course Status",
type: "text", type: "text",
order: 3, // 1: Title, 2: Affiliated, 3: Course Status, 4: Subscription, 5: Duration — see units.mdl.js order: 3, // 1: Title, 2: Affiliated, 3: Course Status, 4: Subscription, 5: Duration — see units.mdl.js
hidden: true,
filterable: false, filterable: false,
literal: `( literal: `(
CASE CASE
+8 -8
View File
@@ -4,18 +4,18 @@ const sequelize = require("../../config/db.config");
// Standalone entity — no unit_id / order_index here. A Lesson is attached to // Standalone entity — no unit_id / order_index here. A Lesson is attached to
// zero or more Units through unit_lessons, where per-unit ordering lives. // zero or more Units through unit_lessons, where per-unit ordering lives.
const Lesson = sequelize.define("Lesson", { const Lesson = sequelize.define("Lesson", {
lesson_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, hidden: true, order: 0 }, lesson_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, hidden: true, order: 0, filterable: false },
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, hidden: true, order: 0 }, uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, hidden: true, order: 0, filterable: false },
title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", hidden: false, order: 1 }, title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", hidden: false, order: 1, filterable: true },
// order 2 is reserved for the computed "Affiliated" (course_count) column, order 3 for computed "Course Status" — see LESSON_LIST_COMPUTED in lessons.controller.js // order 2 is reserved for the computed "Affiliated" (course_count) column, order 3 for computed "Course Status" — see LESSON_LIST_COMPUTED in lessons.controller.js
description: { type: DataTypes.TEXT, allowNull: true, label: "Description", hidden: true, order: 0 }, description: { type: DataTypes.TEXT, allowNull: true, label: "Description", hidden: true, order: 0, filterable: false },
duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0 }, // computed from blocks on save duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0, filterable: false }, // computed from blocks on save
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" }, createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By", filterable: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Updated By" }, updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Updated By", filterable: true },
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" }, deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By", filterable: true },
}, { }, {
tableName: "lessons", tableName: "lessons",
timestamps: true, timestamps: true,
+3 -2
View File
@@ -174,13 +174,14 @@ async function paginate(model, req, {
const totalPages = Math.ceil(count / limit); const totalPages = Math.ceil(count / limit);
// ← Append computed metadata // ← Append computed metadata
const computedMeta = computedAttributes.map(({ key, label, type, order: ord, filterable }) => ({ const computedMeta = computedAttributes.map(({ key, label, type, order: ord, filterable, hidden }) => ({
name: label ?? key, name: label ?? key,
type: type ?? 'text', type: type ?? 'text',
field: key, field: key,
order: ord ?? Infinity, order: ord ?? Infinity,
options: {}, options: {},
filterable: filterable filterable: filterable,
hidden: hidden ?? false,
})); }));
// ← Merge, sort, THEN strip order // ← Merge, sort, THEN strip order