mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -8,12 +8,16 @@ const { rescheduleJob, getOrCreateSetting } = require('../../cron/cronRegistry.u
|
||||
|
||||
// ─── Job registry — which cron scope owns each job (for defaults + labels) ────
|
||||
const JOBS = {
|
||||
taskOverdue: { schedule: '0 * * * *', label: 'Task Overdue Alerts (Admin)', description: 'Notifies admins when tasks flip to overdue.' },
|
||||
userNotifications: { schedule: '5 * * * *', label: 'Task Overdue Alerts (Users)', description: 'Notifies affected users when their tasks are marked overdue.' },
|
||||
taskOverdue: { schedule: '0 * * * *', label: 'Task Alerts (Admin)', description: 'Automatically marks expired tasks as overdue or completed, and notifies admins.' },
|
||||
userNotifications: { schedule: '5 * * * *', label: 'Task Alerts (Users)', description: 'Notifies affected users when their tasks are automatically marked overdue or completed.' },
|
||||
issueCertificates: { schedule: '0 * * * *', label: 'Certificate Issued', description: 'Notifies users when a course certificate is ready.' },
|
||||
expireUserTiers: { schedule: '* * * * *', label: 'Tier Expired', description: 'Notifies users when their subscription tier expires.' },
|
||||
};
|
||||
|
||||
// Jobs whose behavior can be tuned via target_status, and the values each accepts.
|
||||
const TARGET_STATUS_OPTIONS = ['overdue', 'completed'];
|
||||
const TARGET_STATUS_JOBS = ['taskOverdue'];
|
||||
|
||||
// ─── GET ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getSettings = async (req, res) => {
|
||||
@@ -26,6 +30,9 @@ exports.getSettings = async (req, res) => {
|
||||
enabled: row.enabled,
|
||||
schedule: row.schedule,
|
||||
preset: CRON_PRESET_BY_EXPRESSION[row.schedule] ?? null,
|
||||
target_status: TARGET_STATUS_JOBS.includes(job_name)
|
||||
? (row.target_status ?? 'completed')
|
||||
: null,
|
||||
label: meta.label,
|
||||
description: meta.description,
|
||||
updatedAt: row.updatedAt,
|
||||
@@ -44,7 +51,7 @@ exports.getSettings = async (req, res) => {
|
||||
exports.updateSetting = async (req, res) => {
|
||||
try {
|
||||
const { jobName } = req.params;
|
||||
const { enabled, preset, updatedBy } = req.body;
|
||||
const { enabled, preset, target_status, updatedBy } = req.body;
|
||||
|
||||
if (!JOBS[jobName]) return R.error(res, `Unknown job "${jobName}".`, 404);
|
||||
|
||||
@@ -53,6 +60,16 @@ exports.updateSetting = async (req, res) => {
|
||||
|
||||
if (enabled !== undefined) row.enabled = enabled === true || enabled === 'true';
|
||||
|
||||
if (target_status !== undefined) {
|
||||
if (!TARGET_STATUS_JOBS.includes(jobName)) {
|
||||
return R.error(res, `"target_status" is not configurable for job "${jobName}".`, 400);
|
||||
}
|
||||
if (!TARGET_STATUS_OPTIONS.includes(target_status)) {
|
||||
return R.error(res, `Invalid target_status. Must be one of: ${TARGET_STATUS_OPTIONS.join(', ')}`, 400);
|
||||
}
|
||||
row.target_status = target_status;
|
||||
}
|
||||
|
||||
if (preset !== undefined) {
|
||||
const schedule = CRON_PRESETS[preset];
|
||||
if (!schedule) return R.error(res, `Invalid preset. Must be one of: ${Object.keys(CRON_PRESETS).join(', ')}`, 400);
|
||||
@@ -69,7 +86,7 @@ exports.updateSetting = async (req, res) => {
|
||||
row.updatedBy = updatedBy ?? null;
|
||||
await row.save();
|
||||
|
||||
logActivity(req.user?.user_id, 'update_notification_setting', { entityType: 'cron_notification_setting', entityId: jobName, details: { enabled: row.enabled, schedule: row.schedule } });
|
||||
logActivity(req.user?.user_id, 'update_notification_setting', { entityType: 'cron_notification_setting', entityId: jobName, details: { enabled: row.enabled, schedule: row.schedule, target_status: row.target_status } });
|
||||
return R.success(res, 'Notification setting updated.', { data: row });
|
||||
} catch (err) {
|
||||
console.error('[NOTIFICATION SETTINGS][UPDATE]', err);
|
||||
|
||||
@@ -36,6 +36,30 @@ const normalizeUrl = (url) => {
|
||||
return `https://${url}`;
|
||||
};
|
||||
|
||||
// Deduped { user_id, group_id } rows for every user in a group assigned to this
|
||||
// task list — shared by the requirements-changed and submissions-toggled notify
|
||||
// blocks in updateTask. First group membership wins if a user is in more than one.
|
||||
async function getTaskListMembers(taskListId) {
|
||||
const groupRows = await TaskListGroup.findAll({
|
||||
where: { task_list_id: taskListId },
|
||||
attributes: ['group_id'],
|
||||
});
|
||||
const groupIds = groupRows.map((r) => r.group_id);
|
||||
if (!groupIds.length) return [];
|
||||
|
||||
const memberRows = await mdl_UserGroupMembers.findAll({
|
||||
where: { group_id: groupIds },
|
||||
attributes: ['user_id', 'group_id'],
|
||||
});
|
||||
|
||||
const seenUsers = new Set();
|
||||
return memberRows.filter(({ user_id }) => {
|
||||
if (seenUsers.has(user_id)) return false;
|
||||
seenUsers.add(user_id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Allowed filter/sort fields ───────────────────────────────────────────────
|
||||
const TASK_LIST_FIELDS = ['name', 'description', 'createdAt', 'updatedAt', 'deletedAt'];
|
||||
const TASK_FIELDS = ['name', 'description', 'deadline', 'order_index', 'is_required', 'status', 'createdAt', 'updatedAt', 'deletedAt'];
|
||||
@@ -875,12 +899,19 @@ exports.updateTask = async (req, res) => {
|
||||
return R.error(res, 'Task not found.', 404);
|
||||
}
|
||||
|
||||
const { name, description, deadline, status, is_required, requirements, prerequisite_task_ids } = req.body;
|
||||
const { name, description, deadline, status, is_required, accepts_submissions, requirements, prerequisite_task_ids } = req.body;
|
||||
|
||||
await task.update(
|
||||
{ name, description, deadline: deadline || null, status, is_required, updatedBy: req.user.user_id },
|
||||
{ transaction: t }
|
||||
);
|
||||
const wasAccepting = task.accepts_submissions;
|
||||
|
||||
const updates = { updatedBy: req.user.user_id };
|
||||
if (name !== undefined) updates.name = name;
|
||||
if (description !== undefined) updates.description = description;
|
||||
if (deadline !== undefined) updates.deadline = deadline || null;
|
||||
if (status !== undefined) updates.status = status;
|
||||
if (is_required !== undefined) updates.is_required = is_required;
|
||||
if (accepts_submissions !== undefined) updates.accepts_submissions = accepts_submissions;
|
||||
|
||||
await task.update(updates, { transaction: t });
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PATCH: exports.updateTask in task.controller.js (admin)
|
||||
@@ -951,53 +982,66 @@ exports.updateTask = async (req, res) => {
|
||||
// ── Notify assigned users when requirements changed ────────────────────
|
||||
if (Array.isArray(requirements)) {
|
||||
try {
|
||||
const groupRows = await TaskListGroup.findAll({
|
||||
where: { task_list_id: task.task_list_id },
|
||||
attributes: ['group_id'],
|
||||
});
|
||||
const groupIds = groupRows.map((r) => r.group_id);
|
||||
const members = await getTaskListMembers(task.task_list_id);
|
||||
|
||||
if (groupIds.length) {
|
||||
const memberRows = await mdl_UserGroupMembers.findAll({
|
||||
where: { group_id: groupIds },
|
||||
attributes: ['user_id', 'group_id'],
|
||||
if (members.length) {
|
||||
const now = new Date();
|
||||
// Title/message are identical for every member — render once,
|
||||
// then vary only the per-member groupId in the data payload.
|
||||
const notify = NOTIFICATION_REGISTRY.task_requirements_updated.build({
|
||||
taskName: full.name,
|
||||
taskListId: task.task_list_id,
|
||||
taskId: task.task_id,
|
||||
groupId: null,
|
||||
});
|
||||
|
||||
// One notification per user — first group membership wins if they're in more than one.
|
||||
const seenUsers = new Set();
|
||||
const members = memberRows.filter(({ user_id }) => {
|
||||
if (seenUsers.has(user_id)) return false;
|
||||
seenUsers.add(user_id);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (members.length) {
|
||||
const now = new Date();
|
||||
// Title/message are identical for every member — render once,
|
||||
// then vary only the per-member groupId in the data payload.
|
||||
const notify = NOTIFICATION_REGISTRY.task_requirements_updated.build({
|
||||
taskName: full.name,
|
||||
taskListId: task.task_list_id,
|
||||
groupId: null,
|
||||
});
|
||||
await UserNotification.bulkCreate(
|
||||
members.map(({ user_id, group_id }) => ({
|
||||
user_id,
|
||||
...notify,
|
||||
data: { ...notify.data, groupId: group_id },
|
||||
seen: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})),
|
||||
{ validate: false }
|
||||
);
|
||||
}
|
||||
await UserNotification.bulkCreate(
|
||||
members.map(({ user_id, group_id }) => ({
|
||||
user_id,
|
||||
...notify,
|
||||
data: { ...notify.data, groupId: group_id },
|
||||
seen: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})),
|
||||
{ validate: false }
|
||||
);
|
||||
}
|
||||
} catch (notifyErr) {
|
||||
console.error('[ADMIN][UPDATE TASK][NOTIFY]', notifyErr);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notify assigned users when accepting-submissions toggled ───────────
|
||||
if (accepts_submissions !== undefined && accepts_submissions !== wasAccepting) {
|
||||
try {
|
||||
const members = await getTaskListMembers(task.task_list_id);
|
||||
|
||||
if (members.length) {
|
||||
const now = new Date();
|
||||
const registryKey = accepts_submissions ? 'task_submissions_reopened' : 'task_submissions_closed';
|
||||
const notify = NOTIFICATION_REGISTRY[registryKey].build({
|
||||
taskName: full.name,
|
||||
taskListId: task.task_list_id,
|
||||
taskId: task.task_id,
|
||||
groupId: null,
|
||||
});
|
||||
await UserNotification.bulkCreate(
|
||||
members.map(({ user_id, group_id }) => ({
|
||||
user_id,
|
||||
...notify,
|
||||
data: { ...notify.data, groupId: group_id },
|
||||
seen: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})),
|
||||
{ validate: false }
|
||||
);
|
||||
}
|
||||
} catch (notifyErr) {
|
||||
console.error('[ADMIN][UPDATE TASK][NOTIFY SUBMISSIONS]', notifyErr);
|
||||
}
|
||||
}
|
||||
|
||||
return R.success(res, 'Task updated successfully.', full);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
|
||||
@@ -723,6 +723,11 @@ exports.submitTask = async (req, res) => {
|
||||
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId }, transaction: t });
|
||||
if (!task) { await t.rollback(); return R.error(res, 'Task not found.', 404); }
|
||||
|
||||
if (task.accepts_submissions === false) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'This task no longer accepts submissions.', 409);
|
||||
}
|
||||
|
||||
if (!(await assertTaskUnlocked(req.user.user_id, taskListId, task.order_index, taskId))) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'Complete the earlier required tasks in this list first.', 409);
|
||||
|
||||
Reference in New Issue
Block a user