mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -10,10 +10,11 @@
|
||||
***********************************************************************************************************************************************************************/
|
||||
'use strict';
|
||||
|
||||
const { Op } = require('sequelize');
|
||||
const mdl_Achievements = require('../models/users/achievements.mdl');
|
||||
const mdl_Users = require('../models/users/users.mdl');
|
||||
const { EARLY_ACCESS_CUTOFF, ACHIEVEMENT_REGISTRY } = require('../data/achievements.data');
|
||||
const { Op } = require('sequelize');
|
||||
const mdl_Achievements = require('../models/users/achievements.mdl');
|
||||
const mdl_AchievementDefinitions = require('../models/users/achievement_definitions.mdl');
|
||||
const mdl_Users = require('../models/users/users.mdl');
|
||||
const { EARLY_ACCESS_CUTOFF } = require('../data/achievements.data');
|
||||
const UserNotification = require('../models/notifications/user_notification.mdl');
|
||||
const { NOTIFICATION_REGISTRY } = require('../data/notifications.data');
|
||||
|
||||
@@ -24,15 +25,15 @@ const { NOTIFICATION_REGISTRY } = require('../data/notifications.data');
|
||||
* Safe to call multiple times — idempotent via findOrCreate.
|
||||
*
|
||||
* @param {string|number} user_id
|
||||
* @param {string} key — must exist in ACHIEVEMENT_REGISTRY
|
||||
* @param {string} key — must exist in the achievement_definitions catalog
|
||||
* @param {Object} metadata — optional extra data (course_id, score, etc.)
|
||||
* @param {string|number} granted_by — null = system, user_id = admin manual grant
|
||||
* @returns {{ achievement, created }} or null on error
|
||||
*/
|
||||
async function grantAchievement(user_id, key, metadata = {}, granted_by = null) {
|
||||
const def = ACHIEVEMENT_REGISTRY[key];
|
||||
const def = await mdl_AchievementDefinitions.findOne({ where: { key, is_active: true } });
|
||||
if (!def) {
|
||||
console.warn(`[ACHIEVEMENTS] Unknown achievement key: "${key}"`);
|
||||
console.warn(`[ACHIEVEMENTS] Unknown or inactive achievement key: "${key}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -47,6 +48,7 @@ async function grantAchievement(user_id, key, metadata = {}, granted_by = null)
|
||||
key: def.key,
|
||||
label: def.label,
|
||||
description: def.description,
|
||||
icon: def.icon,
|
||||
granted_by: granted_by ?? null,
|
||||
granted_at: new Date(),
|
||||
metadata,
|
||||
@@ -140,7 +142,6 @@ async function backfillEarlyAccess() {
|
||||
// ─── Exports ──────────────────────────────────────────────────────────────────
|
||||
|
||||
module.exports = {
|
||||
ACHIEVEMENT_REGISTRY,
|
||||
grantAchievement,
|
||||
|
||||
// Convenience triggers
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name : certificate-record.service.js
|
||||
* Type : Service
|
||||
* Description : Resolves or lazily creates the persisted `certificates` row for a
|
||||
* user/course pair (cert_no/ref_no assignment, instructor snapshot).
|
||||
* Shared by the PDF download endpoint (certificate.controller.js) and
|
||||
* the hourly issuance cron (cron/jobs/issue_certificates.cron.js) so
|
||||
* both write through the same cert_no/ref_no sequence.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jul. 2, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
'use strict';
|
||||
|
||||
const { formatDuration } = require('../utils/duration.util');
|
||||
|
||||
const {
|
||||
Course,
|
||||
CourseAssessment,
|
||||
QuizAttempt,
|
||||
Certificate,
|
||||
CourseInstructor,
|
||||
} = require('../models/courses/courses.associations');
|
||||
|
||||
function formatInstructors(rows) {
|
||||
const names = rows.map(r => r.display_name);
|
||||
if (names.length === 0) return '';
|
||||
if (names.length === 1) return names[0];
|
||||
if (names.length === 2) return `${names[0]} and ${names[1]}`;
|
||||
return `${names[0]}, ${names[1]} and et. al`;
|
||||
}
|
||||
|
||||
// cert_no format: YYYYMM-{userId:6}-{userCertSeq:5}
|
||||
async function buildCertNo(userId) {
|
||||
const count = await Certificate.count({ where: { user_id: userId } });
|
||||
const seq = String(count + 1).padStart(5, '0');
|
||||
const uid = String(userId).padStart(6, '0');
|
||||
const now = new Date();
|
||||
const YYYYMM = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
return `${YYYYMM}-${uid}-${seq}`;
|
||||
}
|
||||
|
||||
// ref_no format: PP-YYYYMM-{globalSeq:5} (unique across all certs)
|
||||
async function buildRefNo() {
|
||||
const count = await Certificate.count();
|
||||
const seq = String(count + 1).padStart(5, '0');
|
||||
const now = new Date();
|
||||
const YYYYMM = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
return `PP-${YYYYMM}-${seq}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves (or creates) the Certificate row for a user/course. Idempotent —
|
||||
* safe to call from both the cron job and the on-demand download endpoint.
|
||||
* Returns null if the course has no assessment or the user hasn't passed it.
|
||||
*/
|
||||
async function ensureCertificateRecord({ userId, courseId }) {
|
||||
const course = await Course.findOne({
|
||||
where: { course_id: courseId },
|
||||
attributes: ['course_id', 'title', 'duration_seconds'],
|
||||
include: [
|
||||
{ model: CourseAssessment, as: 'assessment', attributes: ['assessment_id'], required: false },
|
||||
{
|
||||
model: CourseInstructor,
|
||||
as: 'instructors',
|
||||
attributes: ['display_name', 'order_index'],
|
||||
required: false,
|
||||
order: [['order_index', 'ASC']],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!course || !course.assessment) return null;
|
||||
|
||||
const passedAttempt = await QuizAttempt.findOne({
|
||||
where: {
|
||||
user_id: userId,
|
||||
assessment_id: course.assessment.assessment_id,
|
||||
passed: true,
|
||||
},
|
||||
order: [['createdAt', 'DESC']],
|
||||
attributes: ['score', 'createdAt'],
|
||||
});
|
||||
if (!passedAttempt) return null;
|
||||
|
||||
const liveInstructors = formatInstructors(course.instructors ?? []);
|
||||
|
||||
// CockroachDB does not support findOrCreate (uses temp PL/pgSQL functions).
|
||||
let cert = await Certificate.findOne({ where: { user_id: userId, course_id: courseId } });
|
||||
if (!cert) {
|
||||
cert = await Certificate.create({
|
||||
user_id: userId,
|
||||
course_id: courseId,
|
||||
cert_no: await buildCertNo(userId),
|
||||
ref_no: await buildRefNo(),
|
||||
instructors: liveInstructors,
|
||||
score: passedAttempt.score ?? null,
|
||||
length_str: formatDuration(course.duration_seconds),
|
||||
issued_at: passedAttempt.createdAt,
|
||||
});
|
||||
} else if (liveInstructors !== (cert.instructors ?? '')) {
|
||||
await cert.update({ instructors: liveInstructors });
|
||||
}
|
||||
|
||||
return cert;
|
||||
}
|
||||
|
||||
module.exports = { ensureCertificateRecord, formatInstructors, buildCertNo, buildRefNo };
|
||||
+34
-11
@@ -2,18 +2,22 @@
|
||||
* File Name: email.service.js
|
||||
* Type of Program: Service
|
||||
* Description: Nodemailer-based email service.
|
||||
* Provides:
|
||||
* - sendOTPEmail() → sends a 6-digit OTP verification email
|
||||
* - sendWelcomeEmail() → sent after successful email verification
|
||||
* Author: rgrgogu
|
||||
* Subject + body per type are loaded from the email_templates
|
||||
* table (admin-editable, see controllers/admin/email_templates.controller.js).
|
||||
* The outer layout (header/footer/signature) below is fixed in
|
||||
* code and is NOT admin-editable — only the body content is.
|
||||
* Author: rgrgogu, Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Oct. 6, 2025
|
||||
* Date Modified: Jul. 3, 2026 — templates moved from data/email_body.data.js into the DB
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* const emailService = require('../services/email.service');
|
||||
* await emailService.sendOTPEmail(user.email, otp);
|
||||
* await emailService.sendEmail({ to: user.email, type: 'OTP', data: { otp } });
|
||||
***********************************************************************************************************************************************************************/
|
||||
const nodemailer = require('nodemailer');
|
||||
const { emailTemplates } = require('../data/email_body.data')
|
||||
const mdl_EmailTemplate = require('../models/email_templates/email_templates.mdl');
|
||||
const { enrichEmailData } = require('../data/email_template_enrichers.data');
|
||||
const { renderTemplate } = require('../utils/renderTemplate.util');
|
||||
|
||||
const port = Number(process.env.SMTP_PORT);
|
||||
|
||||
@@ -31,15 +35,34 @@ const transporter = nodemailer.createTransport({
|
||||
},
|
||||
});
|
||||
|
||||
// Fixed layout — admins cannot change header/footer/signature via the CRUD,
|
||||
// only the body content per template type.
|
||||
const FONT = 'font-family: Arial, sans-serif; font-size: 14px; color: #000;';
|
||||
const wrap = (body) => `
|
||||
<html>
|
||||
<body style="${FONT} line-height: 1.6;">
|
||||
${body.trim()}
|
||||
<br><br>
|
||||
<p style="margin: 0;">Regards,<br>Philproperties IT Team</p>
|
||||
<p style="margin: 0; font-size: 12px; color: #555;">This is an automated message from STARR System. Please do not reply.</p>
|
||||
</body>
|
||||
</html>`.trim();
|
||||
|
||||
const sendEmail = async ({ to, type, data = {} }) => {
|
||||
try {
|
||||
const templateFn = emailTemplates[type];
|
||||
|
||||
if (!templateFn) {
|
||||
const template = await mdl_EmailTemplate.findOne({ where: { type } });
|
||||
if (!template) {
|
||||
throw new Error(`Email template "${type}" not found`);
|
||||
}
|
||||
// Draft content (or a template that's never been sent) never reaches
|
||||
// real mail — only the live subject/html_body columns count as "published".
|
||||
if (!template.subject || !template.html_body) {
|
||||
throw new Error(`Email template "${type}" has no published (sent) version yet`);
|
||||
}
|
||||
|
||||
const { subject, html } = templateFn(data);
|
||||
const enriched = enrichEmailData(type, data);
|
||||
const subject = renderTemplate(template.subject, enriched);
|
||||
const html = wrap(renderTemplate(template.html_body, enriched));
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
transporter.sendMail(
|
||||
@@ -65,4 +88,4 @@ const sendEmail = async ({ to, type, data = {} }) => {
|
||||
|
||||
const ping = () => transporter.verify();
|
||||
|
||||
module.exports = { sendEmail, ping };
|
||||
module.exports = { sendEmail, ping };
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: mediaToken.service.js
|
||||
* Type of Program: Service
|
||||
* Description: Issues + caches short-lived JWT stream tokens (and presigned S3
|
||||
* thumbnail URLs) for asset preview. Shared by:
|
||||
* - controllers/admin/media.controller.js (POST /admin/media/token(s))
|
||||
* - controllers/admin/assets.controller.js (embeds tokens directly
|
||||
* into GET /admin/assets rows so pickers don't need a second
|
||||
* round-trip just to render thumbnails)
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
***********************************************************************************************************************************************************************/
|
||||
"use strict";
|
||||
|
||||
const jwt = require("jsonwebtoken");
|
||||
const s3 = require("./s3.service");
|
||||
|
||||
const MEDIA_SECRET = process.env.MEDIA_JWT_SECRET ?? process.env.JWT_SECRET;
|
||||
const TOKEN_TTL_SEC = 30 * 60; // 30 min — admin preview session
|
||||
const SUPPORTED_TYPES = ["video", "audio", "document", "image"];
|
||||
|
||||
// ─── In-memory token cache (no Redis yet) ──────────────────────────────────────
|
||||
// Avoids re-signing a JWT / re-presigning the thumbnail S3 URL for an asset that
|
||||
// already has a still-valid token. Keyed by (asset_id, ip) because the stream
|
||||
// endpoint (/api/client/media/stream/:token) pins the token to the issuing
|
||||
// request's IP — reusing a token minted for a different IP would get rejected.
|
||||
// Single-process only; each app instance keeps its own cache.
|
||||
const TOKEN_CACHE_MARGIN_SEC = 120; // re-mint a bit before actual expiry
|
||||
const tokenCache = new Map(); // `${asset_id}:${ip}` -> { token, thumbnail_url, expiresAt }
|
||||
|
||||
function tokenCacheKey(assetId, ip) {
|
||||
return `${assetId}:${ip}`;
|
||||
}
|
||||
|
||||
function getCachedToken(assetId, ip) {
|
||||
const key = tokenCacheKey(assetId, ip);
|
||||
const entry = tokenCache.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() >= entry.expiresAt) {
|
||||
tokenCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
function setCachedToken(assetId, ip, token, thumbnail_url) {
|
||||
tokenCache.set(tokenCacheKey(assetId, ip), {
|
||||
token,
|
||||
thumbnail_url,
|
||||
expiresAt: Date.now() + (TOKEN_TTL_SEC - TOKEN_CACHE_MARGIN_SEC) * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// Collapses IPv4-mapped IPv6 ("::ffff:127.0.0.1") and IPv6 loopback ("::1")
|
||||
// down to a single canonical form. Without this, a token minted off one
|
||||
// "localhost" connection (IPv4) fails IP-pin verification on a sibling
|
||||
// request that happened to land on the other stack (IPv6) — browsers race
|
||||
// both when resolving "localhost", so mint and stream requests can land on
|
||||
// different stacks even from the same client.
|
||||
function normalizeIp(ip) {
|
||||
if (ip === "::1") return "127.0.0.1";
|
||||
if (ip.startsWith("::ffff:")) return ip.slice(7);
|
||||
return ip;
|
||||
}
|
||||
|
||||
function resolveIp(req) {
|
||||
const forwarded = req.headers["x-forwarded-for"];
|
||||
const raw = forwarded ? forwarded.split(",")[0].trim() : (req.ip ?? req.socket?.remoteAddress ?? "unknown");
|
||||
return normalizeIp(raw);
|
||||
}
|
||||
|
||||
function signToken(asset, userId, ip) {
|
||||
return jwt.sign(
|
||||
{
|
||||
asset_id: asset.asset_id,
|
||||
user_id: userId,
|
||||
storage_key: asset.storage_key,
|
||||
file_type: asset.file_type,
|
||||
mime_type: asset.mime_type,
|
||||
ip,
|
||||
},
|
||||
MEDIA_SECRET,
|
||||
{ expiresIn: TOKEN_TTL_SEC }
|
||||
);
|
||||
}
|
||||
|
||||
// ─── issueForAsset ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Returns { token, thumbnail_url } for an S3 asset, minting + caching on first
|
||||
// call and serving from tokenCache on subsequent calls within the TTL margin.
|
||||
// `asset` needs: asset_id, storage_key, file_type, mime_type, thumbnail_storage_key.
|
||||
//
|
||||
async function issueForAsset(asset, userId, ip) {
|
||||
const cached = getCachedToken(asset.asset_id, ip);
|
||||
if (cached) return { token: cached.token, thumbnail_url: cached.thumbnail_url };
|
||||
|
||||
const token = signToken(asset, userId, ip);
|
||||
|
||||
let thumbnail_url = null;
|
||||
if (asset.thumbnail_storage_key) {
|
||||
try {
|
||||
thumbnail_url = await s3.getSignedDownloadUrl(asset.thumbnail_storage_key, TOKEN_TTL_SEC);
|
||||
} catch {
|
||||
// Non-fatal — thumbnail is cosmetic
|
||||
}
|
||||
}
|
||||
|
||||
setCachedToken(asset.asset_id, ip, token, thumbnail_url);
|
||||
return { token, thumbnail_url };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TOKEN_TTL_SEC,
|
||||
SUPPORTED_TYPES,
|
||||
resolveIp,
|
||||
issueForAsset,
|
||||
};
|
||||
@@ -1,75 +0,0 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: paypal.service.js
|
||||
* Type of Program: Service
|
||||
* Description: PayPal Orders API helpers — create order, capture order.
|
||||
* Uses client-side JS SDK button → server capture flow.
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 6, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
const axios = require('axios');
|
||||
|
||||
const BASE_URL = process.env.PAYPAL_ENV === 'live'
|
||||
? 'https://api-m.paypal.com'
|
||||
: 'https://api-m.sandbox.paypal.com';
|
||||
|
||||
const getAccessToken = async () => {
|
||||
const { data } = await axios.post(
|
||||
`${BASE_URL}/v1/oauth2/token`,
|
||||
'grant_type=client_credentials',
|
||||
{
|
||||
auth: {
|
||||
username: process.env.PAYPAL_CLIENT_ID,
|
||||
password: process.env.PAYPAL_CLIENT_SECRET,
|
||||
},
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
}
|
||||
);
|
||||
return data.access_token;
|
||||
};
|
||||
|
||||
exports.createOrder = async ({ amount, currency = 'USD', referenceId, returnUrl, cancelUrl }) => {
|
||||
const token = await getAccessToken();
|
||||
const { data } = await axios.post(
|
||||
`${BASE_URL}/v2/checkout/orders`,
|
||||
{
|
||||
intent: 'CAPTURE',
|
||||
purchase_units: [{
|
||||
reference_id: referenceId,
|
||||
amount: { currency_code: currency, value: String(amount) },
|
||||
}],
|
||||
application_context: {
|
||||
return_url: returnUrl ?? `${process.env.FRONTEND_URL}/plans/checkout`,
|
||||
cancel_url: cancelUrl ?? `${process.env.FRONTEND_URL}/plans/checkout?cancelled=true`,
|
||||
brand_name: 'Philproperties',
|
||||
user_action: 'PAY_NOW',
|
||||
},
|
||||
},
|
||||
{ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
exports.captureOrder = async (orderId) => {
|
||||
const token = await getAccessToken();
|
||||
const { data } = await axios.post(
|
||||
`${BASE_URL}/v2/checkout/orders/${orderId}/capture`,
|
||||
{},
|
||||
{ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
return data; // { id, status, purchase_units, payer }
|
||||
};
|
||||
|
||||
exports.refundCapture = async (captureId, amount, currency = 'USD') => {
|
||||
const token = await getAccessToken();
|
||||
const { data } = await axios.post(
|
||||
`${BASE_URL}/v2/payments/captures/${captureId}/refund`,
|
||||
{
|
||||
amount: {
|
||||
value: String(amount),
|
||||
currency_code: currency,
|
||||
},
|
||||
},
|
||||
{ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
return data; // { id, status, amount, ... }
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: task_reading_progress_sync.service.js
|
||||
* Type of Program: Service
|
||||
* Description: Backfills task_progress for read_* task requirements from course_reading_progress.
|
||||
*
|
||||
* This covers the case where a user already completed reading a course/unit/lesson
|
||||
* before a task requiring that item was created or assigned.
|
||||
***********************************************************************************************************************************************************************/
|
||||
'use strict';
|
||||
|
||||
const { Op } = require('sequelize');
|
||||
const CourseReadingProgress = require('../models/courses/course_reading_progress.mdl');
|
||||
const { TaskProgress } = require('../models/task/task_progress.mdl');
|
||||
|
||||
const READ_TYPE_TO_PROGRESS_TYPE = {
|
||||
read_course: 'course',
|
||||
read_unit: 'unit',
|
||||
read_lesson: 'lesson',
|
||||
};
|
||||
|
||||
const READ_REQUIREMENT_TYPES = Object.keys(READ_TYPE_TO_PROGRESS_TYPE);
|
||||
|
||||
function readAttr(row, attr) {
|
||||
if (!row) return undefined;
|
||||
if (typeof row.get === 'function') return row.get(attr);
|
||||
return row[attr];
|
||||
}
|
||||
|
||||
function normalizeRequirement(row) {
|
||||
const type = readAttr(row, 'type');
|
||||
if (!READ_REQUIREMENT_TYPES.includes(type)) return null;
|
||||
|
||||
const referenceId = readAttr(row, 'reference_id');
|
||||
if (!referenceId) return null;
|
||||
|
||||
return {
|
||||
task_id: readAttr(row, 'task_id'),
|
||||
requirement_id: readAttr(row, 'requirement_id'),
|
||||
reference_id: referenceId,
|
||||
type,
|
||||
};
|
||||
}
|
||||
|
||||
async function hydrateReadTaskProgress(userId, requirements = [], options = {}) {
|
||||
const readRequirements = requirements
|
||||
.map(normalizeRequirement)
|
||||
.filter((req) => req && req.task_id && req.requirement_id);
|
||||
|
||||
if (!readRequirements.length) return [];
|
||||
|
||||
const referencesByProgressType = readRequirements.reduce((acc, req) => {
|
||||
const progressType = READ_TYPE_TO_PROGRESS_TYPE[req.type];
|
||||
if (!acc[progressType]) acc[progressType] = new Set();
|
||||
acc[progressType].add(req.reference_id);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const where = {
|
||||
user_id: userId,
|
||||
status: 'completed',
|
||||
[Op.or]: Object.entries(referencesByProgressType).map(([type, references]) => ({
|
||||
type,
|
||||
reference_id: { [Op.in]: [...references] },
|
||||
})),
|
||||
};
|
||||
|
||||
const completedReadingRows = await CourseReadingProgress.findAll({
|
||||
where,
|
||||
attributes: ['type', 'reference_id', 'completed_at'],
|
||||
transaction: options.transaction,
|
||||
});
|
||||
|
||||
if (!completedReadingRows.length) return [];
|
||||
|
||||
const completedReading = new Map(
|
||||
completedReadingRows.map((row) => [
|
||||
`${readAttr(row, 'type')}:${readAttr(row, 'reference_id')}`,
|
||||
readAttr(row, 'completed_at'),
|
||||
])
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
const rowsToUpsert = readRequirements.filter((req) =>
|
||||
completedReading.has(`${READ_TYPE_TO_PROGRESS_TYPE[req.type]}:${req.reference_id}`)
|
||||
);
|
||||
|
||||
if (!rowsToUpsert.length) return [];
|
||||
|
||||
const existingProgressRows = await TaskProgress.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
completed: true,
|
||||
requirement_id: { [Op.in]: rowsToUpsert.map((req) => req.requirement_id) },
|
||||
reference_id: { [Op.in]: rowsToUpsert.map((req) => req.reference_id) },
|
||||
},
|
||||
attributes: ['requirement_id', 'reference_id'],
|
||||
transaction: options.transaction,
|
||||
});
|
||||
|
||||
const existingProgress = new Set(
|
||||
existingProgressRows.map((row) =>
|
||||
`${readAttr(row, 'requirement_id')}:${readAttr(row, 'reference_id')}`
|
||||
)
|
||||
);
|
||||
|
||||
const missingRows = rowsToUpsert.filter((req) =>
|
||||
!existingProgress.has(`${req.requirement_id}:${req.reference_id}`)
|
||||
);
|
||||
|
||||
if (!missingRows.length) return [];
|
||||
|
||||
await Promise.all(missingRows.map((req) =>
|
||||
TaskProgress.upsert(
|
||||
{
|
||||
task_id: req.task_id,
|
||||
requirement_id: req.requirement_id,
|
||||
user_id: userId,
|
||||
reference_id: req.reference_id,
|
||||
type: req.type,
|
||||
completed: true,
|
||||
completed_at: completedReading.get(`${READ_TYPE_TO_PROGRESS_TYPE[req.type]}:${req.reference_id}`) ?? now,
|
||||
createdBy: userId,
|
||||
updatedBy: userId,
|
||||
},
|
||||
{
|
||||
conflictFields: ['requirement_id', 'user_id', 'reference_id'],
|
||||
transaction: options.transaction,
|
||||
}
|
||||
)
|
||||
));
|
||||
|
||||
return missingRows;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hydrateReadTaskProgress,
|
||||
READ_REQUIREMENT_TYPES,
|
||||
};
|
||||
Reference in New Issue
Block a user