testing 101

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-30 19:31:45 +08:00
parent 89acdfc239
commit 1372f4e975
62 changed files with 6696 additions and 281 deletions
+13 -6
View File
@@ -92,19 +92,26 @@ EMAIL_FROM=CHANGE_ME@gmail.com
OTP_EXPIRY_MINUTES=10 OTP_EXPIRY_MINUTES=10
# ── S3-compatible Storage (Garage self-hosted) ──────────────────────────────── # ── S3-compatible Storage (Garage self-hosted) ────────────────────────────────
# S3_ENDPOINT: internal address of your Garage node (e.g. http://127.0.0.1:3900 # Garage is bundled in docker-compose.yml — no separate install needed.
# if Garage runs on the same server, or http://<garage-host>:3900) #
# S3_PUBLIC_URL: the public-facing base URL served by your reverse proxy # Docker setup: S3_ENDPOINT=http://garage:3900 (use the service name)
# (e.g. https://cdn.yourdomain.com) # Bare-metal: S3_ENDPOINT=http://127.0.0.1:3900
S3_ENDPOINT=http://127.0.0.1:3900 #
# S3_PUBLIC_URL: public-facing URL served by your reverse proxy
# (e.g. Caddy/Nginx → https://cdn.yourdomain.com → garage:3900)
#
# GARAGE_RPC_SECRET: shared secret for Garage RPC.
# Generate with: openssl rand -hex 32
S3_ENDPOINT=http://garage:3900
S3_REGION=garage S3_REGION=garage
S3_ACCESS_KEY=CHANGE_ME S3_ACCESS_KEY=CHANGE_ME
S3_SECRET_KEY=CHANGE_ME S3_SECRET_KEY=CHANGE_ME
S3_BUCKET=CHANGE_ME S3_BUCKET=CHANGE_ME
S3_PUBLIC_URL=https://cdn.yourdomain.com S3_PUBLIC_URL=https://cdn.yourdomain.com
GARAGE_RPC_SECRET=CHANGE_ME_32_BYTE_HEX
# ── Chibisafe (optional — used alongside S3 for some asset types) ───────────── # ── Chibisafe (optional — used alongside S3 for some asset types) ─────────────
# Replace the zrok tunnel URL with a stable domain pointing to your Chibisafe instance. # Set CHIBISAFE_BASE_URL to the public domain pointing to your Chibisafe instance.
CHIBISAFE_BASE_URL=https://files.yourdomain.com CHIBISAFE_BASE_URL=https://files.yourdomain.com
CHIBISAFE_API_KEY=CHANGE_ME CHIBISAFE_API_KEY=CHANGE_ME
CHIBISAFE_ALBUM_AVATARS=CHANGE_ME_UUID CHIBISAFE_ALBUM_AVATARS=CHANGE_ME_UUID
+1 -1
View File
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier same "printed page" as the copyright notice for easier
identification within third-party archives. identification within third-party archives.
Copyright [yyyy] [name of copyright owner] Copyright 2025 [Kenneth Obsequio and Russell Obsequio]
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
+1 -1
View File
@@ -8,7 +8,7 @@ const base = {
password: process.env.DB_PASSWORD, password: process.env.DB_PASSWORD,
database: process.env.DB_NAME, database: process.env.DB_NAME,
host: process.env.DB_HOST, host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT, 10) || 26257, port: parseInt(process.env.DB_PORT, 10) || 5432,
dialect: 'postgres', dialect: 'postgres',
dialectOptions: { dialectOptions: {
...(useSSL && { ...(useSSL && {
+17
View File
@@ -0,0 +1,17 @@
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
# Single-node setup. Increase replication_factor if you add more Garage nodes.
replication_factor = 1
# RPC — used for node-to-node and CLI-to-daemon communication.
# rpc_secret is read from GARAGE_RPC_SECRET env var (set in .env).
rpc_bind_addr = "0.0.0.0:3901"
rpc_public_addr = "garage:3901"
[s3_api]
s3_region = "garage"
api_bind_addr = "0.0.0.0:3900"
[admin]
api_bind_addr = "0.0.0.0:3903"
+72 -7
View File
@@ -23,7 +23,7 @@ const {
CoursePrerequisite, CourseAssessment, CoursePrerequisite, CourseAssessment,
UnitQuiz, QuizQuestion, QuizOption, UnitQuiz, QuizQuestion, QuizOption,
QuizAttempt, AssessmentSession, QuizAttempt, AssessmentSession,
CourseInstructor, CourseInstructor, CourseAchievement,
} = require("../../models/courses/courses.associations"); } = require("../../models/courses/courses.associations");
const mdl_Users = require("../../models/users/users.mdl"); const mdl_Users = require("../../models/users/users.mdl");
@@ -100,6 +100,8 @@ exports.createCourse = async (req, res) => {
course_code, level, subscription, course_code, level, subscription,
objectives = [], objectives = [],
category_ids = [], category_ids = [],
achievement_keys = [],
badge_color, badge_asset_id, badge_image_url,
createdBy, createdBy,
} = req.body; } = req.body;
@@ -107,18 +109,28 @@ exports.createCourse = async (req, res) => {
const course = await Course.create({ const course = await Course.create({
title, title,
description: description ?? null, description: description ?? null,
order_index: order_index ?? 0, order_index: order_index ?? 0,
course_code: course_code ?? null, course_code: course_code ?? null,
level: level ?? null, level: level ?? null,
subscription: subscription ?? "free", subscription: subscription ?? "free",
duration_seconds: 0, duration_seconds: 0,
createdBy: createdBy ?? null, badge_color: badge_color ?? "purple",
badge_asset_id: badge_asset_id ?? null,
badge_image_url: badge_image_url ?? null,
createdBy: createdBy ?? null,
}, { transaction: t }); }, { transaction: t });
await syncObjectivesCreate(CourseObjective, "course_id", course.course_id, objectives, t); await syncObjectivesCreate(CourseObjective, "course_id", course.course_id, objectives, t);
await syncJunction(CourseProductCat, course.course_id, category_ids, "category_id", t); await syncJunction(CourseProductCat, course.course_id, category_ids, "category_id", t);
if (achievement_keys.length) {
await CourseAchievement.bulkCreate(
achievement_keys.slice(0, 3).map((key, i) => ({ course_id: course.course_id, achievement_key: key, order_index: i })),
{ transaction: t },
);
}
await t.commit(); await t.commit();
logActivity(req.user?.user_id, 'create_course', { entityType: 'course', entityId: course.course_id, details: { title: course.title } }); logActivity(req.user?.user_id, 'create_course', { entityType: 'course', entityId: course.course_id, details: { title: course.title } });
return R.success(res, "Course created.", { data: course }, 201); return R.success(res, "Course created.", { data: course }, 201);
@@ -140,6 +152,7 @@ exports.updateCourse = async (req, res) => {
title, description, order_index, title, description, order_index,
course_code, level, subscription, course_code, level, subscription,
objectives, category_ids, objectives, category_ids,
badge_color, badge_asset_id, badge_image_url,
updatedBy, updatedBy,
} = req.body; } = req.body;
@@ -149,6 +162,9 @@ exports.updateCourse = async (req, res) => {
if (course_code !== undefined) course.course_code = course_code; if (course_code !== undefined) course.course_code = course_code;
if (level !== undefined) course.level = level; if (level !== undefined) course.level = level;
if (subscription !== undefined) course.subscription = subscription; if (subscription !== undefined) course.subscription = subscription;
if (badge_color !== undefined) course.badge_color = badge_color;
if (badge_asset_id !== undefined) course.badge_asset_id = badge_asset_id;
if (badge_image_url !== undefined) course.badge_image_url = badge_image_url;
course.updatedBy = updatedBy ?? null; course.updatedBy = updatedBy ?? null;
await course.save({ transaction: t }); await course.save({ transaction: t });
@@ -1760,3 +1776,52 @@ exports.getAssessmentSessions = async (req, res) => {
return R.error(res, "Could not retrieve assessment sessions.", 500); return R.error(res, "Could not retrieve assessment sessions.", 500);
} }
}; };
// ══════════════════════════════════════════════════════════════════════════════
// COURSE ACHIEVEMENTS
// ══════════════════════════════════════════════════════════════════════════════
exports.getCourseAchievements = async (req, res) => {
try {
const { courseId } = req.params;
const rows = await CourseAchievement.findAll({
where: { course_id: courseId },
order: [["order_index", "ASC"]],
});
return R.success(res, "Course achievements retrieved.", { data: rows });
} catch (err) {
console.error("[COURSE][ACHIEVEMENTS][GET]", err);
return R.error(res, "Could not retrieve course achievements.", 500);
}
};
exports.syncCourseAchievements = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId } = req.params;
const { achievement_keys = [] } = req.body;
if (achievement_keys.length > 3)
return R.error(res, "Maximum 3 achievements allowed per course.", 400);
await CourseAchievement.destroy({ where: { course_id: courseId }, transaction: t });
if (achievement_keys.length) {
await CourseAchievement.bulkCreate(
achievement_keys.map((key, i) => ({ course_id: courseId, achievement_key: key, order_index: i })),
{ transaction: t },
);
}
await t.commit();
const rows = await CourseAchievement.findAll({
where: { course_id: courseId },
order: [["order_index", "ASC"]],
});
return R.success(res, "Course achievements updated.", { data: rows });
} catch (err) {
await t.rollback();
console.error("[COURSE][ACHIEVEMENTS][SYNC]", err);
return R.error(res, "Could not update course achievements.", 500);
}
};
+19 -4
View File
@@ -113,16 +113,31 @@ exports.issueTokensBatch = async (req, res) => {
storage_provider: "s3", storage_provider: "s3",
deletedAt: null, deletedAt: null,
}, },
attributes: ["asset_id", "file_type", "storage_key", "mime_type"], attributes: ["asset_id", "file_type", "storage_key", "mime_type", "thumbnail_storage_key"],
}); });
const ip = resolveIp(req); const ip = resolveIp(req);
const tokens = {}; const tokens = {};
const thumbnails = {};
for (const asset of assets) { for (const asset of assets) {
tokens[String(asset.asset_id)] = signToken(asset, req.user.user_id, ip); tokens[String(asset.asset_id)] = signToken(asset, req.user.user_id, ip);
// For image/video assets with a thumbnail — presign it so the browser can
// load it directly from Garage without going through the stream proxy.
if (asset.thumbnail_storage_key) {
try {
thumbnails[String(asset.asset_id)] = await s3.getSignedDownloadUrl(
asset.thumbnail_storage_key,
TOKEN_TTL_SEC,
);
} catch {
// Non-fatal — stream token is the fallback
}
}
} }
return R.success(res, "Tokens issued.", { tokens }); return R.success(res, "Tokens issued.", { tokens, thumbnails });
} catch (err) { } catch (err) {
console.error("[ADMIN][MEDIA][TOKENS BATCH]", err); console.error("[ADMIN][MEDIA][TOKENS BATCH]", err);
return R.error(res, "Could not issue media tokens.", 500); return R.error(res, "Could not issue media tokens.", 500);
+139
View File
@@ -0,0 +1,139 @@
/***********************************************************************************************************************************************************************
* File Name: plan_prices.controller.js (admin)
* Type of Program: Controller
* Description: Admin CRUD for localized price overrides per tier plan.
* Routes: GET/POST/PUT/DELETE /admin/tiers/:id/prices[/:currency]
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 29, 2026
***********************************************************************************************************************************************************************/
'use strict';
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
const mdl_PlanPrices = require('../../models/tiers/plan_prices.mdl');
const R = require('../../utils/response.util');
const logActivity = require('../../utils/logActivity.util');
const { isSupported, SUPPORTED_CURRENCIES, validateLocalizedPrice } = require('../../utils/currency.util');
// ─── GET /admin/tiers/:id/prices ─────────────────────────────────────────────
exports.getPrices = async (req, res) => {
try {
const plan = await mdl_TierPlans.findByPk(req.params.id);
if (!plan) return R.error(res, 'Plan not found.', 404);
const prices = await mdl_PlanPrices.findAll({
where: { plan_id: plan.plan_id },
order: [['currency', 'ASC']],
});
return R.success(res, 'Localized prices retrieved.', prices);
} catch (err) {
console.error('[ADMIN][GET PLAN PRICES]', err);
return R.error(res, 'Could not retrieve localized prices.', 500);
}
};
// ─── POST /admin/tiers/:id/prices ────────────────────────────────────────────
exports.addPrice = async (req, res) => {
try {
const plan = await mdl_TierPlans.findByPk(req.params.id);
if (!plan) return R.error(res, 'Plan not found.', 404);
const { currency, price } = req.body;
if (!currency || price === undefined) return R.error(res, 'currency and price are required.', 400);
if (!isSupported(currency)) return R.error(res, `Unsupported currency: ${currency}.`, 400);
if (currency === plan.currency) return R.error(res, `${currency} is already the plan's base currency.`, 400);
if (Number(price) < 0) return R.error(res, 'Price must be 0 or greater.', 400);
const exists = await mdl_PlanPrices.findOne({ where: { plan_id: plan.plan_id, currency } });
if (exists) return R.error(res, `A localized price for ${currency} already exists. Use PUT to update it.`, 409);
// ── Rate validation ────────────────────────────────────────────────────────
const validation = await validateLocalizedPrice(plan.price, plan.currency, price, currency);
if (validation.zone === 'block') return R.error(res, validation.message, 422);
const entry = await mdl_PlanPrices.create({
plan_id: plan.plan_id,
currency: currency.toUpperCase(),
price: Number(price),
});
logActivity(req.user?.user_id, 'add_plan_price', {
entityType: 'plan_price',
details: { plan_id: plan.plan_id, currency, price },
});
if (validation.zone === 'warn')
return res.status(201).json({ success: true, warning: true, message: validation.message, data: entry });
return R.success(res, 'Localized price added.', entry, 201);
} catch (err) {
console.error('[ADMIN][ADD PLAN PRICE]', err);
return R.error(res, 'Could not add localized price.', 500);
}
};
// ─── PUT /admin/tiers/:id/prices/:currency ───────────────────────────────────
exports.updatePrice = async (req, res) => {
try {
const { id, currency } = req.params;
const { price } = req.body;
if (price === undefined) return R.error(res, 'price is required.', 400);
if (Number(price) < 0) return R.error(res, 'Price must be 0 or greater.', 400);
const entry = await mdl_PlanPrices.findOne({ where: { plan_id: id, currency: currency.toUpperCase() } });
if (!entry) return R.error(res, `No localized price found for ${currency} on this plan.`, 404);
// ── Rate validation ────────────────────────────────────────────────────────
const plan = await mdl_TierPlans.findByPk(id);
const validation = await validateLocalizedPrice(plan.price, plan.currency, price, currency);
if (validation.zone === 'block') return R.error(res, validation.message, 422);
await entry.update({ price: Number(price) });
logActivity(req.user?.user_id, 'update_plan_price', {
entityType: 'plan_price',
details: { plan_id: id, currency, price },
});
if (validation.zone === 'warn')
return res.status(200).json({ success: true, warning: true, message: validation.message, data: entry });
return R.success(res, 'Localized price updated.', entry);
} catch (err) {
console.error('[ADMIN][UPDATE PLAN PRICE]', err);
return R.error(res, 'Could not update localized price.', 500);
}
};
// ─── DELETE /admin/tiers/:id/prices/:currency ────────────────────────────────
exports.removePrice = async (req, res) => {
try {
const { id, currency } = req.params;
const entry = await mdl_PlanPrices.findOne({ where: { plan_id: id, currency: currency.toUpperCase() } });
if (!entry) return R.error(res, `No localized price found for ${currency} on this plan.`, 404);
await entry.destroy();
logActivity(req.user?.user_id, 'remove_plan_price', {
entityType: 'plan_price',
details: { plan_id: id, currency },
});
return R.success(res, 'Localized price removed.');
} catch (err) {
console.error('[ADMIN][REMOVE PLAN PRICE]', err);
return R.error(res, 'Could not remove localized price.', 500);
}
};
// ─── GET /admin/currencies ────────────────────────────────────────────────────
exports.getCurrencies = async (_req, res) => {
return R.success(res, 'Supported currencies retrieved.', SUPPORTED_CURRENCIES);
};
+105 -7
View File
@@ -1,12 +1,13 @@
'use strict'; 'use strict';
const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl'); const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl');
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl'); const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
const mdl_PlanPolicies = require('../../models/tiers/plan_policies.mdl'); const mdl_PlanPolicies = require('../../models/tiers/plan_policies.mdl');
const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl'); const mdl_PaymentPolicies = require('../../models/tiers/payment_policies.mdl');
const Asset = require('../../models/assets/assets.mdl'); const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl');
const R = require('../../utils/response.util'); const Asset = require('../../models/assets/assets.mdl');
const logActivity = require('../../utils/logActivity.util'); const R = require('../../utils/response.util');
const logActivity = require('../../utils/logActivity.util');
require('../../models/tiers/tier.associations'); require('../../models/tiers/tier.associations');
@@ -104,6 +105,103 @@ exports.upsertPlanPolicy = async (req, res) => {
} }
}; };
// ─── PAYMENT POLICIES ─────────────────────────────────────────────────────────
const VALID_PROMO_TYPES = new Set(['flat', 'percent']);
const VALID_WINDOW_UNITS = new Set(['minutes', 'hours', 'days']);
function validatePromoRules(rules) {
if (!Array.isArray(rules)) return 'promo_rules must be an array.';
for (const r of rules) {
if (!r.code || typeof r.code !== 'string') return 'Each promo rule must have a code string.';
if (!VALID_PROMO_TYPES.has(r.type)) return `Invalid promo type "${r.type}". Must be 'flat' or 'percent'.`;
if (!r.value || Number(r.value) <= 0) return 'Promo rule value must be a positive number.';
if (r.max_uses != null && (!Number.isInteger(r.max_uses) || r.max_uses < 1))
return 'max_uses must be a positive integer.';
if (r.expires_at != null && isNaN(new Date(r.expires_at).getTime()))
return 'expires_at must be a valid ISO date string.';
if (r.min_amount != null && Number(r.min_amount) < 0)
return 'min_amount must be a non-negative number.';
}
return null;
}
function validateRefundPolicy(rp) {
if (typeof rp !== 'object' || rp === null || Array.isArray(rp))
return 'refund_policy must be an object.';
if (rp.allowed != null && typeof rp.allowed !== 'boolean')
return 'refund_policy.allowed must be a boolean.';
if (rp.window_unit != null && !VALID_WINDOW_UNITS.has(rp.window_unit))
return `refund_policy.window_unit must be 'minutes', 'hours', or 'days'.`;
if (rp.window_value != null && (typeof rp.window_value !== 'number' || rp.window_value <= 0))
return 'refund_policy.window_value must be a positive number.';
return null;
}
exports.getPaymentPolicy = async (req, res) => {
try {
const plan = await mdl_TierPlans.findByPk(req.params.planId);
if (!plan) return R.error(res, 'Plan not found.', 404);
const policy = await mdl_PaymentPolicies.findOne({ where: { plan_id: req.params.planId } });
return R.success(res, 'Payment policy retrieved.', policy ?? null);
} catch (err) {
console.error('[ADMIN][GET PAYMENT POLICY]', err);
return R.error(res, 'Could not retrieve payment policy.', 500);
}
};
exports.upsertPaymentPolicy = async (req, res) => {
try {
const { planId } = req.params;
const plan = await mdl_TierPlans.findByPk(planId);
if (!plan) return R.error(res, 'Plan not found.', 404);
const { promo_rules, refund_policy, allowed_providers } = req.body;
if (promo_rules !== undefined) {
const err = validatePromoRules(promo_rules);
if (err) return R.error(res, err, 400);
}
if (refund_policy !== undefined) {
const err = validateRefundPolicy(refund_policy);
if (err) return R.error(res, err, 400);
}
if (allowed_providers !== undefined) {
if (!Array.isArray(allowed_providers) || !allowed_providers.every((p) => typeof p === 'string'))
return R.error(res, 'allowed_providers must be an array of provider name strings.', 400);
}
let existing = await mdl_PaymentPolicies.findOne({ where: { plan_id: planId } });
const DEFAULTS = { allowed: true, window_value: 5, window_unit: 'minutes', reason_required: false };
const payload = {
plan_id: planId,
promo_rules: promo_rules !== undefined ? promo_rules : (existing?.promo_rules ?? []),
refund_policy: refund_policy !== undefined ? refund_policy : (existing?.refund_policy ?? DEFAULTS),
allowed_providers: allowed_providers !== undefined ? allowed_providers : (existing?.allowed_providers ?? ['paypal']),
};
if (!existing) {
existing = await mdl_PaymentPolicies.create(payload);
logActivity(req.user?.user_id, 'create_payment_policy', { entityType: 'payment_policy', details: { plan_id: planId } });
} else {
await existing.update(payload);
logActivity(req.user?.user_id, 'update_payment_policy', { entityType: 'payment_policy', details: { plan_id: planId } });
}
const result = await mdl_PaymentPolicies.findOne({ where: { plan_id: planId } });
return R.success(res, 'Payment policy saved.', result);
} catch (err) {
console.error('[ADMIN][UPSERT PAYMENT POLICY]', err);
return R.error(res, 'Could not save payment policy.', 500);
}
};
// ─── SYSTEM BADGES ──────────────────────────────────────────────────────────── // ─── SYSTEM BADGES ────────────────────────────────────────────────────────────
exports.getSystemBadges = async (req, res) => { exports.getSystemBadges = async (req, res) => {
+40 -5
View File
@@ -83,11 +83,18 @@ exports.getPlan = async (req, res) => {
} }
}; };
const DURATION_UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 };
function computeDurationDays(value, unit) {
const multiplier = DURATION_UNIT_TO_DAYS[unit] ?? 1;
return parseFloat(value) * multiplier;
}
exports.createPlan = async (req, res) => { exports.createPlan = async (req, res) => {
try { try {
const { tier_category_id, label, duration_days, price, currency } = req.body; const { tier_category_id, label, description, duration_value, duration_unit = 'day', price, currency } = req.body;
if (!tier_category_id || !label || !duration_days || !price) if (!tier_category_id || !label || !duration_value || !price)
return R.error(res, 'tier_category_id, label, duration_days, and price are required.', 400); return R.error(res, 'tier_category_id, label, duration_value, and price are required.', 400);
const category = await mdl_TierCategories.findByPk(tier_category_id); const category = await mdl_TierCategories.findByPk(tier_category_id);
if (!category || !category.is_active) if (!category || !category.is_active)
@@ -96,10 +103,12 @@ exports.createPlan = async (req, res) => {
if (category.is_default) if (category.is_default)
return R.error(res, 'Plans cannot be created under the default (Free) tier. Free access is automatic.', 400); return R.error(res, 'Plans cannot be created under the default (Free) tier. Free access is automatic.', 400);
const duration_days = computeDurationDays(duration_value, duration_unit);
const plan = await mdl_TierPlans.create({ const plan = await mdl_TierPlans.create({
tier_category_id: category.tier_category_id, tier_category_id: category.tier_category_id,
tier: category.slug, tier: category.slug,
label, duration_days, price, currency, label, description, duration_days, duration_unit, price, currency,
}); });
const plain = plan.get({ plain: true }); const plain = plan.get({ plain: true });
logActivity(req.user?.user_id, 'create_tier_plan', { entityType: 'tier_plan', details: { label: plain.label, tier: plain.tier } }); logActivity(req.user?.user_id, 'create_tier_plan', { entityType: 'tier_plan', details: { label: plain.label, tier: plain.tier } });
@@ -115,12 +124,22 @@ exports.updatePlan = async (req, res) => {
const plan = await mdl_TierPlans.findByPk(req.params.id); const plan = await mdl_TierPlans.findByPk(req.params.id);
if (!plan) return R.error(res, 'Plan not found.', 404); if (!plan) return R.error(res, 'Plan not found.', 404);
const allowed = ['label', 'duration_days', 'price', 'currency', 'is_active', 'tier_category_id']; const allowed = ['label', 'description', 'price', 'currency', 'is_active', 'tier_category_id'];
const updates = {}; const updates = {};
for (const k of allowed) { for (const k of allowed) {
if (req.body[k] !== undefined) updates[k] = req.body[k]; if (req.body[k] !== undefined) updates[k] = req.body[k];
} }
// Recompute duration_days when value or unit changes
const { duration_value, duration_unit } = req.body;
if (duration_value !== undefined) {
const unit = duration_unit ?? plan.duration_unit ?? 'day';
updates.duration_days = computeDurationDays(duration_value, unit);
updates.duration_unit = unit;
} else if (duration_unit !== undefined) {
updates.duration_unit = duration_unit;
}
// If tier_category_id is being changed, sync the tier slug // If tier_category_id is being changed, sync the tier slug
if (updates.tier_category_id) { if (updates.tier_category_id) {
const category = await mdl_TierCategories.findByPk(updates.tier_category_id); const category = await mdl_TierCategories.findByPk(updates.tier_category_id);
@@ -140,6 +159,22 @@ exports.updatePlan = async (req, res) => {
} }
}; };
exports.getPlanImpact = async (req, res) => {
try {
const plan = await mdl_TierPlans.findByPk(req.params.id);
if (!plan) return R.error(res, 'Plan not found.', 404);
const active_subscriber_count = await mdl_UserTiers.count({
where: { plan_id: req.params.id, status: 'active' },
});
return R.success(res, 'Plan impact retrieved.', { active_subscriber_count });
} catch (err) {
console.error('[ADMIN][GET PLAN IMPACT]', err);
return R.error(res, 'Could not retrieve plan impact.', 500);
}
};
exports.archivePlan = async (req, res) => { exports.archivePlan = async (req, res) => {
try { try {
const plan = await mdl_TierPlans.findByPk(req.params.id); const plan = await mdl_TierPlans.findByPk(req.params.id);
@@ -1,7 +1,7 @@
'use strict'; 'use strict';
const mdl_CoursePurchase = require('../../models/courses/course_purchases.mdl'); const mdl_CoursePurchase = require('../../models/courses/course_purchases.mdl');
const mdl_Product = require('../../models/courses/products.mdl'); const mdl_Product = require('../../models/courses/products.mdl');
const paypal = require('../../services/paypal.service'); const paymentSvc = require('../../services/payment.service');
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
// ─── CREATE ORDER ───────────────────────────────────────────────────────────── // ─── CREATE ORDER ─────────────────────────────────────────────────────────────
@@ -14,7 +14,6 @@ exports.createCourseOrder = async (req, res) => {
const product = await mdl_Product.findOne({ where: { id: product_id, is_active: true } }); const product = await mdl_Product.findOne({ where: { id: product_id, is_active: true } });
if (!product) return R.error(res, 'Product not found or inactive.', 404); if (!product) return R.error(res, 'Product not found or inactive.', 404);
// Block if user already has an active completed purchase for this product
const existing = await mdl_CoursePurchase.findOne({ const existing = await mdl_CoursePurchase.findOne({
where: { user_id: req.user.user_id, product_id, status: 'completed' }, where: { user_id: req.user.user_id, product_id, status: 'completed' },
}); });
@@ -23,7 +22,7 @@ exports.createCourseOrder = async (req, res) => {
if (stillActive) return R.error(res, 'You already have active access to this course.', 409); if (stillActive) return R.error(res, 'You already have active access to this course.', 409);
} }
const ppOrder = await paypal.createOrder({ const ppOrder = await paymentSvc.createOrder('paypal', {
amount: Number(product.price).toFixed(2), amount: Number(product.price).toFixed(2),
currency: product.currency, currency: product.currency,
referenceId: `user_${req.user.user_id}_product_${product_id}`, referenceId: `user_${req.user.user_id}_product_${product_id}`,
@@ -69,7 +68,7 @@ exports.captureCourseOrder = async (req, res) => {
if (!order_id) return R.error(res, 'order_id is required.', 400); if (!order_id) return R.error(res, 'order_id is required.', 400);
const purchase = await mdl_CoursePurchase.findOne({ const purchase = await mdl_CoursePurchase.findOne({
where: { user_id: req.user.user_id, status: 'pending', provider: 'paypal' }, where: { user_id: req.user.user_id, status: 'pending' },
include: [{ model: mdl_Product, as: 'product' }], include: [{ model: mdl_Product, as: 'product' }],
order: [['createdAt', 'DESC']], order: [['createdAt', 'DESC']],
}); });
@@ -79,13 +78,13 @@ exports.captureCourseOrder = async (req, res) => {
let captureData; let captureData;
try { try {
captureData = await paypal.captureOrder(order_id); captureData = await paymentSvc.captureOrder(purchase.provider, order_id);
} catch (ppErr) { } catch (ppErr) {
await purchase.update({ await purchase.update({
status: 'failed', status: 'failed',
provider_payload: { ...purchase.provider_payload, error: ppErr?.response?.data ?? {} }, provider_payload: { ...purchase.provider_payload, error: ppErr?.response?.data ?? {} },
}); });
return R.error(res, 'PayPal capture failed.', 402); return R.error(res, 'Payment capture failed.', 402);
} }
const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0]; const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0];
@@ -120,7 +119,7 @@ exports.cancelCourseOrder = async (req, res) => {
if (!order_id) return R.error(res, 'order_id is required.', 400); if (!order_id) return R.error(res, 'order_id is required.', 400);
const purchase = await mdl_CoursePurchase.findOne({ const purchase = await mdl_CoursePurchase.findOne({
where: { user_id: req.user.user_id, status: 'pending', provider: 'paypal' }, where: { user_id: req.user.user_id, status: 'pending' },
order: [['createdAt', 'DESC']], order: [['createdAt', 'DESC']],
}); });
+30 -2
View File
@@ -124,6 +124,7 @@ const COURSE_LIST_ATTRS = [
"course_id", "uuid", "title", "description", "course_id", "uuid", "title", "description",
"course_code", "level", "subscription", "course_code", "level", "subscription",
"duration_seconds", "order_index", "duration_seconds", "order_index",
"badge_color", "badge_asset_id", "badge_image_url",
]; ];
// Strip correct-answer data before sending quiz questions to the client. // Strip correct-answer data before sending quiz questions to the client.
@@ -149,6 +150,22 @@ async function getActiveTier(user_id) {
}); });
} }
// ─── COURSE CATEGORIES (public list for filter chips) ─────────────────────────
exports.getCategories = async (req, res) => {
try {
const rows = await mdl_Category.findAll({
where: { is_active: true },
attributes: ['id', 'name', 'slug'],
order: [['name', 'ASC']],
});
return R.success(res, 'Categories retrieved.', rows);
} catch (err) {
console.error('[CLIENT][COURSES][CATEGORIES]', err);
return R.error(res, 'Could not retrieve categories.', 500);
}
};
// ─── COURSES (all visible, is_locked per user tier) ─────────────────────────── // ─── COURSES (all visible, is_locked per user tier) ───────────────────────────
exports.getCourses = async (req, res) => { exports.getCourses = async (req, res) => {
@@ -276,6 +293,11 @@ exports.getCourse = async (req, res) => {
"is_required", "passing_score", "is_required", "passing_score",
"time_limit_minutes", "max_questions", "time_limit_minutes", "max_questions",
], ],
include: [{
model: QuizQuestion, as: "questions",
attributes: ["question_id"],
required: false,
}],
}, },
], ],
order: [ order: [
@@ -313,7 +335,13 @@ exports.getCourse = async (req, res) => {
where: { assessment_id: plain.assessment.assessment_id, user_id: req.user.user_id, passed: true }, where: { assessment_id: plain.assessment.assessment_id, user_id: req.user.user_id, passed: true },
}); });
is_completed = !!passedAttempt; is_completed = !!passedAttempt;
plain.assessment = { ...plain.assessment, has_passed: is_completed }; const questionCount = plain.assessment.questions?.length ?? 0;
plain.assessment = {
...plain.assessment,
has_passed: is_completed,
question_count: questionCount,
questions: undefined,
};
} }
plain.is_completed = is_completed; plain.is_completed = is_completed;
@@ -957,7 +985,7 @@ exports.getCourseByUuid = async (req, res) => {
const { uuid } = req.params; const { uuid } = req.params;
const course = await Course.findOne({ const course = await Course.findOne({
where: { uuid, ...notDeleted }, where: { uuid, ...notDeleted },
attributes: ["course_id", "uuid", "title", "description", "level", "subscription"], attributes: ["course_id", "uuid", "title", "description", "level", "subscription", "badge_color", "badge_asset_id", "badge_image_url"],
}); });
if (!course) return R.error(res, "Course not found.", 404); if (!course) return R.error(res, "Course not found.", 404);
+5
View File
@@ -122,6 +122,11 @@ function pipeRemoteStream(remoteUrl, req, res) {
// //
// S3 assets only — Chibisafe assets use their raw file_url directly. // S3 assets only — Chibisafe assets use their raw file_url directly.
// Returns: { token, provider: "s3", file_type } // Returns: { token, provider: "s3", file_type }
//
// TOKEN HITS: If a consumer (e.g. ClientNav badge) re-fetches unexpectedly,
// the fix lives on the frontend — not here. Use a useRef cache key by
// asset_id on the consumer side so this endpoint is called exactly once per
// asset per session. The 4h token TTL makes ref-caching safe within a session.
exports.issueToken = async (req, res) => { exports.issueToken = async (req, res) => {
try { try {
+17
View File
@@ -66,6 +66,23 @@ exports.updateProfile = async (req, res) => {
} }
}; };
// ─── PATCH preferred currency ──────────────────────────────────────────────────
exports.updateCurrency = async (req, res) => {
try {
const { currency } = req.body;
if (!currency || typeof currency !== 'string' || currency.length !== 3)
return R.error(res, 'A valid 3-letter ISO 4217 currency code is required.', 400);
const user = await mdl_Users.findByPk(req.user.user_id);
await user.update({ preferred_currency: currency.toUpperCase() });
return R.success(res, 'Currency preference updated.', { preferred_currency: user.preferred_currency });
} catch (err) {
console.error('[CLIENT] updateCurrency error:', err);
return R.error(res, 'Could not update currency preference.', 500);
}
};
// ─── GET own sessions ────────────────────────────────────────────────────────── // ─── GET own sessions ──────────────────────────────────────────────────────────
exports.getSessions = async (req, res) => { exports.getSessions = async (req, res) => {
@@ -36,7 +36,7 @@ const isMember = async (userId, groupId) => {
// Strips "{S3_PUBLIC_URL}/{S3_BUCKET}/" prefix, leaving e.g. "images/uuid.jpg" // Strips "{S3_PUBLIC_URL}/{S3_BUCKET}/" prefix, leaving e.g. "images/uuid.jpg"
const deriveStorageKey = (fileUrl) => { const deriveStorageKey = (fileUrl) => {
const publicUrl = (process.env.S3_PUBLIC_URL || '').replace(/\/$/, ''); const publicUrl = (process.env.S3_PUBLIC_URL || '').replace(/\/$/, '');
const bucket = process.env.S3_BUCKET || 'philproperties'; const bucket = process.env.S3_BUCKET;
const prefix = `${publicUrl}/${bucket}/`; const prefix = `${publicUrl}/${bucket}/`;
if (fileUrl && fileUrl.startsWith(prefix)) { if (fileUrl && fileUrl.startsWith(prefix)) {
+1 -1
View File
@@ -71,7 +71,7 @@ const resolveOwnerType = (mimetype = '') => {
// //
// Returns: // Returns:
// { // {
// file_url : "https://garage.philproperties.com/philproperties/documents/uuid.pdf", // file_url : "https://cdn.yourdomain.com/your-bucket/documents/uuid.pdf",
// file_name : "social_media_slides.pdf", // file_name : "social_media_slides.pdf",
// file_size : 2400000, // file_size : 2400000,
// mime_type : "application/pdf", // mime_type : "application/pdf",
+232 -181
View File
@@ -4,47 +4,29 @@
* Description: User-facing tier and payment endpoints. * Description: User-facing tier and payment endpoints.
* - View active tier + history * - View active tier + history
* - Browse active plans (with courses per plan) * - Browse active plans (with courses per plan)
* - PayPal redirect checkout (create order → capture → cancel) * - Promo code validation (server-side)
* - PayPal redirect checkout (create order → capture → cancel → refund)
* - View own payment history * - View own payment history
* Author: rgrgogu * Author: rgrgogu
* Date Created: Jun. 6, 2026 * Date Created: Jun. 6, 2026
* Modified: Jun. 9, 2026 * Modified: Jun. 29, 2026
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl'); const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl');
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl'); const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
const mdl_PlanPrices = require('../../models/tiers/plan_prices.mdl');
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl'); const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
const mdl_Payments = require('../../models/tiers/payments.mdl'); const mdl_Payments = require('../../models/tiers/payments.mdl');
const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl'); const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl');
const Asset = require('../../models/assets/assets.mdl'); const Asset = require('../../models/assets/assets.mdl');
const UserNotification = require('../../models/notifications/user_notification.mdl');
const { onTierActivated } = require('../../services/achievements.service'); const { onTierActivated } = require('../../services/achievements.service');
const { Course } = require('../../models/courses/courses.mdl'); const { Course } = require('../../models/courses/courses.mdl');
const paypal = require('../../services/paypal.service'); const paymentSvc = require('../../services/payment.service');
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
require('../../models/tiers/tier.associations'); require('../../models/tiers/tier.associations');
// ─── Promo codes ──────────────────────────────────────────────────────────────
const PROMO_CODES = {
PHIL10: 10, // $10 flat discount
};
const calculateCheckoutAmount = (price, promoCode) => {
const subtotalCents = Math.round(Number(price) * 100);
const normalizedCode = promoCode?.trim?.().toUpperCase?.() ?? null;
const discountCents = normalizedCode && PROMO_CODES[normalizedCode]
? Math.min(PROMO_CODES[normalizedCode] * 100, subtotalCents)
: 0;
const totalCents = Math.max(subtotalCents - discountCents, 0);
return {
promoCode: discountCents > 0 ? normalizedCode : null,
subtotal: (subtotalCents / 100).toFixed(2),
discount: (discountCents / 100).toFixed(2),
total: (totalCents / 100).toFixed(2),
};
};
// ─── MY TIER ────────────────────────────────────────────────────────────────── // ─── MY TIER ──────────────────────────────────────────────────────────────────
exports.getMyTier = async (req, res) => { exports.getMyTier = async (req, res) => {
@@ -59,27 +41,39 @@ exports.getMyTier = async (req, res) => {
model: mdl_TierCategories, model: mdl_TierCategories,
as: 'category', as: 'category',
required: false, required: false,
include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'file_url', 'display_name'], required: false }], include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'storage_provider', 'file_url', 'display_name'], required: false }],
}], }],
}], }],
order: [['createdAt', 'DESC']], order: [['createdAt', 'DESC']],
}); });
// ── Inline safety net: expire between cron ticks ──────────────────────────
if (tier && tier.expires_at && new Date(tier.expires_at) <= new Date()) {
await tier.update({ status: 'expired' });
UserNotification.create({
user_id: req.user.user_id,
...NOTIFICATION_REGISTRY.tier_expired.build({
tier: tier.tier,
label: tier.plan?.label ?? null,
}),
}).catch(() => {});
return R.success(res, 'Active tier retrieved.', {
tier: 'free', status: 'active', category: null, just_expired: true,
});
}
if (!tier) { if (!tier) {
// Free users with no user_tier row: look up free category badge
const freeCategory = await mdl_TierCategories.findOne({ const freeCategory = await mdl_TierCategories.findOne({
where: { slug: 'free' }, where: { slug: 'free' },
include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'file_url', 'display_name'], required: false }], include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'storage_provider', 'file_url', 'display_name'], required: false }],
}); });
return R.success(res, 'Active tier retrieved.', { tier: 'free', status: 'active', category: freeCategory ?? null }); return R.success(res, 'Active tier retrieved.', { tier: 'free', status: 'active', category: freeCategory ?? null });
} }
// Supplement with the tier category badge even when the user's tier slug doesn't come via a plan
// (e.g., manually granted tiers that only store a slug, not a plan_id)
if (!tier.plan?.category) { if (!tier.plan?.category) {
const category = await mdl_TierCategories.findOne({ const category = await mdl_TierCategories.findOne({
where: { slug: tier.tier }, where: { slug: tier.tier },
include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'file_url', 'display_name'], required: false }], include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'storage_provider', 'file_url', 'display_name'], required: false }],
}); });
const plain = tier.toJSON(); const plain = tier.toJSON();
plain.category = category?.toJSON() ?? null; plain.category = category?.toJSON() ?? null;
@@ -106,20 +100,26 @@ exports.getMyTierHistory = async (req, res) => {
} }
}; };
// ─── PLANS (with courses) ───────────────────────────────────────────────────── // ─── PLANS ────────────────────────────────────────────────────────────────────
exports.getPlans = async (req, res) => { exports.getPlans = async (req, res) => {
try { try {
const plans = await mdl_TierPlans.findAll({ const plans = await mdl_TierPlans.findAll({
where: { is_active: true }, order: [['tier', 'ASC'], ['duration_days', 'ASC']],
order: [['tier', 'ASC'], ['duration_days', 'ASC']], attributes: ['plan_id', 'tier', 'label', 'description', 'duration_days', 'duration_unit', 'price', 'currency', 'is_active'],
attributes: ['plan_id', 'tier', 'label', 'duration_days', 'price', 'currency'], include: [
include: [{ {
model: Course, model: Course,
as: 'courses', as: 'courses',
attributes: ['course_id', 'uuid', 'title', 'course_code', 'level', 'duration_seconds'], attributes: ['course_id', 'uuid', 'title', 'course_code', 'level', 'duration_seconds'],
through: { attributes: [] }, through: { attributes: [] },
}], },
{
model: mdl_PlanPrices,
as: 'prices',
attributes: ['currency', 'price'],
},
],
}); });
const result = plans.map((p) => { const result = plans.map((p) => {
@@ -135,57 +135,109 @@ exports.getPlans = async (req, res) => {
} }
}; };
// ─── PAYPAL CHECKOUT ────────────────────────────────────────────────────────── // ─── PROMO CODE VALIDATION ────────────────────────────────────────────────────
exports.validatePromo = async (req, res) => {
try {
const { plan_id, code, currency } = req.body;
if (!plan_id || !code) return R.error(res, 'plan_id and code are required.', 400);
const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } });
if (!plan) return R.error(res, 'Plan not found or inactive.', 404);
// Resolve localized price if a preferred currency was sent
let effectivePrice = null;
if (currency && currency !== plan.currency) {
const priceEntry = await mdl_PlanPrices.findOne({ where: { plan_id, currency } });
if (priceEntry) effectivePrice = priceEntry.price;
}
const policy = await paymentSvc.getPolicyForPlan(plan_id);
const result = await paymentSvc.evaluatePromo(policy, plan, code, effectivePrice);
return R.success(res, result.valid ? 'Promo code is valid.' : result.reason, result);
} catch (err) {
console.error('[CLIENT][VALIDATE PROMO]', err);
return R.error(res, 'Could not validate promo code.', 500);
}
};
// ─── CHECKOUT ─────────────────────────────────────────────────────────────────
exports.createOrder = async (req, res) => { exports.createOrder = async (req, res) => {
try { try {
const { plan_id, promo_code } = req.body; const { plan_id, promo_code, currency: requestedCurrency } = req.body;
if (!plan_id) return R.error(res, 'plan_id is required.', 400); if (!plan_id) return R.error(res, 'plan_id is required.', 400);
const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } }); const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } });
if (!plan) return R.error(res, 'Plan not found or inactive.', 404); if (!plan) return R.error(res, 'Plan not found or inactive.', 404);
const checkout = calculateCheckoutAmount(plan.price, promo_code); // Resolve localized price — falls back to plan base price when no override exists
if (Number(checkout.total) <= 0) let effectivePrice = Number(plan.price);
let effectiveCurrency = plan.currency;
if (requestedCurrency && requestedCurrency !== plan.currency) {
const priceEntry = await mdl_PlanPrices.findOne({ where: { plan_id, currency: requestedCurrency } });
if (priceEntry) {
effectivePrice = Number(priceEntry.price);
effectiveCurrency = priceEntry.currency;
}
}
const policy = await paymentSvc.getPolicyForPlan(plan_id);
let promoResult = { valid: false, code: null, discount: 0 };
if (promo_code) {
promoResult = await paymentSvc.evaluatePromo(policy, plan, promo_code, effectivePrice);
if (!promoResult.valid)
return R.error(res, promoResult.reason ?? 'Invalid promo code.', 400);
}
const subtotal = effectivePrice;
const discount = promoResult.discount ?? 0;
const total = Math.max(subtotal - discount, 0).toFixed(2);
if (Number(total) <= 0)
return R.error(res, 'PayPal checkout requires a payable amount.', 400); return R.error(res, 'PayPal checkout requires a payable amount.', 400);
const ppOrder = await paypal.createOrder({ const provider = (policy?.allowed_providers?.[0]) ?? 'paypal';
amount: checkout.total, const ppOrder = await paymentSvc.createOrder(provider, {
currency: plan.currency, amount: total,
currency: effectiveCurrency,
referenceId: `user_${req.user.user_id}_plan_${plan_id}`, referenceId: `user_${req.user.user_id}_plan_${plan_id}`,
}); });
// Extract PayPal approval URL from links array
const approvalUrl = ppOrder.links?.find((l) => l.rel === 'approve')?.href ?? null; const approvalUrl = ppOrder.links?.find((l) => l.rel === 'approve')?.href ?? null;
const payment = await mdl_Payments.create({ const payment = await mdl_Payments.create({
user_id: req.user.user_id, user_id: req.user.user_id,
plan_id, plan_id,
status: 'pending', status: 'pending',
amount: checkout.total, amount: total,
currency: plan.currency, currency: effectiveCurrency,
promo_code: checkout.promoCode, promo_code: promoResult.code,
discount: checkout.discount, discount: discount.toFixed(2),
provider: 'paypal', provider,
provider_payload: { provider_payload: {
order_id: ppOrder.id, order_id: ppOrder.id,
approval_url: approvalUrl, approval_url: approvalUrl,
checkout: { checkout: {
subtotal: checkout.subtotal, subtotal: subtotal.toFixed(2),
discount: checkout.discount, discount: discount.toFixed(2),
promo_code: checkout.promoCode, promo_code: promoResult.code,
base_price: Number(plan.price).toFixed(2),
base_currency: plan.currency,
}, },
}, },
}); });
return R.success(res, 'Order created.', { return R.success(res, 'Order created.', {
payment_id: payment.payment_id, payment_id: payment.payment_id,
order_id: ppOrder.id, order_id: ppOrder.id,
approval_url: approvalUrl, approval_url: approvalUrl,
amount: checkout.total, amount: total,
currency: plan.currency, currency: effectiveCurrency,
promo_code: checkout.promoCode, promo_code: promoResult.code,
discount: checkout.discount, discount: discount.toFixed(2),
}, 201); }, 201);
} catch (err) { } catch (err) {
console.error('[CLIENT][CREATE ORDER]', err); console.error('[CLIENT][CREATE ORDER]', err);
@@ -199,70 +251,75 @@ exports.captureOrder = async (req, res) => {
if (!order_id) return R.error(res, 'order_id is required.', 400); if (!order_id) return R.error(res, 'order_id is required.', 400);
const payment = await mdl_Payments.findOne({ const payment = await mdl_Payments.findOne({
where: { where: { status: 'pending', user_id: req.user.user_id },
status: 'pending',
provider: 'paypal',
user_id: req.user.user_id,
},
include: [{ model: mdl_TierPlans, as: 'plan' }], include: [{ model: mdl_TierPlans, as: 'plan' }],
order: [['createdAt', 'DESC']], order: [['createdAt', 'DESC']],
}); });
// Match by order_id inside provider_payload
if (!payment || payment.provider_payload?.order_id !== order_id) if (!payment || payment.provider_payload?.order_id !== order_id)
return R.error(res, 'Pending payment not found.', 404); return R.error(res, 'Pending payment not found.', 404);
let captureData; // Guard: plan was deactivated while user was on PayPal's approval page
try { if (!payment.plan?.is_active) {
captureData = await paypal.captureOrder(order_id);
} catch (ppErr) {
await payment.update({ await payment.update({
status: 'failed', status: 'cancelled',
provider_payload: { provider_payload: {
...payment.provider_payload, ...payment.provider_payload,
error: ppErr?.response?.data ?? {}, cancelled_at: new Date().toISOString(),
cancelled_by: 'system',
cancel_reason: 'plan_deactivated',
}, },
}); });
return R.error(res, 'PayPal capture failed.', 402); return R.error(res, 'This plan is no longer available. No payment was taken.', 409);
}
let captureData;
try {
captureData = await paymentSvc.captureOrder(payment.provider, order_id);
} catch (ppErr) {
await payment.update({
status: 'failed',
provider_payload: { ...payment.provider_payload, error: ppErr?.response?.data ?? {} },
});
return R.error(res, 'Payment capture failed.', 402);
} }
const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0]; const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0];
// Expire current active tier
await mdl_UserTiers.update( await mdl_UserTiers.update(
{ status: 'expired' }, { status: 'expired' },
{ where: { user_id: req.user.user_id, status: 'active' } } { where: { user_id: req.user.user_id, status: 'active' } }
); );
const startsAt = new Date(); const startsAt = new Date();
const expiresAt = new Date(startsAt.getTime() + payment.plan.duration_days * 86400000); const expiresAt = new Date(startsAt.getTime() + payment.plan.duration_days * 86400000);
const newTier = await mdl_UserTiers.create({ const newTier = await mdl_UserTiers.create({
user_id: req.user.user_id, user_id: req.user.user_id,
tier: payment.plan.tier, tier: payment.plan.tier,
plan_id: payment.plan_id, plan_id: payment.plan_id,
status: 'active', status: 'active',
starts_at: startsAt, starts_at: startsAt,
expires_at: expiresAt, expires_at: expiresAt,
granted_by: null, granted_by: null,
}); });
await payment.update({ await payment.update({
status: 'completed', status: 'completed',
tier_id: newTier.tier_id, tier_id: newTier.tier_id,
paid_at: new Date(), paid_at: new Date(),
provider_payload: { provider_payload: {
...payment.provider_payload, ...payment.provider_payload,
capture_id: capture?.id, capture_id: capture?.id,
payer_id: captureData.payer?.payer_id, payer_id: captureData.payer?.payer_id,
capture: captureData, capture: captureData,
}, },
}); });
// await grantAchievement(req.user.user_id, payment.plan.tier);
await onTierActivated(req.user.user_id, newTier.tier); await onTierActivated(req.user.user_id, newTier.tier);
return R.success(res, 'Payment successful. Tier activated.', { return R.success(res, 'Payment successful. Tier activated.', {
tier: newTier.tier, tier: newTier.tier,
expires_at: newTier.expires_at, expires_at: newTier.expires_at,
}); });
} catch (err) { } catch (err) {
@@ -277,7 +334,7 @@ exports.cancelOrder = async (req, res) => {
if (!order_id) return R.error(res, 'order_id is required.', 400); if (!order_id) return R.error(res, 'order_id is required.', 400);
const payment = await mdl_Payments.findOne({ const payment = await mdl_Payments.findOne({
where: { user_id: req.user.user_id, status: 'pending', provider: 'paypal' }, where: { user_id: req.user.user_id, status: 'pending' },
order: [['createdAt', 'DESC']], order: [['createdAt', 'DESC']],
}); });
@@ -285,7 +342,7 @@ exports.cancelOrder = async (req, res) => {
return R.error(res, 'Pending payment not found.', 404); return R.error(res, 'Pending payment not found.', 404);
await payment.update({ await payment.update({
status: 'cancelled', status: 'cancelled',
provider_payload: { provider_payload: {
...payment.provider_payload, ...payment.provider_payload,
cancelled_at: new Date().toISOString(), cancelled_at: new Date().toISOString(),
@@ -300,15 +357,87 @@ exports.cancelOrder = async (req, res) => {
} }
}; };
exports.refundOrder = async (req, res) => {
try {
const user_id = req.user.user_id;
const activeTier = await mdl_UserTiers.findOne({
where: { user_id, status: 'active' },
order: [['createdAt', 'DESC']],
});
if (!activeTier) return R.error(res, 'No active tier to refund.', 404);
const payment = await mdl_Payments.findOne({
where: { user_id, tier_id: activeTier.tier_id, status: 'completed' },
order: [['paid_at', 'DESC']],
});
if (!payment) return R.error(res, 'No completed payment found for this tier.', 404);
// Load plan's payment policy to get the configured refund window
const policy = await paymentSvc.getPolicyForPlan(payment.plan_id);
if (!paymentSvc.isRefundAllowed(policy))
return R.error(res, 'Refunds are not available for this plan.', 403);
const windowMs = paymentSvc.getRefundWindowMs(policy);
if (!payment.paid_at || Date.now() - new Date(payment.paid_at).getTime() > windowMs) {
const rp = policy?.refund_policy ?? {};
const label = `${rp.window_value ?? 5} ${rp.window_unit ?? 'minutes'}`;
return R.error(res, `Refund window has expired. Refunds are only available within ${label} of payment.`, 403);
}
const captureId = payment.provider_payload?.capture_id;
if (!captureId) return R.error(res, 'Capture ID not found. Cannot process refund.', 400);
let refundData;
try {
refundData = await paymentSvc.refundCapture(payment.provider, captureId, payment.amount, payment.currency);
} catch (ppErr) {
console.error('[CLIENT][REFUND] provider error:', ppErr?.response?.data);
return R.error(res, 'Refund failed. Please try again.', 402);
}
await payment.update({
status: 'refunded',
provider_payload: {
...payment.provider_payload,
refund: refundData,
refunded_at: new Date().toISOString(),
},
});
const now = new Date();
await activeTier.update({ status: 'revoked', expires_at: now, revoked_at: now });
await mdl_UserTiers.create({
user_id,
tier: 'free',
status: 'active',
starts_at: now,
expires_at: null,
granted_by: null,
notes: 'Auto-downgrade after refund.',
});
return R.success(res, 'Refund processed successfully. Your access has been revoked.', {
refund_id: refundData.id,
status: refundData.status,
});
} catch (err) {
console.error('[CLIENT][REFUND]', err);
return R.error(res, 'Could not process refund.', 500);
}
};
// ─── MY PAYMENTS ────────────────────────────────────────────────────────────── // ─── MY PAYMENTS ──────────────────────────────────────────────────────────────
exports.getMyPayments = async (req, res) => { exports.getMyPayments = async (req, res) => {
try { try {
const payments = await mdl_Payments.findAll({ const payments = await mdl_Payments.findAll({
where: { user_id: req.user.user_id }, where: { user_id: req.user.user_id },
include: [{ model: mdl_TierPlans, as: 'plan', attributes: ['label', 'tier', 'duration_days'] }], include: [{ model: mdl_TierPlans, as: 'plan', attributes: ['label', 'tier', 'duration_days'] }],
attributes: { exclude: ['provider_payload'] }, attributes: { exclude: ['provider_payload'] },
order: [['createdAt', 'DESC']], order: [['createdAt', 'DESC']],
}); });
return R.success(res, 'Payment history retrieved.', payments); return R.success(res, 'Payment history retrieved.', payments);
} catch (err) { } catch (err) {
@@ -317,14 +446,14 @@ exports.getMyPayments = async (req, res) => {
} }
}; };
// ─── SYSTEM BADGES (read-only for client profile) ──────────────────────────── // ─── TIER CATEGORIES + SYSTEM BADGES ─────────────────────────────────────────
exports.getCategories = async (req, res) => { exports.getCategories = async (req, res) => {
try { try {
const categories = await mdl_TierCategories.findAll({ const categories = await mdl_TierCategories.findAll({
where: { is_active: true }, where: { is_active: true },
attributes: ['tier_category_id', 'slug', 'name', 'rank', 'color', 'badge_label', 'is_default'], attributes: ['tier_category_id', 'slug', 'name', 'rank', 'color', 'badge_icon', 'badge_label', 'is_default'],
include: [{ model: Asset, as: 'badgeAsset', attributes: ['file_url', 'display_name'], required: false }], include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'storage_provider', 'file_url', 'display_name'], required: false }],
order: [['rank', 'ASC']], order: [['rank', 'ASC']],
}); });
return R.success(res, 'Tier categories retrieved.', categories); return R.success(res, 'Tier categories retrieved.', categories);
@@ -347,81 +476,3 @@ exports.getSystemBadges = async (req, res) => {
return R.error(res, 'Could not retrieve system badges.', 500); return R.error(res, 'Could not retrieve system badges.', 500);
} }
}; };
// — add refundOrder export ────────────────
const REFUND_WINDOW_MS = 5 * 60 * 1000; // 5 minutes from paid_at
exports.refundOrder = async (req, res) => {
try {
const user_id = req.user.user_id;
// Get the active tier
const activeTier = await mdl_UserTiers.findOne({
where: { user_id, status: 'active' },
order: [['createdAt', 'DESC']],
});
if (!activeTier) return R.error(res, 'No active tier to refund.', 404);
// Get the completed payment for this tier
const payment = await mdl_Payments.findOne({
where: { user_id, tier_id: activeTier.tier_id, status: 'completed' },
order: [['paid_at', 'DESC']],
});
if (!payment) return R.error(res, 'No completed payment found for this tier.', 404);
// Enforce 5-minute refund window
if (!payment.paid_at || Date.now() - new Date(payment.paid_at).getTime() > REFUND_WINDOW_MS)
return R.error(res, 'Refund window has expired. Refunds are only available within 5 minutes of payment.', 403);
// Get capture_id from provider_payload
const captureId = payment.provider_payload?.capture_id;
if (!captureId) return R.error(res, 'Capture ID not found. Cannot process refund.', 400);
// Call PayPal refund API
let refundData;
try {
refundData = await paypal.refundCapture(captureId, payment.amount, payment.currency);
} catch (ppErr) {
console.error('[CLIENT][REFUND] PayPal error:', ppErr?.response?.data);
return R.error(res, 'PayPal refund failed. Please try again.', 402);
}
// Update payment status to refunded
await payment.update({
status: 'refunded',
provider_payload: {
...payment.provider_payload,
refund: refundData,
refunded_at: new Date().toISOString(),
},
});
// Immediately terminate access — cut expires_at to now and revoke
const now = new Date();
await activeTier.update({
status: 'revoked',
expires_at: now,
revoked_at: now,
});
// Drop user back to free immediately
await mdl_UserTiers.create({
user_id,
tier: 'free',
status: 'active',
starts_at: now,
expires_at: null,
granted_by: null,
notes: 'Auto-downgrade after refund.',
});
return R.success(res, 'Refund processed successfully. Your access has been revoked.', {
refund_id: refundData.id,
status: refundData.status,
});
} catch (err) {
console.error('[CLIENT][REFUND]', err);
return R.error(res, 'Could not process refund.', 500);
}
};
+3
View File
@@ -8,6 +8,7 @@
* Currently registered: * Currently registered:
* - userNotifications (cron/jobs/user_notifications.cron.js) * - userNotifications (cron/jobs/user_notifications.cron.js)
* - issueCertificates (cron/jobs/issue_certificates.cron.js) * - issueCertificates (cron/jobs/issue_certificates.cron.js)
* - expireUserTiers (cron/jobs/expire_user_tiers.cron.js)
* *
* Author: Kenneth Obsequio (@lash0000) * Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 17, 2026 * Date Created: Jun. 17, 2026
@@ -15,11 +16,13 @@
const cron = require('node-cron'); const cron = require('node-cron');
const userNotifications = require('./jobs/user_notifications.cron'); const userNotifications = require('./jobs/user_notifications.cron');
const issueCertificates = require('./jobs/issue_certificates.cron'); const issueCertificates = require('./jobs/issue_certificates.cron');
const expireUserTiers = require('./jobs/expire_user_tiers.cron');
// ─── Registry — add future client-side cron jobs here ──────────────────────── // ─── Registry — add future client-side cron jobs here ────────────────────────
const jobs = [ const jobs = [
userNotifications, userNotifications,
issueCertificates, issueCertificates,
expireUserTiers,
]; ];
// ─── Boot all registered client-side jobs ───────────────────────────────────── // ─── Boot all registered client-side jobs ─────────────────────────────────────
+95
View File
@@ -0,0 +1,95 @@
/***********************************************************************************************************************************************************************
* File Name : expire_user_tiers.cron.js
* Type : Cron Job
* Description : Marks active user_tiers rows as 'expired' when their expires_at
* has passed. Runs every minute to support short-duration plans
* (minute- and hour-level plans in addition to day/month/year).
*
* For each expired batch it:
* 1. Bulk-updates matching rows to status = 'expired'.
* 2. Sends an in-app UserNotification to each affected user.
*
* Safety:
* - Only touches rows with expires_at IS NOT NULL so
* manually-granted unlimited tiers (expires_at = NULL) are
* never touched.
* - Bulk update happens before notifications so a restart
* mid-run never re-expires already-expired rows.
*
* Schedule : Every minute ("* * * * *"). Registered by cron/client.cron.js.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 29, 2026
***********************************************************************************************************************************************************************/
'use strict';
const { Op } = require('sequelize');
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
const UserNotification = require('../../models/notifications/user_notification.mdl');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
require('../../models/tiers/tier.associations');
async function run() {
// ── 1. Find all active tiers whose expires_at has passed ──────────────────
let expired;
try {
expired = await mdl_UserTiers.findAll({
where: {
status: 'active',
expires_at: { [Op.ne]: null, [Op.lte]: new Date() },
},
include: [{
model: mdl_TierPlans,
as: 'plan',
attributes: ['label', 'tier'],
required: false,
}],
attributes: ['tier_id', 'user_id', 'tier'],
});
} catch (err) {
console.error('[CRON][EXPIRE TIERS] Failed to query user_tiers:', err);
return;
}
if (!expired.length) return;
const tierIds = expired.map((t) => t.tier_id);
// ── 2. Bulk-update to expired ──────────────────────────────────────────────
try {
await mdl_UserTiers.update(
{ status: 'expired' },
{ where: { tier_id: tierIds } }
);
} catch (err) {
console.error('[CRON][EXPIRE TIERS] Bulk update failed:', err);
return;
}
// ── 3. Send in-app notifications (one per affected user) ──────────────────
const notifications = expired.map((t) =>
NOTIFICATION_REGISTRY.tier_expired.build({
tier: t.tier,
label: t.plan?.label ?? null,
})
).map((payload, i) => ({
user_id: expired[i].user_id,
...payload,
}));
try {
await UserNotification.bulkCreate(notifications, { ignoreDuplicates: true });
} catch (err) {
console.error('[CRON][EXPIRE TIERS] Notification bulkCreate failed:', err);
}
console.log(`[CRON][EXPIRE TIERS] Expired ${expired.length} tier(s) for ${new Set(expired.map((t) => t.user_id)).size} user(s).`);
}
module.exports = {
name: 'expireUserTiers',
schedule: '* * * * *',
run,
};
+16 -1
View File
@@ -22,7 +22,7 @@
* Admin : task_overdue, user_registration, nogrp_user_registered * Admin : task_overdue, user_registration, nogrp_user_registered
* User : user_task_overdue, achievement, course_unlocked, * User : user_task_overdue, achievement, course_unlocked,
* course_completed, certificate_issued, task_reminder, announcement, * course_completed, certificate_issued, task_reminder, announcement,
* nogrp_welcome * nogrp_welcome, tier_expired
* *
* Author: Kenneth Obsequio (@lash0000) * Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 19, 2026 * Date Created: Jun. 19, 2026
@@ -239,6 +239,21 @@ const NOTIFICATION_REGISTRY = {
}, },
}, },
// ── Tier ──────────────────────────────────────────────────────────────────
tier_expired: {
type: 'tier_expired',
scope: 'user',
trigger: 'cron',
build({ tier, label }) {
return {
type: 'tier_expired',
title: 'Subscription Expired',
message: `Your ${label ?? tier} plan has expired. Renew to keep access.`,
data: { tier, label },
};
},
},
}; };
module.exports = { NOTIFICATION_REGISTRY }; module.exports = { NOTIFICATION_REGISTRY };
@@ -0,0 +1,15 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('tier_plans', 'description', {
type: Sequelize.TEXT,
allowNull: true,
after: 'label',
});
},
async down(queryInterface) {
await queryInterface.removeColumn('tier_plans', 'description');
},
};
@@ -0,0 +1,47 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('payment_policies', {
policy_id: {
type: Sequelize.BIGINT,
primaryKey: true,
autoIncrement: true,
},
plan_id: {
type: Sequelize.BIGINT,
allowNull: true,
unique: true,
references: { model: 'tier_plans', key: 'plan_id' },
onDelete: 'CASCADE',
},
// Array of { code, type:'flat'|'percent', value, currency?, max_discount?,
// max_uses?, expires_at?, min_amount? }
promo_rules: {
type: Sequelize.JSONB,
allowNull: false,
defaultValue: [],
},
// { allowed, window_value, window_unit:'minutes'|'hours'|'days', reason_required }
refund_policy: {
type: Sequelize.JSONB,
allowNull: false,
defaultValue: { allowed: true, window_value: 5, window_unit: 'minutes', reason_required: false },
},
// e.g. ["paypal"]
allowed_providers: {
type: Sequelize.JSONB,
allowNull: false,
defaultValue: ['paypal'],
},
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex('payment_policies', ['plan_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('payment_policies');
},
};
@@ -0,0 +1,23 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('tier_plans', 'duration_unit', {
type: Sequelize.STRING(10),
allowNull: false,
defaultValue: 'day',
});
await queryInterface.changeColumn('tier_plans', 'duration_days', {
type: Sequelize.FLOAT,
allowNull: false,
});
},
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn('tier_plans', 'duration_unit');
await queryInterface.changeColumn('tier_plans', 'duration_days', {
type: Sequelize.INTEGER,
allowNull: false,
});
},
};
@@ -0,0 +1,15 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('users', 'preferred_currency', {
type: Sequelize.CHAR(3),
allowNull: false,
defaultValue: 'USD',
});
},
async down(queryInterface) {
await queryInterface.removeColumn('users', 'preferred_currency');
},
};
@@ -0,0 +1,34 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('plan_prices', {
price_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
plan_id: {
type: Sequelize.BIGINT,
allowNull: false,
references: { model: 'tier_plans', key: 'plan_id' },
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
currency: { type: Sequelize.CHAR(3), allowNull: false },
price: { type: Sequelize.DECIMAL(10, 2), allowNull: false },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addConstraint('plan_prices', {
fields: ['plan_id', 'currency'],
type: 'unique',
name: 'uq_plan_prices_plan_currency',
});
await queryInterface.addIndex('plan_prices', ['plan_id'], {
name: 'idx_plan_prices_plan_id',
});
},
async down(queryInterface) {
await queryInterface.dropTable('plan_prices');
},
};
@@ -0,0 +1,25 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('course_achievements', {
id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: Sequelize.BIGINT, allowNull: false },
achievement_key: { type: Sequelize.STRING(100), allowNull: false },
order_index: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex('course_achievements', ['course_id']);
await queryInterface.addConstraint('course_achievements', {
fields: ['course_id', 'achievement_key'],
type: 'unique',
name: 'course_achievements_course_id_key_unique',
});
},
async down(queryInterface) {
await queryInterface.dropTable('course_achievements');
},
};
+30
View File
@@ -39,6 +39,8 @@ services:
condition: service_healthy condition: service_healthy
valkey: valkey:
condition: service_started condition: service_started
garage:
condition: service_healthy
restart: unless-stopped restart: unless-stopped
# ── Frontend (React / Nginx) ───────────────────────────────────────────────── # ── Frontend (React / Nginx) ─────────────────────────────────────────────────
@@ -73,6 +75,34 @@ services:
- valkey_data:/data - valkey_data:/data
restart: unless-stopped restart: unless-stopped
# ── Garage (S3-compatible self-hosted storage) ────────────────────────────────
# Exposes the S3 API on port 3900. Point your reverse proxy at this port
# to serve S3_PUBLIC_URL (e.g. https://cdn.yourdomain.com → http://garage:3900).
# GARAGE_RPC_SECRET must be set in .env (generate with: openssl rand -hex 32).
#
# garage-init.sh starts the daemon in the background, runs one-time cluster
# initialization (layout, bucket, key), then hands control back to the daemon.
garage:
image: dxflrs/garage
entrypoint: ["/bin/sh", "/garage-init.sh"]
volumes:
- ./config/garage.toml:/etc/garage.toml:ro
- ./scripts/garage-init.sh:/garage-init.sh:ro
- garage_meta:/var/lib/garage/meta
- garage_data:/var/lib/garage/data
ports:
- "3900:3900"
env_file: .env
healthcheck:
test: ["CMD-SHELL", "test -f /var/lib/garage/meta/.ready && garage status > /dev/null 2>&1"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
restart: unless-stopped
volumes: volumes:
postgres_data: postgres_data:
valkey_data: valkey_data:
garage_meta:
garage_data:
+66 -24
View File
@@ -1,37 +1,59 @@
/*********************************************************************************************************************************************************************** /***********************************************************************************************************************************************************************
* File Name: originGuard.middleware.js * File Name: originGuard.middleware.js
* Type of Program: Middleware * Type of Program: Middleware
* Description: Two-layer server-side guard that blocks non-browser clients from reaching any API route. * Description: Three-layer server-side guard that blocks non-browser clients from reaching any API route.
* *
* ── Layer 1 — Sec-Fetch-Site (ALL methods including GET) ──────────────────────────────────────── * ── Layer 1 — Fetch Metadata family (ALL methods including GET) ─────────────────────────────────
* *
* Browsers (Chrome 76+, Firefox 90+, Safari 16.4+) automatically attach the Sec-Fetch-Site * 1a) Sec-Fetch-Site presence + value
* header on every request. It is a forbidden request header — JavaScript cannot set, override, * Browsers (Chrome 76+, Firefox 90+, Safari 16.4+) automatically attach Sec-Fetch-Site on
* or remove it. Its absence is a reliable, low-spoofability signal that the request originated * every request. It is a forbidden request header — JavaScript cannot set, override, or
* from a tool rather than a real browser. * remove it. Its absence reliably signals a non-browser client. The value cross-site is
* also rejected; only same-origin, same-site, and none (direct navigation) are accepted.
* *
* Tools blocked by this layer (default configurations): * Tools blocked (default configurations):
* ✓ Metasploit (Rex HTTP client) — no Sec-Fetch-Site * ✓ Metasploit (Rex HTTP client) — no Sec-Fetch-Site
* ✓ BurpSuite Repeater / Scanner — no Sec-Fetch-Site * ✓ BurpSuite Repeater / Scanner — no Sec-Fetch-Site
* ✓ Postman — no Sec-Fetch-Site * ✓ Postman — no Sec-Fetch-Site
* ✓ curl / wget / httpie / python-requests — no Sec-Fetch-Site * ✓ curl / wget / httpie / python-requests — no Sec-Fetch-Site
* ✓ Nikto — no Sec-Fetch-Site * ✓ Nikto — no Sec-Fetch-Site
* ✓ sqlmap — no Sec-Fetch-Site * ✓ sqlmap — no Sec-Fetch-Site
* ✓ dirb / gobuster / feroxbuster — no Sec-Fetch-Site * ✓ dirb / gobuster / feroxbuster — no Sec-Fetch-Site
* ✓ nmap HTTP scripts — no Sec-Fetch-Site * ✓ nmap HTTP scripts — no Sec-Fetch-Site
* *
* ── Layer 2 — Origin allowlist (POST / PUT / PATCH / DELETE only) ─────────────────────────────── * 1b) Sec-Fetch-Mode + Sec-Fetch-Dest presence + valid combination
* Browsers that send Sec-Fetch-Site always send Mode and Dest too (Chrome 80+,
* Firefox 90+, Safari 16.4+). Missing headers or impossible combinations signal
* manual header injection. Only combinations expected on an API server are allowed:
* cors|empty — standard fetch() call from a cross-origin SPA
* same-origin|empty — same-origin fetch()
* navigate|document — direct browser navigation to an API URL
*
* Additional tools blocked:
* ✓ Scripts that fake only Sec-Fetch-Site — missing Mode or Dest
* ✓ Scripts with wrong Mode+Dest combos — no-cors, cors+document, etc.
*
* ── Layer 2 — Browser presence signals (ALL methods including GET) ─────────────────────────────
*
* At least one browser-native header must be present:
* Sec-CH-UA — Chromium client hint, forbidden in non-browser contexts
* Accept-Language — sent by all browsers (Chrome, Firefox, Safari)
* Absence of both is a strong automation signal that catches tools sophisticated enough to
* replicate the Sec-Fetch-* family but not the full browser header profile.
*
* ── Layer 3 — Origin allowlist (POST / PUT / PATCH / DELETE only) ──────────────────────────────
* *
* State-mutating requests must also carry an Origin header that matches ALLOWED_ORIGINS. * State-mutating requests must also carry an Origin header that matches ALLOWED_ORIGINS.
* Stops credential-stuffing and cross-origin mutation attempts from unlisted domains, * Stops credential-stuffing and cross-origin mutation attempts from unlisted domains,
* even if an attacker managed to set Sec-Fetch-Site manually. * even if an attacker replicated all browser headers above.
* *
* ── What this does NOT stop ────────────────────────────────────────────────────────────────────── * ── What this does NOT stop ──────────────────────────────────────────────────────────────────────
* *
* ✗ BurpSuite running as MITM proxy through a real browser session. * ✗ BurpSuite running as MITM proxy through a real browser session.
* The browser supplies all correct headers — requests are indistinguishable from * ✗ Playwright / Puppeteer / Selenium controlling a real browser — they produce all correct
* legitimate traffic. The only defences here are rate limiting and valid credentials. * Sec-Fetch-* headers, Sec-CH-UA, and Accept-Language automatically.
* ✗ A determined attacker who manually replicates all browser headers in their tool. * ✗ A determined attacker who manually replicates all required headers.
* The only defences at that point are rate limiting and valid credentials.
* *
* ── Browser compatibility note ─────────────────────────────────────────────────────────────────── * ── Browser compatibility note ───────────────────────────────────────────────────────────────────
* *
@@ -41,7 +63,7 @@
* *
* Author: Kenneth Obsequio (@lash0000) * Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 20, 2026 * Date Created: Jun. 20, 2026
* Date Modified: Jun. 20, 2026 * Date Modified: Jun. 29, 2026
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
"use strict"; "use strict";
@@ -54,6 +76,13 @@ const ALLOWED = (process.env.ALLOWED_ORIGINS || process.env.APP_URL || '')
const MUTATION_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); const MUTATION_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
// Valid Sec-Fetch-Mode + Sec-Fetch-Dest combinations expected on this API server.
const VALID_FETCH_COMBOS = new Set([
'cors|empty', // standard fetch() from cross-origin SPA
'same-origin|empty', // same-origin fetch()
'navigate|document', // direct browser navigation to an API URL
]);
module.exports = function originGuard(req, res, next) { module.exports = function originGuard(req, res, next) {
// Dev bypass: set ORIGIN_GUARD_DISABLED=true in .env to allow Postman/curl through. // Dev bypass: set ORIGIN_GUARD_DISABLED=true in .env to allow Postman/curl through.
// Hardcoded production lock — never bypassed even if the flag is accidentally set. // Hardcoded production lock — never bypassed even if the flag is accidentally set.
@@ -63,12 +92,25 @@ module.exports = function originGuard(req, res, next) {
if (req.method === 'OPTIONS') return next(); // preflight — handled by cors() if (req.method === 'OPTIONS') return next(); // preflight — handled by cors()
// ── Layer 1: Sec-Fetch-Site must be present (covers GET scanning) ───────── // ── Layer 1a: Sec-Fetch-Site must be present and not cross-site ───────────
if (!req.headers['sec-fetch-site']) { const fetchSite = req.headers['sec-fetch-site'];
if (!fetchSite || fetchSite === 'cross-site') {
return R.error(res, 'Forbidden.', 403); return R.error(res, 'Forbidden.', 403);
} }
// ── Layer 2: Origin must be in allowlist for state-changing requests ────── // ── Layer 1b: Fetch Metadata family must be complete and form a valid combo ─
const fetchMode = req.headers['sec-fetch-mode'];
const fetchDest = req.headers['sec-fetch-dest'];
if (!fetchMode || !fetchDest || !VALID_FETCH_COMBOS.has(`${fetchMode}|${fetchDest}`)) {
return R.error(res, 'Forbidden.', 403);
}
// ── Layer 2: At least one browser-native fingerprint header must be present ─
if (!req.headers['sec-ch-ua'] && !req.headers['accept-language']) {
return R.error(res, 'Forbidden.', 403);
}
// ── Layer 3: Origin must be in allowlist for state-changing requests ───────
if (MUTATION_METHODS.has(req.method)) { if (MUTATION_METHODS.has(req.method)) {
const origin = req.headers['origin']; const origin = req.headers['origin'];
if (!origin || !ALLOWED.includes(origin)) { if (!origin || !ALLOWED.includes(origin)) {
+2 -1
View File
@@ -80,10 +80,11 @@ const sensitiveOpsLimiter = rateLimit({
message: { status: 'error', message: 'Too many sensitive operations. Please wait 1 hour.' }, message: { status: 'error', message: 'Too many sensitive operations. Please wait 1 hour.' },
}); });
/** Admin routes — tighter than global */ /** Admin routes — per authenticated user, not per IP */
const adminLimiter = rateLimit({ const adminLimiter = rateLimit({
windowMs: windowMs15, windowMs: windowMs15,
max: 200, max: 200,
keyGenerator: (req) => req.user?.user_id?.toString() ?? req.ip,
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
store: makeStore('admin'), store: makeStore('admin'),
+16
View File
@@ -0,0 +1,16 @@
'use strict';
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const CourseAchievement = sequelize.define('CourseAchievement', {
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: DataTypes.BIGINT, allowNull: false },
achievement_key: { type: DataTypes.STRING(100), allowNull: false },
order_index: { type: DataTypes.INTEGER, defaultValue: 0 },
}, {
tableName: 'course_achievements',
timestamps: true,
});
module.exports = CourseAchievement;
+5 -1
View File
@@ -21,6 +21,7 @@ const CourseInstructor = require("./course_instructor.mdl");
const CourseReadingProgress = require("./course_reading_progress.mdl"); const CourseReadingProgress = require("./course_reading_progress.mdl");
const UnitReadingProgress = require("./unit_reading_progress.mdl"); const UnitReadingProgress = require("./unit_reading_progress.mdl");
const LessonReadingProgress = require("./lesson_reading_progress.mdl"); const LessonReadingProgress = require("./lesson_reading_progress.mdl");
const CourseAchievement = require("./course_achievement.mdl");
// ── CourseReadingProgress ───────────────────────────────────────────────────── // ── CourseReadingProgress ─────────────────────────────────────────────────────
CourseReadingProgress.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' }); CourseReadingProgress.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' });
@@ -51,9 +52,11 @@ Course.hasMany(CourseObjective, { as: "objectives", foreignKey: "course_id" });
Course.hasMany(CoursePrerequisite, { as: "prerequisites", foreignKey: "course_id" }); Course.hasMany(CoursePrerequisite, { as: "prerequisites", foreignKey: "course_id" });
Course.hasOne(CourseAssessment, { as: "assessment", foreignKey: "course_id" }); Course.hasOne(CourseAssessment, { as: "assessment", foreignKey: "course_id" });
Course.hasMany(Certificate, { as: "certificates", foreignKey: "course_id" }); Course.hasMany(Certificate, { as: "certificates", foreignKey: "course_id" });
Course.hasMany(CourseInstructor, { as: "instructors", foreignKey: "course_id" }); Course.hasMany(CourseInstructor, { as: "instructors", foreignKey: "course_id" });
CourseInstructor.belongsTo(Course, { as: "course", foreignKey: "course_id" }); CourseInstructor.belongsTo(Course, { as: "course", foreignKey: "course_id" });
CourseInstructor.belongsTo(mdl_Users, { as: "user", foreignKey: "user_id" }); CourseInstructor.belongsTo(mdl_Users, { as: "user", foreignKey: "user_id" });
Course.hasMany(CourseAchievement, { as: "courseAchievements", foreignKey: "course_id" });
CourseAchievement.belongsTo(Course, { as: "course", foreignKey: "course_id" });
Course.belongsToMany(mdl_Category, { through: CourseProductCategory, foreignKey: 'course_id', otherKey: 'category_id', as: 'categories' }); Course.belongsToMany(mdl_Category, { through: CourseProductCategory, foreignKey: 'course_id', otherKey: 'category_id', as: 'categories' });
mdl_Category.belongsToMany(Course, { through: CourseProductCategory, foreignKey: 'category_id', otherKey: 'course_id', as: 'courses' }); mdl_Category.belongsToMany(Course, { through: CourseProductCategory, foreignKey: 'category_id', otherKey: 'course_id', as: 'courses' });
@@ -117,4 +120,5 @@ module.exports = {
AssessmentSession, QuizSession, AssessmentSession, QuizSession,
mdl_Category, Certificate, CourseInstructor, mdl_Category, Certificate, CourseInstructor,
CourseReadingProgress, UnitReadingProgress, LessonReadingProgress, CourseReadingProgress, UnitReadingProgress, LessonReadingProgress,
CourseAchievement,
}; };
+4 -1
View File
@@ -10,7 +10,10 @@ const Course = sequelize.define("Course", {
order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, hidden: false, order: 7, filterable: false }, order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, hidden: false, order: 7, filterable: false },
level: { type: DataTypes.ENUM("beginner", "intermediate", "advanced"), allowNull: true, hidden: false, order: 3, filterable: true }, level: { type: DataTypes.ENUM("beginner", "intermediate", "advanced"), allowNull: true, hidden: false, order: 3, filterable: true },
subscription: { type: DataTypes.STRING(50), allowNull: false, defaultValue: "free", hidden: false, order: 4, filterable: true }, subscription: { type: DataTypes.STRING(50), allowNull: false, defaultValue: "free", hidden: false, order: 4, filterable: true },
duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0, hidden: false, order: 8, filterable: false }, duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0, hidden: false, order: 8, filterable: false },
badge_color: { type: DataTypes.STRING(50), allowNull: true, defaultValue: "purple" },
badge_asset_id: { type: DataTypes.BIGINT, allowNull: true },
badge_image_url: { type: DataTypes.TEXT, allowNull: true },
createdBy: { type: DataTypes.BIGINT, allowNull: true }, createdBy: { type: DataTypes.BIGINT, allowNull: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true }, updatedBy: { type: DataTypes.BIGINT, allowNull: true },
deletedBy: { type: DataTypes.BIGINT, allowNull: true }, deletedBy: { type: DataTypes.BIGINT, allowNull: true },
+36
View File
@@ -0,0 +1,36 @@
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_PaymentPolicies = sequelize.define('PaymentPolicy', {
policy_id: {
type: DataTypes.BIGINT,
primaryKey: true,
autoIncrement: true,
},
plan_id: {
type: DataTypes.BIGINT,
allowNull: true,
unique: true,
},
promo_rules: {
type: DataTypes.JSONB,
allowNull: false,
defaultValue: [],
},
refund_policy: {
type: DataTypes.JSONB,
allowNull: false,
defaultValue: { allowed: true, window_value: 5, window_unit: 'minutes', reason_required: false },
},
allowed_providers: {
type: DataTypes.JSONB,
allowNull: false,
defaultValue: ['paypal'],
},
}, {
tableName: 'payment_policies',
timestamps: true,
paranoid: false,
});
module.exports = mdl_PaymentPolicies;
+26
View File
@@ -0,0 +1,26 @@
/***********************************************************************************************************************************************************************
* File Name: plan_prices.mdl.js
* Type of Program: Model
* Description: Admin-managed localized price overrides for tier plans.
* One row per (plan_id, currency) pair. When a user's preferred_currency
* matches a row here, the override price is shown instead of the base price.
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 29, 2026
***********************************************************************************************************************************************************************/
'use strict';
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_PlanPrices = sequelize.define('PlanPrice', {
price_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
plan_id: { type: DataTypes.BIGINT, allowNull: false },
currency: { type: DataTypes.CHAR(3), allowNull: false, label: 'Currency' },
price: { type: DataTypes.DECIMAL(10, 2), allowNull: false, label: 'Price' },
}, {
tableName: 'plan_prices',
timestamps: true,
paranoid: false,
});
module.exports = mdl_PlanPrices;
+6
View File
@@ -5,6 +5,7 @@ const mdl_UserTiers = require('./user_tiers.mdl');
const mdl_Payments = require('./payments.mdl'); const mdl_Payments = require('./payments.mdl');
const mdl_PlanCourses = require('./plan_courses.mdl'); const mdl_PlanCourses = require('./plan_courses.mdl');
const mdl_PlanPolicies = require('./plan_policies.mdl'); const mdl_PlanPolicies = require('./plan_policies.mdl');
const mdl_PlanPrices = require('./plan_prices.mdl');
const mdl_SystemBadges = require('../system_badges/system_badges.mdl'); const mdl_SystemBadges = require('../system_badges/system_badges.mdl');
const Asset = require('../assets/assets.mdl'); const Asset = require('../assets/assets.mdl');
const { Course } = require('../courses/courses.mdl'); const { Course } = require('../courses/courses.mdl');
@@ -53,6 +54,10 @@ mdl_TierPlans.hasMany(mdl_UserTiers, { foreignKey: 'plan_id', as: 'userTiers'
mdl_TierPlans.hasOne(mdl_PlanPolicies, { foreignKey: 'plan_id', as: 'policy' }); mdl_TierPlans.hasOne(mdl_PlanPolicies, { foreignKey: 'plan_id', as: 'policy' });
mdl_PlanPolicies.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' }); mdl_PlanPolicies.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' });
// ─── Plan ↔ Localized Prices ──────────────────────────────────────────────────
mdl_TierPlans.hasMany(mdl_PlanPrices, { foreignKey: 'plan_id', as: 'prices' });
mdl_PlanPrices.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' });
// ─── SystemBadge → Asset ───────────────────────────────────────────────────── // ─── SystemBadge → Asset ─────────────────────────────────────────────────────
mdl_SystemBadges.belongsTo(Asset, { foreignKey: 'asset_id', as: 'asset' }); mdl_SystemBadges.belongsTo(Asset, { foreignKey: 'asset_id', as: 'asset' });
@@ -63,5 +68,6 @@ module.exports = {
mdl_Payments, mdl_Payments,
mdl_PlanCourses, mdl_PlanCourses,
mdl_PlanPolicies, mdl_PlanPolicies,
mdl_PlanPrices,
mdl_SystemBadges, mdl_SystemBadges,
}; };
+2 -1
View File
@@ -7,7 +7,8 @@
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
const excludeAttributes = [ const excludeAttributes = [
// nothing hidden by default — all columns are safe to expose to admin "tier_category_id",
"description",
]; ];
const jsonbSchemas = {}; // no JSONB columns on this model const jsonbSchemas = {}; // no JSONB columns on this model
+4 -2
View File
@@ -13,8 +13,10 @@ const mdl_TierPlans = sequelize.define('TierPlan', {
plan_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: 'Plan ID' }, plan_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: 'Plan ID' },
tier_category_id: { type: DataTypes.BIGINT, allowNull: true, label: 'Tier Category' }, tier_category_id: { type: DataTypes.BIGINT, allowNull: true, label: 'Tier Category' },
tier: { type: DataTypes.STRING(50), allowNull: false, label: 'Tier' }, tier: { type: DataTypes.STRING(50), allowNull: false, label: 'Tier' },
label: { type: DataTypes.STRING(100), allowNull: false, label: 'Plan Label' }, label: { type: DataTypes.STRING(100), allowNull: false, label: 'Plan Label' },
duration_days: { type: DataTypes.INTEGER, allowNull: false, label: 'Duration (Days)' }, description: { type: DataTypes.TEXT, allowNull: true, label: 'Description' },
duration_days: { type: DataTypes.FLOAT, allowNull: false, label: 'Duration (Days)' },
duration_unit: { type: DataTypes.STRING(10), allowNull: false, defaultValue: 'day', label: 'Duration Unit' },
price: { type: DataTypes.DECIMAL(10, 2), allowNull: false, label: 'Price' }, price: { type: DataTypes.DECIMAL(10, 2), allowNull: false, label: 'Price' },
currency: { type: DataTypes.CHAR(3), allowNull: false, defaultValue: 'USD', label: 'Currency' }, currency: { type: DataTypes.CHAR(3), allowNull: false, defaultValue: 'USD', label: 'Currency' },
is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: 'Active' }, is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: 'Active' },
+3
View File
@@ -43,6 +43,9 @@ const mdl_Users = sequelize.define('User', {
*/ */
personal_info: { type: DataTypes.JSONB, defaultValue: null, label: "Personal Info", order: 2 }, personal_info: { type: DataTypes.JSONB, defaultValue: null, label: "Personal Info", order: 2 },
// ── Currency preference ──────────────────────────────────────────────────────
preferred_currency: { type: DataTypes.CHAR(3), allowNull: false, defaultValue: 'USD', label: 'Preferred Currency' },
// ── Ban state ──────────────────────────────────────────────────────────────── // ── Ban state ────────────────────────────────────────────────────────────────
is_banned: { type: DataTypes.BOOLEAN, defaultValue: false, allowNull: false, label: "Banned" }, is_banned: { type: DataTypes.BOOLEAN, defaultValue: false, allowNull: false, label: "Banned" },
ban_expires_at: { type: DataTypes.DATE, defaultValue: null, allowNull: true, label: "Ban Expires At" }, ban_expires_at: { type: DataTypes.DATE, defaultValue: null, allowNull: true, label: "Ban Expires At" },
+3833 -4
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -6,6 +6,7 @@
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
"dev": "nodemon server.js", "dev": "nodemon server.js",
"test": "jest",
"db:migrate": "sequelize-cli db:migrate", "db:migrate": "sequelize-cli db:migrate",
"db:migrate:undo": "sequelize-cli db:migrate:undo", "db:migrate:undo": "sequelize-cli db:migrate:undo",
"db:migrate:undo:all": "sequelize-cli db:migrate:undo:all", "db:migrate:undo:all": "sequelize-cli db:migrate:undo:all",
@@ -46,7 +47,12 @@
"uuid": "^9.0.0" "uuid": "^9.0.0"
}, },
"devDependencies": { "devDependencies": {
"jest": "^30.4.2",
"nodemon": "^3.0.1", "nodemon": "^3.0.1",
"sequelize-cli": "^6.6.5" "sequelize-cli": "^6.6.5"
},
"jest": {
"testMatch": ["**/tests/**/*.test.js"],
"forceExit": true
} }
} }
+72
View File
@@ -0,0 +1,72 @@
/***********************************************************************************************************************************************************************
* File Name: paypal.provider.js
* Type of Program: Payment Provider
* Description: PayPal Orders API — create order, capture order, refund capture.
* Canonical provider used by payment.service.js via the provider registry.
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 29, 2026
***********************************************************************************************************************************************************************/
'use strict';
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: process.env.PAYPAL_BRAND_NAME ?? 'STARR',
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;
};
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;
};
+14
View File
@@ -0,0 +1,14 @@
'use strict';
const paypal = require('./paypal.provider');
const PROVIDERS = { paypal };
module.exports = {
get(name) {
const p = PROVIDERS[name];
if (!p) throw new Error(`Unknown payment provider: "${name}". Available: ${Object.keys(PROVIDERS).join(', ')}`);
return p;
},
list: () => Object.keys(PROVIDERS),
};
+3 -1
View File
@@ -31,6 +31,7 @@ const assetsRoutes = require('./assets.routes');
const coursesRoutes = require('./courses.routes'); const coursesRoutes = require('./courses.routes');
const taskRoutes = require('./task.routes'); const taskRoutes = require('./task.routes');
const tiersRoutes = require('./tiers.routes'); const tiersRoutes = require('./tiers.routes');
const tierPoliciesRoutes = require('./tier_policies.routes');
const tierCategoriesRoutes = require('./tier_categories.routes'); const tierCategoriesRoutes = require('./tier_categories.routes');
const categoriesRoutes = require('./categories.routes'); const categoriesRoutes = require('./categories.routes');
const productsRoutes = require('./products.routes'); const productsRoutes = require('./products.routes');
@@ -51,7 +52,8 @@ router.use('/assets', assetsRoutes);
router.use('/courses', coursesRoutes); router.use('/courses', coursesRoutes);
router.use('/task-lists', taskRoutes); router.use('/task-lists', taskRoutes);
router.use('/tiers/categories', tierCategoriesRoutes); router.use('/tiers/categories', tierCategoriesRoutes);
router.use('/tiers', tiersRoutes); router.use('/tiers', tiersRoutes);
router.use('/tier-policies', tierPoliciesRoutes);
router.use('/categories', categoriesRoutes); router.use('/categories', categoriesRoutes);
router.use('/products', productsRoutes); router.use('/products', productsRoutes);
router.use('/advertisements', advertisementRoutes); router.use('/advertisements', advertisementRoutes);
+7
View File
@@ -39,6 +39,13 @@ router.delete("/:courseId", ctrl.archiveCourse);
router.get("/:courseId/instructors", ctrl.getInstructors); router.get("/:courseId/instructors", ctrl.getInstructors);
router.put("/:courseId/instructors", ctrl.syncInstructors); router.put("/:courseId/instructors", ctrl.syncInstructors);
// ══════════════════════════════════════════════════════════════════════════════
// COURSE ACHIEVEMENTS
// ══════════════════════════════════════════════════════════════════════════════
router.get("/:courseId/achievements", ctrl.getCourseAchievements);
router.put("/:courseId/achievements", ctrl.syncCourseAchievements);
// ══════════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════════
// PREREQUISITES // PREREQUISITES
// ══════════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════════
+8 -2
View File
@@ -2,12 +2,18 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const ctrl = require('../../controllers/admin/tier_policies.controller'); const ctrl = require('../../controllers/admin/tier_policies.controller');
// ─── Plan Policies ──────────────────────────────────────────────────────────── // ─── Plan Policies (access rules) ────────────────────────────────────────────
// GET /admin/tiers/plans/:planId/policy // GET /admin/tiers/plans/:planId/policy
// PUT /admin/tiers/plans/:planId/policy — JSON: { badge_asset_id, badge_label, access_rules, … } // PUT /admin/tiers/plans/:planId/policy — JSON: { access_rules }
router.get('/plans/:planId/policy', ctrl.getPlanPolicy); router.get('/plans/:planId/policy', ctrl.getPlanPolicy);
router.put('/plans/:planId/policy', ctrl.upsertPlanPolicy); router.put('/plans/:planId/policy', ctrl.upsertPlanPolicy);
// ─── Payment Policies (promo codes + refund window) ───────────────────────────
// GET /admin/tiers/plans/:planId/payment-policy
// PUT /admin/tiers/plans/:planId/payment-policy — JSON: { promo_rules, refund_policy, allowed_providers }
router.get('/plans/:planId/payment-policy', ctrl.getPaymentPolicy);
router.put('/plans/:planId/payment-policy', ctrl.upsertPaymentPolicy);
// ─── System Badges ──────────────────────────────────────────────────────────── // ─── System Badges ────────────────────────────────────────────────────────────
// GET /admin/tiers/system-badges // GET /admin/tiers/system-badges
// GET /admin/tiers/system-badges/:key // GET /admin/tiers/system-badges/:key
+11 -4
View File
@@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const ctrl = require('../../controllers/admin/tiers.controller'); const ctrl = require('../../controllers/admin/tiers.controller');
const priceCtrl = require('../../controllers/admin/plan_prices.controller');
router.get ('/', ctrl.getPlans); router.get ('/', ctrl.getPlans);
router.post ('/', ctrl.createPlan); router.post ('/', ctrl.createPlan);
@@ -16,10 +17,16 @@ router.get ('/users/:id/tiers', ctrl.getUserTiers);
router.post ('/users/tiers/grant', ctrl.grantTier); router.post ('/users/tiers/grant', ctrl.grantTier);
router.patch ('/users/tiers/:tid/revoke', ctrl.revokeTier); router.patch ('/users/tiers/:tid/revoke', ctrl.revokeTier);
// ← course routes before /:id // ← course + impact + prices routes before /:id
router.get ('/:id/impact', ctrl.getPlanImpact);
router.get ('/:id/courses', ctrl.getPlanCourses); router.get ('/:id/courses', ctrl.getPlanCourses);
router.post ('/:id/courses', ctrl.syncPlanCourses); router.post ('/:id/courses', ctrl.syncPlanCourses);
router.get ('/:id/prices', priceCtrl.getPrices);
router.post ('/:id/prices', priceCtrl.addPrice);
router.put ('/:id/prices/:currency', priceCtrl.updatePrice);
router.delete('/:id/prices/:currency', priceCtrl.removePrice);
router.get ('/:id', ctrl.getPlan); router.get ('/:id', ctrl.getPlan);
router.put ('/:id', ctrl.updatePlan); router.put ('/:id', ctrl.updatePlan);
router.delete('/:id', ctrl.archivePlan); router.delete('/:id', ctrl.archivePlan);
+1
View File
@@ -50,6 +50,7 @@ router.use(authenticate, requireClient());
router.get('/profile', profileCtrl.getProfile); router.get('/profile', profileCtrl.getProfile);
router.put('/profile', ...updateProfileValidator, validate, profileCtrl.updateProfile); router.put('/profile', ...updateProfileValidator, validate, profileCtrl.updateProfile);
router.delete('/profile', profileCtrl.deleteAccount); router.delete('/profile', profileCtrl.deleteAccount);
router.patch('/profile/currency', profileCtrl.updateCurrency);
router.post('/profile/avatar', handleAvatarUpload, profileCtrl.uploadAvatar); router.post('/profile/avatar', handleAvatarUpload, profileCtrl.uploadAvatar);
router.delete('/profile/avatar', profileCtrl.deleteAvatar); router.delete('/profile/avatar', profileCtrl.deleteAvatar);
router.get('/sessions', profileCtrl.getSessions); router.get('/sessions', profileCtrl.getSessions);
+3
View File
@@ -3,6 +3,9 @@ const router = express.Router();
const ctrl = require('../../controllers/client/courses.controller'); const ctrl = require('../../controllers/client/courses.controller');
const progressCtrl = require('../../controllers/client/course_reading_progress.controller'); const progressCtrl = require('../../controllers/client/course_reading_progress.controller');
// Course categories for filter chips — must come before /:courseId
router.get('/categories', ctrl.getCategories);
// Profile learning progress card — must come before /:courseId // Profile learning progress card — must come before /:courseId
router.get('/in-progress', progressCtrl.getMyInProgressCourses); router.get('/in-progress', progressCtrl.getMyInProgressCourses);
+11 -6
View File
@@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const ctrl = require('../../controllers/client/tiers.controller'); const ctrl = require('../../controllers/client/tiers.controller');
const priceCtrl = require('../../controllers/admin/plan_prices.controller');
// My tier // My tier
router.get ('/me', ctrl.getMyTier); router.get ('/me', ctrl.getMyTier);
@@ -9,12 +10,13 @@ router.get ('/me/history', ctrl.getMyTierHistory);
// Plans // Plans
router.get ('/plans', ctrl.getPlans); router.get ('/plans', ctrl.getPlans);
// PayPal checkout // Promo code validation
router.post('/promos/validate', ctrl.validatePromo);
// Checkout
router.post('/checkout/order', ctrl.createOrder); router.post('/checkout/order', ctrl.createOrder);
router.post('/checkout/capture', ctrl.captureOrder); router.post('/checkout/capture', ctrl.captureOrder);
router.post('/checkout/cancel', ctrl.cancelOrder); router.post('/checkout/cancel', ctrl.cancelOrder);
// PayPal refund
router.post('/checkout/refund', ctrl.refundOrder); router.post('/checkout/refund', ctrl.refundOrder);
// My payments // My payments
@@ -26,4 +28,7 @@ router.get ('/categories', ctrl.getCategories);
// System badges (public read for profile display) // System badges (public read for profile display)
router.get ('/system-badges', ctrl.getSystemBadges); router.get ('/system-badges', ctrl.getSystemBadges);
// Supported currencies (public — used by currency picker in settings + checkout)
router.get ('/currencies', priceCtrl.getCurrencies);
module.exports = router; module.exports = router;
+31
View File
@@ -0,0 +1,31 @@
#!/bin/sh
# Wraps the Garage daemon: starts it in the background, runs one-time cluster
# initialization using the local RPC connection, then hands control back to
# the daemon process. Safe to re-run — all operations are idempotent.
set -e
garage server &
DAEMON_PID=$!
echo "Waiting for Garage to be ready..."
until garage status > /dev/null 2>&1; do
sleep 2
done
echo "Garage is up. Running cluster initialization..."
NODE_ID=$(garage node id 2>/dev/null | head -1 | awk '{print $1}')
garage layout assign "$NODE_ID" -z dc1 -c 100G 2>/dev/null || true
garage layout apply --version 1 2>/dev/null || true
garage bucket create "$S3_BUCKET" 2>/dev/null || true
# --yes skips the interactive confirmation prompt.
garage key import "$S3_ACCESS_KEY" "$S3_SECRET_KEY" -n starr-app --yes 2>/dev/null || true
garage bucket allow "$S3_BUCKET" --read --write --owner --key "$S3_ACCESS_KEY" 2>/dev/null || true
# Flag file read by the healthcheck — ensures the backend waits for full init.
touch /var/lib/garage/meta/.ready
echo "Garage init complete."
wait $DAEMON_PID
+10 -3
View File
@@ -15,13 +15,20 @@
const nodemailer = require('nodemailer'); const nodemailer = require('nodemailer');
const { emailTemplates } = require('../data/email_body.data') const { emailTemplates } = require('../data/email_body.data')
const port = Number(process.env.SMTP_PORT);
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST, host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT), port,
secure: port === 465,
requireTLS: port === 587,
auth: { auth: {
user: process.env.SMTP_USER, user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS, pass: process.env.SMTP_PASS,
}, },
tls: {
rejectUnauthorized: false,
},
}); });
const sendEmail = async ({ to, type, data = {} }) => { const sendEmail = async ({ to, type, data = {} }) => {
@@ -39,7 +46,7 @@ const sendEmail = async ({ to, type, data = {} }) => {
{ {
from: { from: {
name: "STARR System", name: "STARR System",
address: "do-not-reply@philproperties.com", address: process.env.EMAIL_FROM,
}, },
to, to,
subject, subject,
+120
View File
@@ -0,0 +1,120 @@
/***********************************************************************************************************************************************************************
* File Name: payment.service.js
* Type of Program: Service
* Description: Provider-agnostic payment orchestration.
* - Loads payment policy per plan (promo rules, refund policy, allowed providers)
* - Evaluates promo codes server-side (type: flat | percent)
* - Calculates refund eligibility window (unit: minutes | hours | days)
* - Delegates create/capture/refund to the provider registry
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 29, 2026
***********************************************************************************************************************************************************************/
'use strict';
const registry = require('../providers/registry');
const mdl_PaymentPolicies = require('../models/tiers/payment_policies.mdl');
const mdl_Payments = require('../models/tiers/payments.mdl');
// ─── Defaults ─────────────────────────────────────────────────────────────────
const DEFAULT_REFUND_POLICY = {
allowed: true,
window_value: 5,
window_unit: 'minutes',
reason_required: false,
};
const UNIT_MS = { minutes: 60_000, hours: 3_600_000, days: 86_400_000 };
// ─── Policy ───────────────────────────────────────────────────────────────────
async function getPolicyForPlan(plan_id) {
return mdl_PaymentPolicies.findOne({ where: { plan_id } });
}
// ─── Refund window ────────────────────────────────────────────────────────────
function getRefundWindowMs(policy) {
const rp = policy?.refund_policy ?? DEFAULT_REFUND_POLICY;
const { window_value = 5, window_unit = 'minutes' } = rp;
return Number(window_value) * (UNIT_MS[window_unit] ?? 60_000);
}
function isRefundAllowed(policy) {
return policy?.refund_policy?.allowed ?? DEFAULT_REFUND_POLICY.allowed;
}
// ─── Promo evaluation ─────────────────────────────────────────────────────────
// effectivePrice — pass the localized price when charging in a non-base currency
// so discounts are computed against the actual amount being charged.
async function evaluatePromo(policy, plan, rawCode, effectivePrice = null) {
const code = rawCode?.trim?.().toUpperCase?.() ?? null;
if (!code) return { valid: false, reason: 'No promo code provided.' };
const rules = policy?.promo_rules ?? [];
const rule = rules.find((r) => r.code?.toUpperCase() === code);
if (!rule) return { valid: false, reason: 'Invalid promo code.' };
if (rule.expires_at && new Date(rule.expires_at) < new Date())
return { valid: false, reason: 'Promo code has expired.' };
// Count how many completed payments used this code for this plan
if (rule.max_uses != null) {
const uses = await mdl_Payments.count({
where: { promo_code: code, plan_id: plan.plan_id },
});
if (uses >= Number(rule.max_uses))
return { valid: false, reason: 'Promo code has reached its usage limit.' };
}
const subtotal = effectivePrice !== null ? Number(effectivePrice) : Number(plan.price);
if (rule.min_amount != null && subtotal < Number(rule.min_amount))
return { valid: false, reason: `This promo code requires a minimum purchase of ${rule.min_amount}.` };
let discount;
if (rule.type === 'flat') {
discount = Math.min(Number(rule.value), subtotal);
} else if (rule.type === 'percent') {
const pct = Math.min(Number(rule.value), 100);
const raw = (subtotal * pct) / 100;
discount = rule.max_discount != null ? Math.min(raw, Number(rule.max_discount)) : raw;
discount = Math.min(discount, subtotal);
} else {
return { valid: false, reason: 'Unsupported promo type.' };
}
return {
valid: true,
code,
type: rule.type,
value: rule.value,
discount: Number(discount.toFixed(2)),
reason: null,
};
}
// ─── Provider delegation ──────────────────────────────────────────────────────
function createOrder(provider, opts) {
return registry.get(provider).createOrder(opts);
}
function captureOrder(provider, orderId) {
return registry.get(provider).captureOrder(orderId);
}
function refundCapture(provider, captureId, amount, currency) {
return registry.get(provider).refundCapture(captureId, amount, currency);
}
module.exports = {
getPolicyForPlan,
getRefundWindowMs,
isRefundAllowed,
evaluatePromo,
createOrder,
captureOrder,
refundCapture,
};
+4 -4
View File
@@ -10,8 +10,8 @@
// S3_REGION – garage // S3_REGION – garage
// S3_ACCESS_KEY // S3_ACCESS_KEY
// S3_SECRET_KEY // S3_SECRET_KEY
// S3_BUCKET – philproperties // S3_BUCKET – your-bucket-name
// S3_PUBLIC_URL – https://garage.philproperties.com // S3_PUBLIC_URL – https://cdn.yourdomain.com
const { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadBucketCommand } = require("@aws-sdk/client-s3"); const { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadBucketCommand } = require("@aws-sdk/client-s3");
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
@@ -44,7 +44,7 @@ const s3Public = new S3Client({
forcePathStyle: true, forcePathStyle: true,
}); });
const DEFAULT_BUCKET = process.env.S3_BUCKET || "philproperties"; const DEFAULT_BUCKET = process.env.S3_BUCKET;
const PUBLIC_URL = (process.env.S3_PUBLIC_URL || "").replace(/\/$/, ""); const PUBLIC_URL = (process.env.S3_PUBLIC_URL || "").replace(/\/$/, "");
// ─── Key prefix map ─────────────────────────────────────────────────────────── // ─── Key prefix map ───────────────────────────────────────────────────────────
@@ -84,7 +84,7 @@ function buildKey(originalname, ownerType) {
// Builds the public URL for a stored object. // Builds the public URL for a stored object.
// Garage path-style: {S3_PUBLIC_URL}/{bucket}/{key} // Garage path-style: {S3_PUBLIC_URL}/{bucket}/{key}
// e.g. https://garage.philproperties.com/philproperties/images/uuid.jpg // e.g. https://cdn.yourdomain.com/your-bucket/images/uuid.jpg
function buildPublicUrl(key, bucket = DEFAULT_BUCKET) { function buildPublicUrl(key, bucket = DEFAULT_BUCKET) {
return `${PUBLIC_URL}/${bucket}/${key}`; return `${PUBLIC_URL}/${bucket}/${key}`;
} }
+258
View File
@@ -0,0 +1,258 @@
'use strict';
// Set env BEFORE requiring the middleware (ALLOWED array is built at load time)
process.env.ORIGIN_GUARD_DISABLED = 'false';
process.env.NODE_ENV = 'development';
process.env.ALLOWED_ORIGINS = 'http://localhost:5173,http://localhost:3024';
const originGuard = require('../../middleware/originGuard.middleware');
// ── Mock helpers ──────────────────────────────────────────────────────────────
function makeReq({ method = 'GET', headers = {} } = {}) {
return { method, headers };
}
function makeRes() {
const res = {
_status: null,
_body: null,
status(code) { this._status = code; return this; },
json(body) { this._body = body; return this; },
};
return res;
}
// Minimal headers that represent a real browser fetch() call (SPA → API).
// localhost:5173 → localhost:3024 is same-site (same eTLD+1, different port).
const BROWSER_HEADERS = {
'sec-fetch-site': 'same-site',
'sec-fetch-mode': 'cors',
'sec-fetch-dest': 'empty',
'accept-language': 'en-US,en;q=0.9',
};
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('originGuard middleware', () => {
// ── Layer 1a: Sec-Fetch-Site presence + value ─────────────────────────────
test('1. GET with no Sec-Fetch-Site → 403 (Layer 1a: header absent)', () => {
const req = makeReq({ method: 'GET', headers: {} });
const res = makeRes();
const next = jest.fn();
originGuard(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(403);
expect(res._body).toMatchObject({ status: 'error', message: 'Forbidden.' });
});
test('2. GET with Sec-Fetch-Site: cross-site → 403 (Layer 1a: cross-site rejected)', () => {
const req = makeReq({ method: 'GET', headers: { 'sec-fetch-site': 'cross-site' } });
const res = makeRes();
const next = jest.fn();
originGuard(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(403);
expect(res._body).toMatchObject({ status: 'error', message: 'Forbidden.' });
});
// ── Layer 1b: Fetch Metadata family completeness + valid combo ────────────
test('3. GET with Sec-Fetch-Site but missing Mode and Dest → 403 (Layer 1b: incomplete family)', () => {
const req = makeReq({ method: 'GET', headers: { 'sec-fetch-site': 'same-site' } });
const res = makeRes();
const next = jest.fn();
originGuard(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(403);
expect(res._body).toMatchObject({ status: 'error', message: 'Forbidden.' });
});
test('4. GET with Sec-Fetch-Site + Mode but missing Dest → 403 (Layer 1b: partial family)', () => {
const req = makeReq({
method: 'GET',
headers: { 'sec-fetch-site': 'same-site', 'sec-fetch-mode': 'cors' },
});
const res = makeRes();
const next = jest.fn();
originGuard(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(403);
});
test('5. GET with impossible combo (cors + document) → 403 (Layer 1b: invalid combination)', () => {
const req = makeReq({
method: 'GET',
headers: {
'sec-fetch-site': 'same-site',
'sec-fetch-mode': 'cors',
'sec-fetch-dest': 'document',
'accept-language': 'en-US',
},
});
const res = makeRes();
const next = jest.fn();
originGuard(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(403);
});
test('6. GET with no-cors mode → 403 (Layer 1b: no-cors not expected on API server)', () => {
const req = makeReq({
method: 'GET',
headers: {
'sec-fetch-site': 'same-site',
'sec-fetch-mode': 'no-cors',
'sec-fetch-dest': 'empty',
'accept-language': 'en-US',
},
});
const res = makeRes();
const next = jest.fn();
originGuard(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(403);
});
// ── Layer 2: Browser presence signals ────────────────────────────────────
test('7. GET with valid Fetch Metadata but no Sec-CH-UA and no Accept-Language → 403 (Layer 2: no browser fingerprint)', () => {
const req = makeReq({
method: 'GET',
headers: {
'sec-fetch-site': 'same-site',
'sec-fetch-mode': 'cors',
'sec-fetch-dest': 'empty',
},
});
const res = makeRes();
const next = jest.fn();
originGuard(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(403);
expect(res._body).toMatchObject({ status: 'error', message: 'Forbidden.' });
});
// ── Layer 3: Origin allowlist ─────────────────────────────────────────────
test('8. POST with full browser headers but foreign Origin → 403 (Layer 3: unlisted origin)', () => {
const req = makeReq({
method: 'POST',
headers: { ...BROWSER_HEADERS, 'origin': 'http://attacker.com' },
});
const res = makeRes();
const next = jest.fn();
originGuard(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(403);
expect(res._body).toMatchObject({ status: 'error', message: 'Forbidden.' });
});
test('9. POST with full browser headers but missing Origin → 403 (Layer 3: no origin header)', () => {
const req = makeReq({ method: 'POST', headers: { ...BROWSER_HEADERS } });
const res = makeRes();
const next = jest.fn();
originGuard(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(403);
});
// ── Happy paths ───────────────────────────────────────────────────────────
test('10. GET with full browser headers (Accept-Language path) → passes', () => {
const req = makeReq({ method: 'GET', headers: { ...BROWSER_HEADERS } });
const res = makeRes();
const next = jest.fn();
originGuard(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(res._status).toBeNull();
});
test('11. GET with Sec-CH-UA instead of Accept-Language (Chromium path) → passes', () => {
const req = makeReq({
method: 'GET',
headers: {
'sec-fetch-site': 'same-site',
'sec-fetch-mode': 'cors',
'sec-fetch-dest': 'empty',
'sec-ch-ua': '"Chromium";v="137", "Not/A)Brand";v="24"',
},
});
const res = makeRes();
const next = jest.fn();
originGuard(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(res._status).toBeNull();
});
test('12. POST with full browser headers and allowed Origin → passes', () => {
const req = makeReq({
method: 'POST',
headers: { ...BROWSER_HEADERS, 'origin': 'http://localhost:5173' },
});
const res = makeRes();
const next = jest.fn();
originGuard(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(res._status).toBeNull();
});
test('13. Direct browser navigation (navigate + document, site: none) → passes', () => {
const req = makeReq({
method: 'GET',
headers: {
'sec-fetch-site': 'none',
'sec-fetch-mode': 'navigate',
'sec-fetch-dest': 'document',
'sec-fetch-user': '?1',
'accept-language': 'en-US,en;q=0.9',
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
},
});
const res = makeRes();
const next = jest.fn();
originGuard(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(res._status).toBeNull();
});
test('14. OPTIONS preflight → passes immediately (handled by cors())', () => {
const req = makeReq({ method: 'OPTIONS', headers: {} });
const res = makeRes();
const next = jest.fn();
originGuard(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(res._status).toBeNull();
});
});
+203
View File
@@ -0,0 +1,203 @@
'use strict';
const {
requireClient,
requireStaff,
requireAdmin,
requireOwnerOrStaff,
requireOwnerOrAdmin,
} = require('../../middleware/rbac.middleware');
// ── Mock helpers ──────────────────────────────────────────────────────────────
function makeRes() {
const res = {
_status: null,
_body: null,
status(code) { this._status = code; return this; },
json(body) { this._body = body; return this; },
};
return res;
}
function makeReq(accType = null, userId = null, paramId = null) {
return {
user: accType ? { user_id: userId, acc_type: accType } : null,
params: { id: paramId },
};
}
// ── requireClient ─────────────────────────────────────────────────────────────
describe('requireClient()', () => {
test('user role → passes', () => {
const next = jest.fn();
requireClient()(makeReq('user'), makeRes(), next);
expect(next).toHaveBeenCalledTimes(1);
});
test('staff role → passes', () => {
const next = jest.fn();
requireClient()(makeReq('staff'), makeRes(), next);
expect(next).toHaveBeenCalledTimes(1);
});
test('admin role → passes', () => {
const next = jest.fn();
requireClient()(makeReq('admin'), makeRes(), next);
expect(next).toHaveBeenCalledTimes(1);
});
test('no req.user → 401', () => {
const res = makeRes();
const next = jest.fn();
requireClient()(makeReq(null), res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(401);
});
});
// ── requireStaff ──────────────────────────────────────────────────────────────
describe('requireStaff()', () => {
test('staff role → passes', () => {
const next = jest.fn();
requireStaff()(makeReq('staff'), makeRes(), next);
expect(next).toHaveBeenCalledTimes(1);
});
test('admin role → passes', () => {
const next = jest.fn();
requireStaff()(makeReq('admin'), makeRes(), next);
expect(next).toHaveBeenCalledTimes(1);
});
test('user role → 403', () => {
const res = makeRes();
const next = jest.fn();
requireStaff()(makeReq('user'), res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(403);
});
test('no req.user → 401', () => {
const res = makeRes();
const next = jest.fn();
requireStaff()(makeReq(null), res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(401);
});
});
// ── requireAdmin ──────────────────────────────────────────────────────────────
describe('requireAdmin()', () => {
test('admin role → passes', () => {
const next = jest.fn();
requireAdmin()(makeReq('admin'), makeRes(), next);
expect(next).toHaveBeenCalledTimes(1);
});
test('staff role → 403', () => {
const res = makeRes();
const next = jest.fn();
requireAdmin()(makeReq('staff'), res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(403);
});
test('user role → 403', () => {
const res = makeRes();
const next = jest.fn();
requireAdmin()(makeReq('user'), res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(403);
});
test('no req.user → 401', () => {
const res = makeRes();
const next = jest.fn();
requireAdmin()(makeReq(null), res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(401);
});
});
// ── requireOwnerOrStaff ───────────────────────────────────────────────────────
describe('requireOwnerOrStaff()', () => {
test('owner (user) accessing own resource → passes', () => {
const next = jest.fn();
const req = { user: { user_id: 10, acc_type: 'user' }, params: { id: '10' } };
requireOwnerOrStaff()(req, makeRes(), next);
expect(next).toHaveBeenCalledTimes(1);
});
test('staff accessing another user\'s resource → passes', () => {
const next = jest.fn();
const req = { user: { user_id: 2, acc_type: 'staff' }, params: { id: '99' } };
requireOwnerOrStaff()(req, makeRes(), next);
expect(next).toHaveBeenCalledTimes(1);
});
test('admin accessing another user\'s resource → passes', () => {
const next = jest.fn();
const req = { user: { user_id: 1, acc_type: 'admin' }, params: { id: '99' } };
requireOwnerOrStaff()(req, makeRes(), next);
expect(next).toHaveBeenCalledTimes(1);
});
test('user accessing another user\'s resource → 403', () => {
const res = makeRes();
const next = jest.fn();
const req = { user: { user_id: 10, acc_type: 'user' }, params: { id: '99' } };
requireOwnerOrStaff()(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(403);
});
test('no req.user → 401', () => {
const res = makeRes();
const next = jest.fn();
const req = { user: null, params: { id: '10' } };
requireOwnerOrStaff()(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(401);
});
});
// ── requireOwnerOrAdmin ───────────────────────────────────────────────────────
describe('requireOwnerOrAdmin()', () => {
test('owner (user) accessing own resource → passes', () => {
const next = jest.fn();
const req = { user: { user_id: 10, acc_type: 'user' }, params: { id: '10' } };
requireOwnerOrAdmin()(req, makeRes(), next);
expect(next).toHaveBeenCalledTimes(1);
});
test('admin accessing another user\'s resource → passes', () => {
const next = jest.fn();
const req = { user: { user_id: 1, acc_type: 'admin' }, params: { id: '99' } };
requireOwnerOrAdmin()(req, makeRes(), next);
expect(next).toHaveBeenCalledTimes(1);
});
test('staff (non-owner) accessing another user\'s resource → 403', () => {
const res = makeRes();
const next = jest.fn();
const req = { user: { user_id: 2, acc_type: 'staff' }, params: { id: '99' } };
requireOwnerOrAdmin()(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(403);
});
test('no req.user → 401', () => {
const res = makeRes();
const next = jest.fn();
const req = { user: null, params: { id: '10' } };
requireOwnerOrAdmin()(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res._status).toBe(401);
});
});
+235
View File
@@ -0,0 +1,235 @@
'use strict';
// ── Env vars must be set BEFORE the service loads (enabled flags read process.env at load time)
process.env.DB_HOST = 'test-db-host';
process.env.DB_PORT = '5432';
process.env.S3_ENDPOINT = 'http://test-s3:3900';
process.env.S3_PUBLIC_URL = 'https://cdn.example.com';
process.env.S3_BUCKET = 'test-bucket';
process.env.SMTP_HOST = 'smtp.test.com';
process.env.SMTP_PORT = '587';
process.env.REDIS_URL = 'redis://127.0.0.1:6379';
// ── Mock all infrastructure before the service module loads ───────────────────
jest.mock('../../config/db.config', () => ({ authenticate: jest.fn() }));
jest.mock('../../config/redis.config', () => ({ ping: jest.fn() }));
jest.mock('../../services/s3.service', () => ({ ping: jest.fn() }));
jest.mock('../../services/email.service', () => ({ ping: jest.fn() }));
const db = require('../../config/db.config');
const cache = require('../../config/redis.config');
const { ping: s3 } = require('../../services/s3.service');
const { ping: smtp } = require('../../services/email.service');
const { runDashboard, runReadiness } = require('../../services/health.service');
// ── Reset mocks between tests ─────────────────────────────────────────────────
beforeEach(() => jest.clearAllMocks());
// ── Preset helpers ────────────────────────────────────────────────────────────
const resolve = (fn) => fn.mockResolvedValue();
function allHealthy() {
resolve(db.authenticate);
cache.ping.mockResolvedValue('PONG');
resolve(s3);
resolve(smtp);
}
// ── runDashboard ──────────────────────────────────────────────────────────────
describe('runDashboard()', () => {
test('all healthy → status=healthy, HTTP 200', async () => {
allHealthy();
const { httpStatus, body } = await runDashboard();
expect(httpStatus).toBe(200);
expect(body.status).toBe('healthy');
});
test('database down (critical) → status=unhealthy, HTTP 503', async () => {
db.authenticate.mockRejectedValue(new Error('Connection refused'));
cache.ping.mockResolvedValue('PONG');
resolve(s3);
resolve(smtp);
const { httpStatus, body } = await runDashboard();
expect(httpStatus).toBe(503);
expect(body.status).toBe('unhealthy');
});
test('S3 down (non-critical) → status=degraded, HTTP 200', async () => {
resolve(db.authenticate);
cache.ping.mockResolvedValue('PONG');
s3.mockRejectedValue(new Error('S3 storage unavailable'));
resolve(smtp);
const { httpStatus, body } = await runDashboard();
expect(httpStatus).toBe(200);
expect(body.status).toBe('degraded');
});
test('SMTP down (non-critical) → degraded, HTTP 200', async () => {
resolve(db.authenticate);
cache.ping.mockResolvedValue('PONG');
resolve(s3);
smtp.mockRejectedValue(new Error('SMTP unreachable'));
const { httpStatus, body } = await runDashboard();
expect(httpStatus).toBe(200);
expect(body.status).toBe('degraded');
});
test('both DB and S3 down → unhealthy (critical takes precedence), HTTP 503', async () => {
db.authenticate.mockRejectedValue(new Error('refused'));
cache.ping.mockResolvedValue('PONG');
s3.mockRejectedValue(new Error('s3 down'));
resolve(smtp);
const { httpStatus, body } = await runDashboard();
expect(httpStatus).toBe(503);
expect(body.status).toBe('unhealthy');
});
test('body has app, system, uptime, timestamp, services', async () => {
allHealthy();
const { body } = await runDashboard();
expect(body).toHaveProperty('app');
expect(body).toHaveProperty('system');
expect(body).toHaveProperty('uptime');
expect(body).toHaveProperty('timestamp');
expect(body).toHaveProperty('services');
});
test('app info has correct shape', async () => {
allHealthy();
const { body } = await runDashboard();
expect(body.app).toMatchObject({
name: expect.any(String),
version: expect.any(String),
environment: expect.any(String),
node_version: expect.stringMatching(/^v\d+/),
pid: expect.any(Number),
});
});
test('database connection label is "established" when healthy', async () => {
allHealthy();
const { body } = await runDashboard();
expect(body.services.database.connection).toBe('established');
});
test('database connection label is "unavailable" when down', async () => {
db.authenticate.mockRejectedValue(new Error('refused'));
cache.ping.mockResolvedValue('PONG');
resolve(s3);
resolve(smtp);
const { body } = await runDashboard();
expect(body.services.database.connection).toBe('unavailable');
});
test('latency_ms is a non-negative number for enabled checks', async () => {
allHealthy();
const { body } = await runDashboard();
expect(typeof body.services.database.latency_ms).toBe('number');
expect(body.services.database.latency_ms).toBeGreaterThanOrEqual(0);
});
test('timestamp is a valid ISO 8601 string', async () => {
allHealthy();
const { body } = await runDashboard();
expect(new Date(body.timestamp).toISOString()).toBe(body.timestamp);
});
});
// ── runReadiness ──────────────────────────────────────────────────────────────
describe('runReadiness()', () => {
test('all healthy → status=healthy, HTTP 200', async () => {
allHealthy();
const { httpStatus, body } = await runReadiness();
expect(httpStatus).toBe(200);
expect(body.status).toBe('healthy');
});
test('database down → HTTP 503', async () => {
db.authenticate.mockRejectedValue(new Error('refused'));
cache.ping.mockResolvedValue('PONG');
resolve(s3);
resolve(smtp);
const { httpStatus } = await runReadiness();
expect(httpStatus).toBe(503);
});
test('S3 down → HTTP 200, degraded — non-critical stays routable', async () => {
resolve(db.authenticate);
cache.ping.mockResolvedValue('PONG');
s3.mockRejectedValue(new Error('S3 storage unavailable'));
resolve(smtp);
const { httpStatus, body } = await runReadiness();
expect(httpStatus).toBe(200);
expect(body.status).toBe('degraded');
});
test('body has version, uptime, timestamp, checks, memory', async () => {
allHealthy();
const { body } = await runReadiness();
expect(body).toHaveProperty('version');
expect(body).toHaveProperty('uptime');
expect(body).toHaveProperty('timestamp');
expect(body).toHaveProperty('checks');
expect(body).toHaveProperty('memory');
});
test('checks object has database, cache, storage, smtp keys', async () => {
allHealthy();
const { body } = await runReadiness();
['database', 'cache', 'storage', 'smtp'].forEach((key) => {
expect(body.checks).toHaveProperty(key);
});
});
test('memory snapshot has heap_used_mb, heap_total_mb, rss_mb, external_mb', async () => {
allHealthy();
const { body } = await runReadiness();
expect(body.memory).toMatchObject({
heap_used_mb: expect.any(Number),
heap_total_mb: expect.any(Number),
rss_mb: expect.any(Number),
external_mb: expect.any(Number),
});
});
test('database check entry has status=unhealthy and critical=true when DB fails', async () => {
db.authenticate.mockRejectedValue(new Error('refused'));
cache.ping.mockResolvedValue('PONG');
resolve(s3);
resolve(smtp);
const { body } = await runReadiness();
expect(body.checks.database.status).toBe('unhealthy');
expect(body.checks.database.critical).toBe(true);
});
test('storage check entry has status=unhealthy and critical=false when S3 fails', async () => {
resolve(db.authenticate);
cache.ping.mockResolvedValue('PONG');
s3.mockRejectedValue(new Error('S3 storage unavailable'));
resolve(smtp);
const { body } = await runReadiness();
expect(body.checks.storage.status).toBe('unhealthy');
expect(body.checks.storage.critical).toBe(false);
});
test('cache check entry has status=skipped when redis is null (CACHE_DRIVER=memory)', async () => {
allHealthy();
const { body } = await runReadiness();
// cache mock returns { ping: fn } so it is enabled; but the skipped test
// is covered when redis module exports null — we verify the shape here instead
expect(['healthy', 'skipped']).toContain(body.checks.cache.status);
});
});
+143
View File
@@ -0,0 +1,143 @@
'use strict';
const { evaluateCourseAccess, TIER_RANK } = require('../../utils/accessPolicy.util');
// ── Helpers ───────────────────────────────────────────────────────────────────
function ctx(tier = 'free', access_rules = [], group_ids = []) {
return { tier, access_rules, group_ids };
}
function course(subscription = 'free') {
return { subscription };
}
// ── Free course is always accessible ─────────────────────────────────────────
describe('free course (rank 0)', () => {
test('free user can access free course', () => {
expect(evaluateCourseAccess(ctx('free'), course('free'))).toEqual({ allowed: true, reason: null });
});
test('premium user can access free course', () => {
expect(evaluateCourseAccess(ctx('premium'), course('free'))).toEqual({ allowed: true, reason: null });
});
test('free user with no rules can still access free course', () => {
expect(evaluateCourseAccess(ctx('free', []), course('free'))).toEqual({ allowed: true, reason: null });
});
});
// ── No access_rules — fallback tier rank comparison ──────────────────────────
describe('no access_rules (fallback mode)', () => {
test('premium user can access premium course', () => {
const result = evaluateCourseAccess(ctx('premium', []), course('premium'), TIER_RANK);
expect(result).toEqual({ allowed: true, reason: null });
});
test('exclusive user can access exclusive course', () => {
const result = evaluateCourseAccess(ctx('exclusive', []), course('exclusive'), TIER_RANK);
expect(result).toEqual({ allowed: true, reason: null });
});
test('free user cannot access premium course', () => {
const result = evaluateCourseAccess(ctx('free', []), course('premium'), TIER_RANK);
expect(result).toEqual({ allowed: false, reason: 'tier_rank' });
});
test('premium user cannot access exclusive course', () => {
const result = evaluateCourseAccess(ctx('premium', []), course('exclusive'), TIER_RANK);
expect(result).toEqual({ allowed: false, reason: 'tier_rank' });
});
test('unknown course subscription slug → denied (safe default)', () => {
const result = evaluateCourseAccess(ctx('exclusive', []), course('unknown-tier'), TIER_RANK);
expect(result.allowed).toBe(false);
});
});
// ── Rule: course_subscription_access ─────────────────────────────────────────
describe('rule: course_subscription_access', () => {
const rules = [{ type: 'course_subscription_access', levels: ['free', 'premium'] }];
test('plan covers premium → allowed', () => {
const result = evaluateCourseAccess(ctx('premium', rules), course('premium'), TIER_RANK);
expect(result.allowed).toBe(true);
});
test('plan does not cover exclusive → denied', () => {
const result = evaluateCourseAccess(ctx('exclusive', rules), course('exclusive'), TIER_RANK);
expect(result).toEqual({ allowed: false, reason: 'subscription_access' });
});
});
// ── Rule: required_active_tier ────────────────────────────────────────────────
describe('rule: required_active_tier', () => {
const rules = [{ type: 'required_active_tier', tier: 'premium' }];
test('premium user meets premium requirement → allowed', () => {
const result = evaluateCourseAccess(ctx('premium', rules), course('premium'), TIER_RANK);
expect(result.allowed).toBe(true);
});
test('exclusive user exceeds premium requirement → allowed', () => {
const result = evaluateCourseAccess(ctx('exclusive', rules), course('premium'), TIER_RANK);
expect(result.allowed).toBe(true);
});
test('free user fails premium requirement → denied', () => {
const result = evaluateCourseAccess(ctx('free', rules), course('premium'), TIER_RANK);
expect(result).toEqual({ allowed: false, reason: 'required_tier' });
});
test('unknown required tier slug → always denied', () => {
const badRules = [{ type: 'required_active_tier', tier: 'ghost-tier' }];
const result = evaluateCourseAccess(ctx('exclusive', badRules), course('premium'), TIER_RANK);
expect(result.allowed).toBe(false);
});
});
// ── Rule: group_restriction ───────────────────────────────────────────────────
describe('rule: group_restriction', () => {
const rules = [{ type: 'group_restriction', group_ids: [5, 10] }];
test('user in allowed group → allowed', () => {
const result = evaluateCourseAccess(ctx('premium', rules, [10, 20]), course('premium'), TIER_RANK);
expect(result.allowed).toBe(true);
});
test('user not in any allowed group → denied', () => {
const result = evaluateCourseAccess(ctx('premium', rules, [99]), course('premium'), TIER_RANK);
expect(result).toEqual({ allowed: false, reason: 'group_restriction' });
});
test('empty group_ids on rule → no restriction (passes)', () => {
const openRules = [{ type: 'group_restriction', group_ids: [] }];
const result = evaluateCourseAccess(ctx('premium', openRules, []), course('premium'), TIER_RANK);
expect(result.allowed).toBe(true);
});
});
// ── Multiple rules evaluated together ────────────────────────────────────────
describe('multiple rules', () => {
test('fails if any one rule blocks', () => {
const rules = [
{ type: 'course_subscription_access', levels: ['premium', 'exclusive'] },
{ type: 'required_active_tier', tier: 'premium' },
{ type: 'group_restriction', group_ids: [7] },
];
// All pass
expect(evaluateCourseAccess(ctx('premium', rules, [7]), course('premium'), TIER_RANK).allowed).toBe(true);
// Fails group_restriction
expect(evaluateCourseAccess(ctx('premium', rules, [99]), course('premium'), TIER_RANK).allowed).toBe(false);
// Fails required_active_tier
expect(evaluateCourseAccess(ctx('free', rules, [7]), course('premium'), TIER_RANK).allowed).toBe(false);
});
});
+74
View File
@@ -0,0 +1,74 @@
'use strict';
const { fmtDate, fmtDateTime, fmtTime } = require('../../utils/datetime.util');
const ISO = '2026-06-20T14:30:00.000Z'; // Saturday, June 20, 2026, 2:30 PM UTC
// ── fmtDate ───────────────────────────────────────────────────────────────────
describe('fmtDate()', () => {
test('null → returns —', () => {
expect(fmtDate(null)).toBe('—');
});
test('empty string → returns —', () => {
expect(fmtDate('')).toBe('—');
});
test('formats year correctly', () => {
expect(fmtDate(ISO)).toContain('2026');
});
test('formats month correctly (June)', () => {
expect(fmtDate(ISO)).toContain('June');
});
test('formats day correctly (20)', () => {
expect(fmtDate(ISO)).toContain('20');
});
test('accepts Date object as well as ISO string', () => {
const d = new Date(ISO);
expect(fmtDate(d)).toContain('2026');
});
});
// ── fmtDateTime ───────────────────────────────────────────────────────────────
describe('fmtDateTime()', () => {
test('null → returns —', () => {
expect(fmtDateTime(null)).toBe('—');
});
test('contains date portion', () => {
expect(fmtDateTime(ISO)).toContain('2026');
expect(fmtDateTime(ISO)).toContain('June');
});
test('contains time portion (2:30 PM UTC)', () => {
const out = fmtDateTime(ISO);
expect(out).toMatch(/2:30/);
expect(out).toContain('UTC');
});
});
// ── fmtTime ───────────────────────────────────────────────────────────────────
describe('fmtTime()', () => {
test('null → returns —', () => {
expect(fmtTime(null)).toBe('—');
});
test('outputs time with AM/PM', () => {
expect(fmtTime(ISO)).toMatch(/\d+:\d{2}\s*(AM|PM)/);
});
test('contains timezone label (UTC)', () => {
expect(fmtTime(ISO)).toContain('UTC');
});
test('locale option changes output', () => {
const en = fmtTime(ISO, { locale: 'en-US' });
expect(en).toBeTruthy();
});
});
+83
View File
@@ -0,0 +1,83 @@
'use strict';
const { generateOTP, getOTPExpiry, isOTPExpired } = require('../../utils/otp.util');
// ── generateOTP ───────────────────────────────────────────────────────────────
describe('generateOTP()', () => {
test('returns a 6-character string', () => {
expect(generateOTP()).toHaveLength(6);
});
test('contains only digits', () => {
expect(generateOTP()).toMatch(/^\d{6}$/);
});
test('value is within 000000-999999', () => {
const n = Number(generateOTP());
expect(n).toBeGreaterThanOrEqual(0);
expect(n).toBeLessThanOrEqual(999999);
});
test('zero-pads values under 100000', () => {
// Run many samples to catch low values; deterministic check via string length
for (let i = 0; i < 50; i++) {
expect(generateOTP()).toHaveLength(6);
}
});
});
// ── getOTPExpiry ──────────────────────────────────────────────────────────────
describe('getOTPExpiry()', () => {
test('returns a Date instance', () => {
expect(getOTPExpiry()).toBeInstanceOf(Date);
});
test('expiry is in the future', () => {
expect(getOTPExpiry().getTime()).toBeGreaterThan(Date.now());
});
test('default offset is approximately 10 minutes', () => {
const before = Date.now();
const expiry = getOTPExpiry().getTime();
const after = Date.now();
const tenMin = 10 * 60 * 1000;
expect(expiry).toBeGreaterThanOrEqual(before + tenMin - 100);
expect(expiry).toBeLessThanOrEqual(after + tenMin + 100);
});
test('custom minutes are respected', () => {
const before = Date.now();
const expiry = getOTPExpiry(5).getTime();
const fiveMin = 5 * 60 * 1000;
expect(expiry).toBeGreaterThanOrEqual(before + fiveMin - 100);
});
});
// ── isOTPExpired ──────────────────────────────────────────────────────────────
describe('isOTPExpired()', () => {
test('null → expired (true)', () => {
expect(isOTPExpired(null)).toBe(true);
});
test('undefined → expired (true)', () => {
expect(isOTPExpired(undefined)).toBe(true);
});
test('past date → expired (true)', () => {
const past = new Date(Date.now() - 60_000);
expect(isOTPExpired(past)).toBe(true);
});
test('future date → not expired (false)', () => {
const future = new Date(Date.now() + 60_000);
expect(isOTPExpired(future)).toBe(false);
});
test('accepts ISO string as well as Date', () => {
const future = new Date(Date.now() + 60_000).toISOString();
expect(isOTPExpired(future)).toBe(false);
});
});
+92
View File
@@ -0,0 +1,92 @@
'use strict';
const R = require('../../utils/response.util');
function makeRes() {
const res = {
_status: null,
_body: null,
status(code) { this._status = code; return this; },
json(body) { this._body = body; return this; },
};
return res;
}
// ── R.success ─────────────────────────────────────────────────────────────────
describe('R.success()', () => {
test('default status is 200', () => {
const res = makeRes();
R.success(res, 'OK');
expect(res._status).toBe(200);
});
test('envelope shape: status=success, message, data', () => {
const res = makeRes();
R.success(res, 'Created', { id: 1 }, 201);
expect(res._body).toEqual({ status: 'success', message: 'Created', data: { id: 1 } });
});
test('custom status code is used', () => {
const res = makeRes();
R.success(res, 'Created', null, 201);
expect(res._status).toBe(201);
});
test('data is null when omitted', () => {
const res = makeRes();
R.success(res, 'OK');
expect(res._body.data).toBeNull();
});
});
// ── R.error ───────────────────────────────────────────────────────────────────
describe('R.error()', () => {
test('default status is 500', () => {
const res = makeRes();
R.error(res, 'Something broke');
expect(res._status).toBe(500);
});
test('envelope shape: status=error, message', () => {
const res = makeRes();
R.error(res, 'Not found', 404);
expect(res._body).toMatchObject({ status: 'error', message: 'Not found' });
expect(res._status).toBe(404);
});
test('errors field is absent when not provided', () => {
const res = makeRes();
R.error(res, 'Bad request', 400);
expect(res._body).not.toHaveProperty('errors');
});
test('errors field is included when provided', () => {
const res = makeRes();
const errs = [{ field: 'email', msg: 'Invalid' }];
R.error(res, 'Validation failed', 422, errs);
expect(res._body.errors).toEqual(errs);
});
});
// ── R.validationError ─────────────────────────────────────────────────────────
describe('R.validationError()', () => {
test('status is always 422', () => {
const res = makeRes();
R.validationError(res, []);
expect(res._status).toBe(422);
});
test('envelope shape: status=error, message=Validation failed, errors', () => {
const res = makeRes();
const errs = [{ field: 'name', msg: 'Required' }];
R.validationError(res, errs);
expect(res._body).toEqual({
status: 'error',
message: 'Validation failed',
errors: errs,
});
});
});
+128
View File
@@ -0,0 +1,128 @@
'use strict';
// Set secrets BEFORE requiring — jwt.sign/verify reads process.env at call time
// but we want a clean, isolated secret that does not change across test runs.
process.env.JWT_SECRET = 'test-jwt-secret-32chars-padding!!';
process.env.JWT_REFRESH_SECRET = 'test-refresh-secret-32chars-pad!!';
process.env.JWT_EXPIRES_IN = '15m';
process.env.JWT_REFRESH_EXPIRES_IN = '7d';
const jwt = require('jsonwebtoken');
const {
generateTokens,
verifyAccessToken,
verifyRefreshToken,
hashToken,
shouldRotateRefreshToken,
} = require('../../utils/token.util');
const MOCK_USER = { user_id: 1, email: 'test@example.com', acc_type: 'user', reg_type: 'system' };
// ── generateTokens ────────────────────────────────────────────────────────────
describe('generateTokens()', () => {
test('returns accessToken and refreshToken strings', () => {
const { accessToken, refreshToken } = generateTokens(MOCK_USER);
expect(typeof accessToken).toBe('string');
expect(typeof refreshToken).toBe('string');
});
test('accessToken contains correct payload fields', () => {
const { accessToken } = generateTokens(MOCK_USER);
const decoded = jwt.decode(accessToken);
expect(decoded.user_id).toBe(MOCK_USER.user_id);
expect(decoded.email).toBe(MOCK_USER.email);
expect(decoded.acc_type).toBe(MOCK_USER.acc_type);
expect(decoded.reg_type).toBe(MOCK_USER.reg_type);
});
test('refreshToken contains only user_id', () => {
const { refreshToken } = generateTokens(MOCK_USER);
const decoded = jwt.decode(refreshToken);
expect(decoded.user_id).toBe(MOCK_USER.user_id);
expect(decoded.email).toBeUndefined();
});
});
// ── verifyAccessToken ─────────────────────────────────────────────────────────
describe('verifyAccessToken()', () => {
test('valid token → returns decoded payload', () => {
const { accessToken } = generateTokens(MOCK_USER);
const decoded = verifyAccessToken(accessToken);
expect(decoded.user_id).toBe(MOCK_USER.user_id);
});
test('tampered token → throws JsonWebTokenError', () => {
const { accessToken } = generateTokens(MOCK_USER);
expect(() => verifyAccessToken(accessToken + 'tampered')).toThrow();
});
test('expired token → throws TokenExpiredError', () => {
const expired = jwt.sign(
{ user_id: 99, exp: Math.floor(Date.now() / 1000) - 10 },
process.env.JWT_SECRET
);
let err;
try { verifyAccessToken(expired); } catch (e) { err = e; }
expect(err.name).toBe('TokenExpiredError');
});
test('token signed with wrong secret → throws', () => {
const bad = jwt.sign({ user_id: 1 }, 'wrong-secret');
expect(() => verifyAccessToken(bad)).toThrow();
});
});
// ── verifyRefreshToken ────────────────────────────────────────────────────────
describe('verifyRefreshToken()', () => {
test('valid refresh token → returns payload with user_id', () => {
const { refreshToken } = generateTokens(MOCK_USER);
const decoded = verifyRefreshToken(refreshToken);
expect(decoded.user_id).toBe(MOCK_USER.user_id);
});
test('access token used as refresh token → throws (different secret)', () => {
const { accessToken } = generateTokens(MOCK_USER);
expect(() => verifyRefreshToken(accessToken)).toThrow();
});
});
// ── hashToken ─────────────────────────────────────────────────────────────────
describe('hashToken()', () => {
test('returns a 64-character hex string (SHA-256)', () => {
expect(hashToken('some-token')).toMatch(/^[a-f0-9]{64}$/);
});
test('is deterministic — same input produces same hash', () => {
expect(hashToken('abc')).toBe(hashToken('abc'));
});
test('different inputs produce different hashes', () => {
expect(hashToken('token-a')).not.toBe(hashToken('token-b'));
});
});
// ── shouldRotateRefreshToken ──────────────────────────────────────────────────
describe('shouldRotateRefreshToken()', () => {
const now = Math.floor(Date.now() / 1000);
test('expires in 10 minutes (within 1-day threshold) → should rotate', () => {
const decoded = { exp: now + 600 };
expect(shouldRotateRefreshToken(decoded, 1)).toBe(true);
});
test('expires in 5 days (well beyond 1-day threshold) → should not rotate', () => {
const decoded = { exp: now + 5 * 24 * 60 * 60 };
expect(shouldRotateRefreshToken(decoded, 1)).toBe(false);
});
test('custom threshold is respected', () => {
const decoded = { exp: now + 2 * 24 * 60 * 60 }; // 2 days left
expect(shouldRotateRefreshToken(decoded, 3)).toBe(true); // 3-day threshold → rotate
expect(shouldRotateRefreshToken(decoded, 1)).toBe(false); // 1-day threshold → don't rotate
});
});
+2 -2
View File
@@ -3,8 +3,8 @@
/** /**
* Single archive — soft delete one record * Single archive — soft delete one record
*/ */
async function archiveOne(Model, options, deletedBy, transaction) { async function archiveOne(Model, where, deletedBy, transaction) {
const record = await Model.findOne(options); const record = await Model.findOne({ where, transaction });
if (!record) return null; if (!record) return null;
await record.update({ deletedBy: deletedBy ?? null }, { transaction }); await record.update({ deletedBy: deletedBy ?? null }, { transaction });
await record.destroy({ transaction }); await record.destroy({ transaction });
+171
View File
@@ -0,0 +1,171 @@
/***********************************************************************************************************************************************************************
* File Name: currency.util.js
* Type of Program: Utility
* Description: Currency formatting and resolution helpers for backend use (emails, receipts, notifications).
*
* All format functions accept an optional options object: { locale }
* locale — BCP 47 tag, defaults to 'en-US'
*
* USD is the platform's base/canonical currency. Plans may carry localized price
* overrides (plan_prices table). resolvePrice() applies the COALESCE logic:
* localized override wins → falls back to plan's base price + currency.
***********************************************************************************************************************************************************************/
'use strict';
// ─── Supported currencies ─────────────────────────────────────────────────────
const SUPPORTED_CURRENCIES = [
{ code: 'USD', name: 'US Dollar', symbol: '$' },
{ code: 'EUR', name: 'Euro', symbol: '€' },
{ code: 'GBP', name: 'British Pound', symbol: '£' },
{ code: 'CNY', name: 'Chinese Yuan', symbol: '¥' },
{ code: 'JPY', name: 'Japanese Yen', symbol: '¥' },
{ code: 'PHP', name: 'Philippine Peso', symbol: '₱' },
{ code: 'KRW', name: 'South Korean Won', symbol: '₩' },
{ code: 'AUD', name: 'Australian Dollar', symbol: 'A$' },
{ code: 'CAD', name: 'Canadian Dollar', symbol: 'C$' },
{ code: 'SGD', name: 'Singapore Dollar', symbol: 'S$' },
{ code: 'HKD', name: 'Hong Kong Dollar', symbol: 'HK$'},
{ code: 'INR', name: 'Indian Rupee', symbol: '₹' },
{ code: 'MYR', name: 'Malaysian Ringgit', symbol: 'RM' },
{ code: 'THB', name: 'Thai Baht', symbol: '฿' },
{ code: 'IDR', name: 'Indonesian Rupiah', symbol: 'Rp' },
{ code: 'TWD', name: 'Taiwan Dollar', symbol: 'NT$'},
{ code: 'VND', name: 'Vietnamese Dong', symbol: '₫' },
];
const SUPPORTED_CURRENCY_CODES = new Set(SUPPORTED_CURRENCIES.map((c) => c.code));
function isSupported(code) {
return SUPPORTED_CURRENCY_CODES.has(code?.toUpperCase());
}
// ─── Formatting ───────────────────────────────────────────────────────────────
/** "¥1,299.00" / "$9.99" */
function fmtCurrency(amount, currency = 'USD', { locale = 'en-US' } = {}) {
if (amount === null || amount === undefined) return '—';
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency ?? 'USD',
minimumFractionDigits: 2,
}).format(Number(amount));
}
// ─── Price resolution ─────────────────────────────────────────────────────────
/**
* Returns the effective { price, currency } for a plan given a user's preferred currency.
* plan.prices must be eager-loaded (as: 'prices') for the override to be considered.
* Falls back to plan.price + plan.currency when no override exists.
*/
function resolvePrice(plan, preferredCurrency) {
if (!preferredCurrency || preferredCurrency === plan.currency)
return { price: Number(plan.price), currency: plan.currency };
const override = (plan.prices ?? []).find((p) => p.currency === preferredCurrency);
if (override) return { price: Number(override.price), currency: override.currency };
return { price: Number(plan.price), currency: plan.currency };
}
// ─── Exchange rate fetching ───────────────────────────────────────────────────
// Uses frankfurter.app (ECB-backed, no API key, free).
// In-process cache with 1-hour TTL avoids hammering the API on every save.
const _rateCache = new Map();
async function fetchExchangeRate(from, to) {
if (from === to) return 1;
const key = `${from}:${to}`;
const now = Date.now();
const cached = _rateCache.get(key);
if (cached && cached.expiresAt > now) return cached.rate;
try {
const res = await fetch(
`https://api.frankfurter.app/latest?from=${from}&to=${to}`,
{ signal: AbortSignal.timeout(4000) },
);
if (!res.ok) return null;
const json = await res.json();
const rate = json?.rates?.[to];
if (!rate) return null;
_rateCache.set(key, { rate, expiresAt: now + 60 * 60 * 1000 }); // 1 h TTL
return rate;
} catch {
return null;
}
}
// ─── Localized price validation ───────────────────────────────────────────────
// Three zones relative to the market-rate conversion of the base price:
//
// pass → 85 % – 150 % of expected (green, saves normally)
// warn → 70 % – 85 % or 150 % – 300 % (saves with caution message)
// block → < 70 % or > 300 % (rejected — too far from market rate)
//
// If the exchange-rate API is unavailable the check is skipped (returns 'pass').
const PRICE_ZONES = {
LOWER_HARD: 0.70,
LOWER_WARN: 0.85,
UPPER_WARN: 1.50,
UPPER_HARD: 3.00,
};
async function validateLocalizedPrice(basePrice, baseCurrency, localizedPrice, targetCurrency) {
const rate = await fetchExchangeRate(baseCurrency, targetCurrency);
if (!rate) return { zone: 'pass', skipped: true };
const expected = Number(basePrice) * rate;
const entered = Number(localizedPrice);
const { LOWER_HARD, LOWER_WARN, UPPER_WARN, UPPER_HARD } = PRICE_ZONES;
const hardMin = expected * LOWER_HARD;
const hardMax = expected * UPPER_HARD;
const warnMin = expected * LOWER_WARN;
const warnMax = expected * UPPER_WARN;
const fmt = (n) => n.toFixed(2);
const rateStr = `1 ${baseCurrency} = ${rate} ${targetCurrency}`;
if (entered < hardMin || entered > hardMax) {
return {
zone: 'block',
expected: fmt(expected),
hardMin: fmt(hardMin), hardMax: fmt(hardMax),
warnMin: fmt(warnMin), warnMax: fmt(warnMax),
message: `${fmt(entered)} ${targetCurrency} is too far from the current market rate (${rateStr}). ` +
`Acceptable range: ${fmt(hardMin)} – ${fmt(hardMax)} ${targetCurrency}.`,
};
}
if (entered < warnMin || entered > warnMax) {
return {
zone: 'warn',
expected: fmt(expected),
hardMin: fmt(hardMin), hardMax: fmt(hardMax),
warnMin: fmt(warnMin), warnMax: fmt(warnMax),
message: `${fmt(entered)} ${targetCurrency} is outside the suggested range (${rateStr}). ` +
`Suggested: ${fmt(warnMin)} – ${fmt(warnMax)} ${targetCurrency}. Saved with caution.`,
};
}
return {
zone: 'pass',
expected: fmt(expected),
hardMin: fmt(hardMin), hardMax: fmt(hardMax),
warnMin: fmt(warnMin), warnMax: fmt(warnMax),
};
}
module.exports = {
SUPPORTED_CURRENCIES,
isSupported,
fmtCurrency,
resolvePrice,
fetchExchangeRate,
validateLocalizedPrice,
PRICE_ZONES,
};