mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
Adjusted
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Single archive — soft delete one record
|
||||
*/
|
||||
async function archiveOne(Model, where, deletedBy, transaction) {
|
||||
const record = await Model.findOne({ where });
|
||||
if (!record) return null;
|
||||
await record.update({ deletedBy: deletedBy ?? null }, { transaction });
|
||||
await record.destroy({ transaction });
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk archive — soft delete multiple records by primary key
|
||||
*/
|
||||
async function archiveMany(Model, pkField, ids, deletedBy, transaction) {
|
||||
if (!ids?.length) return 0;
|
||||
|
||||
const records = await Model.findAll({ where: { [pkField]: ids, deletedAt: null } });
|
||||
if (!records.length) return 0;
|
||||
|
||||
for (const record of records) {
|
||||
await record.update({ deletedBy: deletedBy ?? null }, { transaction });
|
||||
await record.destroy({ transaction });
|
||||
}
|
||||
|
||||
return records.length;
|
||||
}
|
||||
|
||||
module.exports = { archiveOne, archiveMany };
|
||||
@@ -0,0 +1,16 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Replace all junction rows for a course in one shot (delete + bulk create).
|
||||
*/
|
||||
async function syncJunction(Model, courseId, ids, fkField, transaction) {
|
||||
await Model.destroy({ where: { course_id: courseId }, transaction });
|
||||
if (ids?.length) {
|
||||
await Model.bulkCreate(
|
||||
ids.map((id) => ({ course_id: courseId, [fkField]: id })),
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { syncJunction };
|
||||
@@ -0,0 +1,62 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* For CREATE — plain text array, no IDs needed
|
||||
* objectives: ["text1"] or [{ text: "text1" }]
|
||||
*/
|
||||
async function syncObjectivesCreate(Model, parentField, parentId, objectives, transaction) {
|
||||
if (!objectives?.length) return;
|
||||
|
||||
await Model.bulkCreate(
|
||||
objectives.map((o, i) => ({
|
||||
[parentField]: parentId,
|
||||
text: typeof o === "string" ? o : o.text,
|
||||
order_index: i,
|
||||
})),
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* For UPDATE — upsert by objective_id, hard delete removed ones
|
||||
* objectives: [{ objective_id: "123", text: "text1" }, { text: "new" }]
|
||||
*/
|
||||
async function syncObjectivesUpdate(Model, parentField, parentId, objectives, transaction) {
|
||||
if (!objectives?.length) {
|
||||
await Model.destroy({ where: { [parentField]: parentId }, force: true, transaction });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await Model.findAll({ where: { [parentField]: parentId }, transaction });
|
||||
const existingMap = new Map(existing.map((o) => [String(o.objective_id), o]));
|
||||
const incomingIds = new Set(
|
||||
objectives.filter((o) => o.objective_id).map((o) => String(o.objective_id))
|
||||
);
|
||||
|
||||
// Hard delete removed
|
||||
const toDelete = existing.filter((o) => !incomingIds.has(String(o.objective_id)));
|
||||
if (toDelete.length) {
|
||||
await Model.destroy({
|
||||
where: { objective_id: toDelete.map((o) => o.objective_id) },
|
||||
force: true,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
// Update existing or create new
|
||||
for (let i = 0; i < objectives.length; i++) {
|
||||
const item = objectives[i];
|
||||
const record = item.objective_id ? existingMap.get(String(item.objective_id)) : null;
|
||||
|
||||
if (record) {
|
||||
await record.update({ text: item.text, order_index: i }, { transaction });
|
||||
} else {
|
||||
await Model.create(
|
||||
{ [parentField]: parentId, text: item.text, order_index: i },
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { syncObjectivesCreate, syncObjectivesUpdate };
|
||||
@@ -0,0 +1,44 @@
|
||||
"use strict";
|
||||
|
||||
const { Op } = require("sequelize");
|
||||
|
||||
const onlyDeleted = { deletedAt: { [Op.not]: null } };
|
||||
|
||||
/**
|
||||
* Soft-restore a single record by clearing deletedAt / deletedBy.
|
||||
* Returns the record on success, null if not found.
|
||||
*
|
||||
* @param {Model} Model - Sequelize model
|
||||
* @param {object} where - Where clause (must include onlyDeleted or equivalent)
|
||||
* @param {*} restoredBy - User ID stamped onto updatedBy
|
||||
* @param {object} t - Sequelize transaction
|
||||
*/
|
||||
async function restoreOne(Model, where, restoredBy, t) {
|
||||
const record = await Model.findOne({ where, paranoid: false });
|
||||
if (!record) return null;
|
||||
|
||||
await record.restore();
|
||||
await record.update({ updatedBy: restoredBy, deletedBy: null })
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-restore many records by their primary key column.
|
||||
* Returns the count of restored records.
|
||||
*
|
||||
* @param {Model} Model - Sequelize model
|
||||
* @param {string} pkColumn - Primary key column name (e.g. "course_id")
|
||||
* @param {Array} ids - Array of primary key values to restore
|
||||
* @param {*} restoredBy - User ID stamped onto updatedBy
|
||||
* @param {object} t - Sequelize transaction
|
||||
*/
|
||||
async function restoreMany(Model, pkColumn, ids, restoredBy, t) {
|
||||
const [count] = await Model.update(
|
||||
{ deletedAt: null, deletedBy: null, updatedBy: restoredBy ?? null },
|
||||
{ where: { [pkColumn]: ids, ...onlyDeleted }, transaction: t, paranoid: false }
|
||||
);
|
||||
return count;
|
||||
}
|
||||
|
||||
module.exports = { restoreOne, restoreMany };
|
||||
@@ -0,0 +1,112 @@
|
||||
// utils/duration.util.js
|
||||
|
||||
const AVG_WORDS_PER_MINUTE = 200;
|
||||
|
||||
function stripHtml(html) {
|
||||
// Simple regex strip — no extra dependency needed
|
||||
return (html ?? "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate seconds for a single lesson block
|
||||
* Block shape: { type: 'text'|'image'|'video', content, word_count, video_duration_seconds }
|
||||
*/
|
||||
function estimateBlockDuration(block) {
|
||||
const videoDuration = Number(block.video_duration_seconds) || 0;
|
||||
|
||||
// Derive word count from HTML content on the fly
|
||||
const getWordCount = (html) => {
|
||||
const text = stripHtml(html);
|
||||
return text ? text.split(" ").filter(Boolean).length : 0;
|
||||
};
|
||||
|
||||
const readingSecs = (html) =>
|
||||
Math.ceil((getWordCount(html) / AVG_WORDS_PER_MINUTE) * 60);
|
||||
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
return readingSecs(block.content?.body);
|
||||
|
||||
case "image":
|
||||
return 60;
|
||||
|
||||
case "video":
|
||||
return videoDuration;
|
||||
|
||||
case "text-image":
|
||||
case "text_image":
|
||||
return readingSecs(block.content?.body) + 60;
|
||||
|
||||
case "text-video":
|
||||
case "text_video":
|
||||
return readingSecs(block.content?.body) + videoDuration;
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute and persist duration_seconds up the chain:
|
||||
* blocks → lesson → unit → course
|
||||
*/
|
||||
async function recomputeDurations(lessonId) {
|
||||
const Lesson = require("../models/courses/lessons.mdl");
|
||||
const Unit = require("../models/courses/units.mdl");
|
||||
const { Course } = require("../models/courses/courses.mdl");
|
||||
const LessonPage = require("../models/courses/lesson_page.mdl");
|
||||
|
||||
// 1. Lesson duration from blocks
|
||||
const page = await LessonPage.findOne({ where: { lesson_id: lessonId } });
|
||||
const lessonSecs = (page?.blocks ?? []).reduce(
|
||||
(sum, block) => sum + estimateBlockDuration(block), 0
|
||||
);
|
||||
|
||||
// Guard against NaN before DB write
|
||||
const safeLessonSecs = isNaN(lessonSecs) ? 0 : lessonSecs;
|
||||
await Lesson.update({ duration_seconds: safeLessonSecs }, { where: { lesson_id: lessonId } });
|
||||
|
||||
// 2. Unit duration — sum of its lessons
|
||||
const lesson = await Lesson.findOne({ where: { lesson_id: lessonId } });
|
||||
const [unitResult] = await Lesson.sequelize.query(`
|
||||
SELECT COALESCE(SUM(duration_seconds), 0) AS total
|
||||
FROM lessons
|
||||
WHERE unit_id = :unitId AND "deletedAt" IS NULL
|
||||
`, { replacements: { unitId: lesson.unit_id }, type: Lesson.sequelize.QueryTypes.SELECT });
|
||||
|
||||
await Unit.update(
|
||||
{ duration_seconds: unitResult.total },
|
||||
{ where: { unit_id: lesson.unit_id } }
|
||||
);
|
||||
|
||||
// 3. Course duration — sum of its units
|
||||
const unit = await Unit.findOne({ where: { unit_id: lesson.unit_id } });
|
||||
const [courseResult] = await Lesson.sequelize.query(`
|
||||
SELECT COALESCE(SUM(duration_seconds), 0) AS total
|
||||
FROM units
|
||||
WHERE course_id = :courseId AND "deletedAt" IS NULL
|
||||
`, { replacements: { courseId: unit.course_id }, type: Lesson.sequelize.QueryTypes.SELECT });
|
||||
|
||||
await Course.update(
|
||||
{ duration_seconds: courseResult.total },
|
||||
{ where: { course_id: unit.course_id } }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format seconds → human readable: "1 hr 10 mins", "45 mins", "30 secs"
|
||||
*/
|
||||
function formatDuration(seconds) {
|
||||
if (!seconds) return "0 mins";
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
|
||||
const parts = [];
|
||||
if (h) parts.push(`${h} hr`);
|
||||
if (m) parts.push(`${m} min${m !== 1 ? "s" : ""}`);
|
||||
if (!h && !m && s) parts.push(`${s} sec${s !== 1 ? "s" : ""}`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
module.exports = { estimateBlockDuration, recomputeDurations, formatDuration };
|
||||
@@ -72,6 +72,7 @@ function flattenJsonb(jsonbSchema = {}, prefix = "", exclude = []) {
|
||||
field: path,
|
||||
order: value?.order ?? Infinity, // ← carry order from schema
|
||||
hidden: value?.hidden ?? false, // ← carry hidden flag
|
||||
filterable: value.filterable ?? true,
|
||||
options: {},
|
||||
});
|
||||
}
|
||||
@@ -141,14 +142,15 @@ function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLa
|
||||
name: defaultTimestampLabels[field] ?? def.label ?? toLabel(field),
|
||||
type,
|
||||
field,
|
||||
order: def.order ?? Infinity, // ← carry order from model
|
||||
hidden: def.hidden ?? false, // ← carry hidden flag
|
||||
order: def.order ?? Infinity,
|
||||
hidden: def.hidden ?? false,
|
||||
filterable: def.filterable ?? true,
|
||||
options: resolveOptions(def.type)
|
||||
});
|
||||
}
|
||||
|
||||
// ── Audit fields in correct sequence ────────────────────────────────────────
|
||||
for (const { field, type, order, hiddenOnList, hiddenOnArchived } of auditSequence) {
|
||||
for (const { field, type, order, hiddenOnList, hiddenOnArchived, filterable } of auditSequence) {
|
||||
if (exclude.includes(field)) continue;
|
||||
if (!rawAttrs[field]) continue; // skip if field doesn't exist on model
|
||||
|
||||
@@ -161,6 +163,7 @@ function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLa
|
||||
order,
|
||||
hidden: isArchived ? hiddenOnArchived : hiddenOnList,
|
||||
options: resolveOptions(rawAttrs[field]?.type),
|
||||
filterable: filterable ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -151,12 +151,13 @@ async function paginate(model, req, {
|
||||
const totalPages = Math.ceil(count / limit);
|
||||
|
||||
// ← Append computed metadata
|
||||
const computedMeta = computedAttributes.map(({ key, label, type, order: ord }) => ({
|
||||
const computedMeta = computedAttributes.map(({ key, label, type, order: ord, filterable }) => ({
|
||||
name: label ?? key,
|
||||
type: type ?? 'text',
|
||||
field: key,
|
||||
order: ord ?? Infinity,
|
||||
options: {},
|
||||
filterable: filterable
|
||||
}));
|
||||
|
||||
// ← Merge, sort, THEN strip order
|
||||
|
||||
Reference in New Issue
Block a user