+13
-6
@@ -92,19 +92,26 @@ EMAIL_FROM=CHANGE_ME@gmail.com
|
||||
OTP_EXPIRY_MINUTES=10
|
||||
|
||||
# ── S3-compatible Storage (Garage self-hosted) ────────────────────────────────
|
||||
# S3_ENDPOINT: internal address of your Garage node (e.g. http://127.0.0.1:3900
|
||||
# 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
|
||||
# (e.g. https://cdn.yourdomain.com)
|
||||
S3_ENDPOINT=http://127.0.0.1:3900
|
||||
# Garage is bundled in docker-compose.yml — no separate install needed.
|
||||
#
|
||||
# Docker setup: S3_ENDPOINT=http://garage:3900 (use the service name)
|
||||
# Bare-metal: 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_ACCESS_KEY=CHANGE_ME
|
||||
S3_SECRET_KEY=CHANGE_ME
|
||||
S3_BUCKET=CHANGE_ME
|
||||
S3_PUBLIC_URL=https://cdn.yourdomain.com
|
||||
GARAGE_RPC_SECRET=CHANGE_ME_32_BYTE_HEX
|
||||
|
||||
# ── 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_API_KEY=CHANGE_ME
|
||||
CHIBISAFE_ALBUM_AVATARS=CHANGE_ME_UUID
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
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");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ const base = {
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
host: process.env.DB_HOST,
|
||||
port: parseInt(process.env.DB_PORT, 10) || 26257,
|
||||
port: parseInt(process.env.DB_PORT, 10) || 5432,
|
||||
dialect: 'postgres',
|
||||
dialectOptions: {
|
||||
...(useSSL && {
|
||||
|
||||
@@ -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"
|
||||
@@ -23,7 +23,7 @@ const {
|
||||
CoursePrerequisite, CourseAssessment,
|
||||
UnitQuiz, QuizQuestion, QuizOption,
|
||||
QuizAttempt, AssessmentSession,
|
||||
CourseInstructor,
|
||||
CourseInstructor, CourseAchievement,
|
||||
} = require("../../models/courses/courses.associations");
|
||||
|
||||
const mdl_Users = require("../../models/users/users.mdl");
|
||||
@@ -100,6 +100,8 @@ exports.createCourse = async (req, res) => {
|
||||
course_code, level, subscription,
|
||||
objectives = [],
|
||||
category_ids = [],
|
||||
achievement_keys = [],
|
||||
badge_color, badge_asset_id, badge_image_url,
|
||||
createdBy,
|
||||
} = req.body;
|
||||
|
||||
@@ -113,12 +115,22 @@ exports.createCourse = async (req, res) => {
|
||||
level: level ?? null,
|
||||
subscription: subscription ?? "free",
|
||||
duration_seconds: 0,
|
||||
badge_color: badge_color ?? "purple",
|
||||
badge_asset_id: badge_asset_id ?? null,
|
||||
badge_image_url: badge_image_url ?? null,
|
||||
createdBy: createdBy ?? null,
|
||||
}, { transaction: t });
|
||||
|
||||
await syncObjectivesCreate(CourseObjective, "course_id", course.course_id, objectives, 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();
|
||||
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);
|
||||
@@ -140,6 +152,7 @@ exports.updateCourse = async (req, res) => {
|
||||
title, description, order_index,
|
||||
course_code, level, subscription,
|
||||
objectives, category_ids,
|
||||
badge_color, badge_asset_id, badge_image_url,
|
||||
updatedBy,
|
||||
} = req.body;
|
||||
|
||||
@@ -149,6 +162,9 @@ exports.updateCourse = async (req, res) => {
|
||||
if (course_code !== undefined) course.course_code = course_code;
|
||||
if (level !== undefined) course.level = level;
|
||||
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;
|
||||
await course.save({ transaction: t });
|
||||
|
||||
@@ -1760,3 +1776,52 @@ exports.getAssessmentSessions = async (req, res) => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -113,16 +113,31 @@ exports.issueTokensBatch = async (req, res) => {
|
||||
storage_provider: "s3",
|
||||
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 tokens = {};
|
||||
const thumbnails = {};
|
||||
|
||||
for (const asset of assets) {
|
||||
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) {
|
||||
console.error("[ADMIN][MEDIA][TOKENS BATCH]", err);
|
||||
return R.error(res, "Could not issue media tokens.", 500);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl');
|
||||
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
|
||||
const mdl_PlanPolicies = require('../../models/tiers/plan_policies.mdl');
|
||||
const mdl_PaymentPolicies = require('../../models/tiers/payment_policies.mdl');
|
||||
const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl');
|
||||
const Asset = require('../../models/assets/assets.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
@@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getSystemBadges = async (req, res) => {
|
||||
|
||||
@@ -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) => {
|
||||
try {
|
||||
const { tier_category_id, label, duration_days, price, currency } = req.body;
|
||||
if (!tier_category_id || !label || !duration_days || !price)
|
||||
return R.error(res, 'tier_category_id, label, duration_days, and price are required.', 400);
|
||||
const { tier_category_id, label, description, duration_value, duration_unit = 'day', price, currency } = req.body;
|
||||
if (!tier_category_id || !label || !duration_value || !price)
|
||||
return R.error(res, 'tier_category_id, label, duration_value, and price are required.', 400);
|
||||
|
||||
const category = await mdl_TierCategories.findByPk(tier_category_id);
|
||||
if (!category || !category.is_active)
|
||||
@@ -96,10 +103,12 @@ exports.createPlan = async (req, res) => {
|
||||
if (category.is_default)
|
||||
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({
|
||||
tier_category_id: category.tier_category_id,
|
||||
tier: category.slug,
|
||||
label, duration_days, price, currency,
|
||||
label, description, duration_days, duration_unit, price, currency,
|
||||
});
|
||||
const plain = plan.get({ plain: true });
|
||||
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);
|
||||
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 = {};
|
||||
for (const k of allowed) {
|
||||
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 (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) => {
|
||||
try {
|
||||
const plan = await mdl_TierPlans.findByPk(req.params.id);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
const mdl_CoursePurchase = require('../../models/courses/course_purchases.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');
|
||||
|
||||
// ─── CREATE ORDER ─────────────────────────────────────────────────────────────
|
||||
@@ -14,7 +14,6 @@ exports.createCourseOrder = async (req, res) => {
|
||||
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);
|
||||
|
||||
// Block if user already has an active completed purchase for this product
|
||||
const existing = await mdl_CoursePurchase.findOne({
|
||||
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);
|
||||
}
|
||||
|
||||
const ppOrder = await paypal.createOrder({
|
||||
const ppOrder = await paymentSvc.createOrder('paypal', {
|
||||
amount: Number(product.price).toFixed(2),
|
||||
currency: product.currency,
|
||||
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);
|
||||
|
||||
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' }],
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
@@ -79,13 +78,13 @@ exports.captureCourseOrder = async (req, res) => {
|
||||
|
||||
let captureData;
|
||||
try {
|
||||
captureData = await paypal.captureOrder(order_id);
|
||||
captureData = await paymentSvc.captureOrder(purchase.provider, order_id);
|
||||
} catch (ppErr) {
|
||||
await purchase.update({
|
||||
status: 'failed',
|
||||
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];
|
||||
@@ -120,7 +119,7 @@ exports.cancelCourseOrder = async (req, res) => {
|
||||
if (!order_id) return R.error(res, 'order_id is required.', 400);
|
||||
|
||||
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']],
|
||||
});
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ const COURSE_LIST_ATTRS = [
|
||||
"course_id", "uuid", "title", "description",
|
||||
"course_code", "level", "subscription",
|
||||
"duration_seconds", "order_index",
|
||||
"badge_color", "badge_asset_id", "badge_image_url",
|
||||
];
|
||||
|
||||
// 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) ───────────────────────────
|
||||
|
||||
exports.getCourses = async (req, res) => {
|
||||
@@ -276,6 +293,11 @@ exports.getCourse = async (req, res) => {
|
||||
"is_required", "passing_score",
|
||||
"time_limit_minutes", "max_questions",
|
||||
],
|
||||
include: [{
|
||||
model: QuizQuestion, as: "questions",
|
||||
attributes: ["question_id"],
|
||||
required: false,
|
||||
}],
|
||||
},
|
||||
],
|
||||
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 },
|
||||
});
|
||||
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;
|
||||
|
||||
@@ -957,7 +985,7 @@ exports.getCourseByUuid = async (req, res) => {
|
||||
const { uuid } = req.params;
|
||||
const course = await Course.findOne({
|
||||
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);
|
||||
|
||||
|
||||
@@ -122,6 +122,11 @@ function pipeRemoteStream(remoteUrl, req, res) {
|
||||
//
|
||||
// S3 assets only — Chibisafe assets use their raw file_url directly.
|
||||
// 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) => {
|
||||
try {
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
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"
|
||||
const deriveStorageKey = (fileUrl) => {
|
||||
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}/`;
|
||||
|
||||
if (fileUrl && fileUrl.startsWith(prefix)) {
|
||||
|
||||
@@ -71,7 +71,7 @@ const resolveOwnerType = (mimetype = '') => {
|
||||
//
|
||||
// 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_size : 2400000,
|
||||
// mime_type : "application/pdf",
|
||||
|
||||
@@ -4,47 +4,29 @@
|
||||
* Description: User-facing tier and payment endpoints.
|
||||
* - View active tier + history
|
||||
* - 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
|
||||
* Author: rgrgogu
|
||||
* Date Created: Jun. 6, 2026
|
||||
* Modified: Jun. 9, 2026
|
||||
* Modified: Jun. 29, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
const mdl_TierCategories = require('../../models/tiers/tier_categories.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_Payments = require('../../models/tiers/payments.mdl');
|
||||
const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl');
|
||||
const Asset = require('../../models/assets/assets.mdl');
|
||||
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
||||
const { onTierActivated } = require('../../services/achievements.service');
|
||||
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 { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
||||
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getMyTier = async (req, res) => {
|
||||
@@ -59,27 +41,39 @@ exports.getMyTier = async (req, res) => {
|
||||
model: mdl_TierCategories,
|
||||
as: 'category',
|
||||
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']],
|
||||
});
|
||||
|
||||
// ── 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) {
|
||||
// Free users with no user_tier row: look up free category badge
|
||||
const freeCategory = await mdl_TierCategories.findOne({
|
||||
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 });
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const category = await mdl_TierCategories.findOne({
|
||||
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();
|
||||
plain.category = category?.toJSON() ?? null;
|
||||
@@ -106,20 +100,26 @@ exports.getMyTierHistory = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PLANS (with courses) ─────────────────────────────────────────────────────
|
||||
// ─── PLANS ────────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getPlans = async (req, res) => {
|
||||
try {
|
||||
const plans = await mdl_TierPlans.findAll({
|
||||
where: { is_active: true },
|
||||
order: [['tier', 'ASC'], ['duration_days', 'ASC']],
|
||||
attributes: ['plan_id', 'tier', 'label', 'duration_days', 'price', 'currency'],
|
||||
include: [{
|
||||
attributes: ['plan_id', 'tier', 'label', 'description', 'duration_days', 'duration_unit', 'price', 'currency', 'is_active'],
|
||||
include: [
|
||||
{
|
||||
model: Course,
|
||||
as: 'courses',
|
||||
attributes: ['course_id', 'uuid', 'title', 'course_code', 'level', 'duration_seconds'],
|
||||
through: { attributes: [] },
|
||||
}],
|
||||
},
|
||||
{
|
||||
model: mdl_PlanPrices,
|
||||
as: 'prices',
|
||||
attributes: ['currency', 'price'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = plans.map((p) => {
|
||||
@@ -135,45 +135,97 @@ 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) => {
|
||||
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);
|
||||
|
||||
const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } });
|
||||
if (!plan) return R.error(res, 'Plan not found or inactive.', 404);
|
||||
|
||||
const checkout = calculateCheckoutAmount(plan.price, promo_code);
|
||||
if (Number(checkout.total) <= 0)
|
||||
// Resolve localized price — falls back to plan base price when no override exists
|
||||
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);
|
||||
|
||||
const ppOrder = await paypal.createOrder({
|
||||
amount: checkout.total,
|
||||
currency: plan.currency,
|
||||
const provider = (policy?.allowed_providers?.[0]) ?? 'paypal';
|
||||
const ppOrder = await paymentSvc.createOrder(provider, {
|
||||
amount: total,
|
||||
currency: effectiveCurrency,
|
||||
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 payment = await mdl_Payments.create({
|
||||
user_id: req.user.user_id,
|
||||
plan_id,
|
||||
status: 'pending',
|
||||
amount: checkout.total,
|
||||
currency: plan.currency,
|
||||
promo_code: checkout.promoCode,
|
||||
discount: checkout.discount,
|
||||
provider: 'paypal',
|
||||
amount: total,
|
||||
currency: effectiveCurrency,
|
||||
promo_code: promoResult.code,
|
||||
discount: discount.toFixed(2),
|
||||
provider,
|
||||
provider_payload: {
|
||||
order_id: ppOrder.id,
|
||||
approval_url: approvalUrl,
|
||||
checkout: {
|
||||
subtotal: checkout.subtotal,
|
||||
discount: checkout.discount,
|
||||
promo_code: checkout.promoCode,
|
||||
subtotal: subtotal.toFixed(2),
|
||||
discount: discount.toFixed(2),
|
||||
promo_code: promoResult.code,
|
||||
base_price: Number(plan.price).toFixed(2),
|
||||
base_currency: plan.currency,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -182,10 +234,10 @@ exports.createOrder = async (req, res) => {
|
||||
payment_id: payment.payment_id,
|
||||
order_id: ppOrder.id,
|
||||
approval_url: approvalUrl,
|
||||
amount: checkout.total,
|
||||
currency: plan.currency,
|
||||
promo_code: checkout.promoCode,
|
||||
discount: checkout.discount,
|
||||
amount: total,
|
||||
currency: effectiveCurrency,
|
||||
promo_code: promoResult.code,
|
||||
discount: discount.toFixed(2),
|
||||
}, 201);
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][CREATE ORDER]', err);
|
||||
@@ -199,36 +251,41 @@ exports.captureOrder = async (req, res) => {
|
||||
if (!order_id) return R.error(res, 'order_id is required.', 400);
|
||||
|
||||
const payment = await mdl_Payments.findOne({
|
||||
where: {
|
||||
status: 'pending',
|
||||
provider: 'paypal',
|
||||
user_id: req.user.user_id,
|
||||
},
|
||||
where: { status: 'pending', user_id: req.user.user_id },
|
||||
include: [{ model: mdl_TierPlans, as: 'plan' }],
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
|
||||
// Match by order_id inside provider_payload
|
||||
if (!payment || payment.provider_payload?.order_id !== order_id)
|
||||
return R.error(res, 'Pending payment not found.', 404);
|
||||
|
||||
// Guard: plan was deactivated while user was on PayPal's approval page
|
||||
if (!payment.plan?.is_active) {
|
||||
await payment.update({
|
||||
status: 'cancelled',
|
||||
provider_payload: {
|
||||
...payment.provider_payload,
|
||||
cancelled_at: new Date().toISOString(),
|
||||
cancelled_by: 'system',
|
||||
cancel_reason: 'plan_deactivated',
|
||||
},
|
||||
});
|
||||
return R.error(res, 'This plan is no longer available. No payment was taken.', 409);
|
||||
}
|
||||
|
||||
let captureData;
|
||||
try {
|
||||
captureData = await paypal.captureOrder(order_id);
|
||||
captureData = await paymentSvc.captureOrder(payment.provider, order_id);
|
||||
} catch (ppErr) {
|
||||
await payment.update({
|
||||
status: 'failed',
|
||||
provider_payload: {
|
||||
...payment.provider_payload,
|
||||
error: ppErr?.response?.data ?? {},
|
||||
},
|
||||
provider_payload: { ...payment.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];
|
||||
|
||||
// Expire current active tier
|
||||
await mdl_UserTiers.update(
|
||||
{ status: 'expired' },
|
||||
{ where: { user_id: req.user.user_id, status: 'active' } }
|
||||
@@ -258,7 +315,7 @@ exports.captureOrder = async (req, res) => {
|
||||
capture: captureData,
|
||||
},
|
||||
});
|
||||
// await grantAchievement(req.user.user_id, payment.plan.tier);
|
||||
|
||||
await onTierActivated(req.user.user_id, newTier.tier);
|
||||
|
||||
return R.success(res, 'Payment successful. Tier activated.', {
|
||||
@@ -277,7 +334,7 @@ exports.cancelOrder = async (req, res) => {
|
||||
if (!order_id) return R.error(res, 'order_id is required.', 400);
|
||||
|
||||
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']],
|
||||
});
|
||||
|
||||
@@ -300,6 +357,78 @@ 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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getMyPayments = async (req, res) => {
|
||||
@@ -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) => {
|
||||
try {
|
||||
const categories = await mdl_TierCategories.findAll({
|
||||
where: { is_active: true },
|
||||
attributes: ['tier_category_id', 'slug', 'name', 'rank', 'color', 'badge_label', 'is_default'],
|
||||
include: [{ model: Asset, as: 'badgeAsset', attributes: ['file_url', 'display_name'], required: false }],
|
||||
attributes: ['tier_category_id', 'slug', 'name', 'rank', 'color', 'badge_icon', 'badge_label', 'is_default'],
|
||||
include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'storage_provider', 'file_url', 'display_name'], required: false }],
|
||||
order: [['rank', 'ASC']],
|
||||
});
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// — 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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* Currently registered:
|
||||
* - userNotifications (cron/jobs/user_notifications.cron.js)
|
||||
* - issueCertificates (cron/jobs/issue_certificates.cron.js)
|
||||
* - expireUserTiers (cron/jobs/expire_user_tiers.cron.js)
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 17, 2026
|
||||
@@ -15,11 +16,13 @@
|
||||
const cron = require('node-cron');
|
||||
const userNotifications = require('./jobs/user_notifications.cron');
|
||||
const issueCertificates = require('./jobs/issue_certificates.cron');
|
||||
const expireUserTiers = require('./jobs/expire_user_tiers.cron');
|
||||
|
||||
// ─── Registry — add future client-side cron jobs here ────────────────────────
|
||||
const jobs = [
|
||||
userNotifications,
|
||||
issueCertificates,
|
||||
expireUserTiers,
|
||||
];
|
||||
|
||||
// ─── Boot all registered client-side jobs ─────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -22,7 +22,7 @@
|
||||
* Admin : task_overdue, user_registration, nogrp_user_registered
|
||||
* User : user_task_overdue, achievement, course_unlocked,
|
||||
* course_completed, certificate_issued, task_reminder, announcement,
|
||||
* nogrp_welcome
|
||||
* nogrp_welcome, tier_expired
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* 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 };
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
@@ -39,6 +39,8 @@ services:
|
||||
condition: service_healthy
|
||||
valkey:
|
||||
condition: service_started
|
||||
garage:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
# ── Frontend (React / Nginx) ─────────────────────────────────────────────────
|
||||
@@ -73,6 +75,34 @@ services:
|
||||
- valkey_data:/data
|
||||
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:
|
||||
postgres_data:
|
||||
valkey_data:
|
||||
garage_meta:
|
||||
garage_data:
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: originGuard.middleware.js
|
||||
* 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
|
||||
* header on every request. It is a forbidden request header — JavaScript cannot set, override,
|
||||
* or remove it. Its absence is a reliable, low-spoofability signal that the request originated
|
||||
* from a tool rather than a real browser.
|
||||
* 1a) Sec-Fetch-Site presence + value
|
||||
* Browsers (Chrome 76+, Firefox 90+, Safari 16.4+) automatically attach Sec-Fetch-Site on
|
||||
* every request. It is a forbidden request header — JavaScript cannot set, override, or
|
||||
* 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
|
||||
* ✓ BurpSuite Repeater / Scanner — no Sec-Fetch-Site
|
||||
* ✓ Postman — no Sec-Fetch-Site
|
||||
@@ -20,18 +21,39 @@
|
||||
* ✓ dirb / gobuster / feroxbuster — 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.
|
||||
* 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 ──────────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* ✗ BurpSuite running as MITM proxy through a real browser session.
|
||||
* The browser supplies all correct headers — requests are indistinguishable from
|
||||
* legitimate traffic. The only defences here are rate limiting and valid credentials.
|
||||
* ✗ A determined attacker who manually replicates all browser headers in their tool.
|
||||
* ✗ Playwright / Puppeteer / Selenium controlling a real browser — they produce all correct
|
||||
* Sec-Fetch-* headers, Sec-CH-UA, and Accept-Language automatically.
|
||||
* ✗ A determined attacker who manually replicates all required headers.
|
||||
* The only defences at that point are rate limiting and valid credentials.
|
||||
*
|
||||
* ── Browser compatibility note ───────────────────────────────────────────────────────────────────
|
||||
*
|
||||
@@ -41,7 +63,7 @@
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 20, 2026
|
||||
* Date Modified: Jun. 20, 2026
|
||||
* Date Modified: Jun. 29, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
"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']);
|
||||
|
||||
// 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) {
|
||||
// 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.
|
||||
@@ -63,12 +92,25 @@ module.exports = function originGuard(req, res, next) {
|
||||
|
||||
if (req.method === 'OPTIONS') return next(); // preflight — handled by cors()
|
||||
|
||||
// ── Layer 1: Sec-Fetch-Site must be present (covers GET scanning) ─────────
|
||||
if (!req.headers['sec-fetch-site']) {
|
||||
// ── Layer 1a: Sec-Fetch-Site must be present and not cross-site ───────────
|
||||
const fetchSite = req.headers['sec-fetch-site'];
|
||||
if (!fetchSite || fetchSite === 'cross-site') {
|
||||
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)) {
|
||||
const origin = req.headers['origin'];
|
||||
if (!origin || !ALLOWED.includes(origin)) {
|
||||
|
||||
@@ -80,10 +80,11 @@ const sensitiveOpsLimiter = rateLimit({
|
||||
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({
|
||||
windowMs: windowMs15,
|
||||
max: 200,
|
||||
keyGenerator: (req) => req.user?.user_id?.toString() ?? req.ip,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
store: makeStore('admin'),
|
||||
|
||||
@@ -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;
|
||||
@@ -21,6 +21,7 @@ const CourseInstructor = require("./course_instructor.mdl");
|
||||
const CourseReadingProgress = require("./course_reading_progress.mdl");
|
||||
const UnitReadingProgress = require("./unit_reading_progress.mdl");
|
||||
const LessonReadingProgress = require("./lesson_reading_progress.mdl");
|
||||
const CourseAchievement = require("./course_achievement.mdl");
|
||||
|
||||
// ── CourseReadingProgress ─────────────────────────────────────────────────────
|
||||
CourseReadingProgress.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' });
|
||||
@@ -54,6 +55,8 @@ Course.hasMany(Certificate, { as: "certificates", foreignKey: "course_id"
|
||||
Course.hasMany(CourseInstructor, { as: "instructors", foreignKey: "course_id" });
|
||||
CourseInstructor.belongsTo(Course, { as: "course", foreignKey: "course_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' });
|
||||
mdl_Category.belongsToMany(Course, { through: CourseProductCategory, foreignKey: 'category_id', otherKey: 'course_id', as: 'courses' });
|
||||
|
||||
@@ -117,4 +120,5 @@ module.exports = {
|
||||
AssessmentSession, QuizSession,
|
||||
mdl_Category, Certificate, CourseInstructor,
|
||||
CourseReadingProgress, UnitReadingProgress, LessonReadingProgress,
|
||||
CourseAchievement,
|
||||
};
|
||||
@@ -11,6 +11,9 @@ const Course = sequelize.define("Course", {
|
||||
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 },
|
||||
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 },
|
||||
updatedBy: { type: DataTypes.BIGINT, allowNull: true },
|
||||
deletedBy: { type: DataTypes.BIGINT, allowNull: true },
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -5,6 +5,7 @@ const mdl_UserTiers = require('./user_tiers.mdl');
|
||||
const mdl_Payments = require('./payments.mdl');
|
||||
const mdl_PlanCourses = require('./plan_courses.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 Asset = require('../assets/assets.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_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 ─────────────────────────────────────────────────────
|
||||
mdl_SystemBadges.belongsTo(Asset, { foreignKey: 'asset_id', as: 'asset' });
|
||||
|
||||
@@ -63,5 +68,6 @@ module.exports = {
|
||||
mdl_Payments,
|
||||
mdl_PlanCourses,
|
||||
mdl_PlanPolicies,
|
||||
mdl_PlanPrices,
|
||||
mdl_SystemBadges,
|
||||
};
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
***********************************************************************************************************************************************************************/
|
||||
|
||||
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
|
||||
|
||||
@@ -14,7 +14,9 @@ const mdl_TierPlans = sequelize.define('TierPlan', {
|
||||
tier_category_id: { type: DataTypes.BIGINT, allowNull: true, label: 'Tier Category' },
|
||||
tier: { type: DataTypes.STRING(50), allowNull: false, label: 'Tier' },
|
||||
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' },
|
||||
currency: { type: DataTypes.CHAR(3), allowNull: false, defaultValue: 'USD', label: 'Currency' },
|
||||
is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: 'Active' },
|
||||
|
||||
@@ -43,6 +43,9 @@ const mdl_Users = sequelize.define('User', {
|
||||
*/
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
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" },
|
||||
|
||||
Generated
+3833
-4
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
"test": "jest",
|
||||
"db:migrate": "sequelize-cli db:migrate",
|
||||
"db:migrate:undo": "sequelize-cli db:migrate:undo",
|
||||
"db:migrate:undo:all": "sequelize-cli db:migrate:undo:all",
|
||||
@@ -46,7 +47,12 @@
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^30.4.2",
|
||||
"nodemon": "^3.0.1",
|
||||
"sequelize-cli": "^6.6.5"
|
||||
},
|
||||
"jest": {
|
||||
"testMatch": ["**/tests/**/*.test.js"],
|
||||
"forceExit": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
@@ -31,6 +31,7 @@ const assetsRoutes = require('./assets.routes');
|
||||
const coursesRoutes = require('./courses.routes');
|
||||
const taskRoutes = require('./task.routes');
|
||||
const tiersRoutes = require('./tiers.routes');
|
||||
const tierPoliciesRoutes = require('./tier_policies.routes');
|
||||
const tierCategoriesRoutes = require('./tier_categories.routes');
|
||||
const categoriesRoutes = require('./categories.routes');
|
||||
const productsRoutes = require('./products.routes');
|
||||
@@ -52,6 +53,7 @@ router.use('/courses', coursesRoutes);
|
||||
router.use('/task-lists', taskRoutes);
|
||||
router.use('/tiers/categories', tierCategoriesRoutes);
|
||||
router.use('/tiers', tiersRoutes);
|
||||
router.use('/tier-policies', tierPoliciesRoutes);
|
||||
router.use('/categories', categoriesRoutes);
|
||||
router.use('/products', productsRoutes);
|
||||
router.use('/advertisements', advertisementRoutes);
|
||||
|
||||
@@ -39,6 +39,13 @@ router.delete("/:courseId", ctrl.archiveCourse);
|
||||
router.get("/:courseId/instructors", ctrl.getInstructors);
|
||||
router.put("/:courseId/instructors", ctrl.syncInstructors);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// COURSE ACHIEVEMENTS
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
router.get("/:courseId/achievements", ctrl.getCourseAchievements);
|
||||
router.put("/:courseId/achievements", ctrl.syncCourseAchievements);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// PREREQUISITES
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -2,12 +2,18 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const ctrl = require('../../controllers/admin/tier_policies.controller');
|
||||
|
||||
// ─── Plan Policies ────────────────────────────────────────────────────────────
|
||||
// ─── Plan Policies (access rules) ────────────────────────────────────────────
|
||||
// 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.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 ────────────────────────────────────────────────────────────
|
||||
// GET /admin/tiers/system-badges
|
||||
// GET /admin/tiers/system-badges/:key
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const ctrl = require('../../controllers/admin/tiers.controller');
|
||||
const priceCtrl = require('../../controllers/admin/plan_prices.controller');
|
||||
|
||||
router.get ('/', ctrl.getPlans);
|
||||
router.post ('/', ctrl.createPlan);
|
||||
@@ -16,10 +17,16 @@ router.get ('/users/:id/tiers', ctrl.getUserTiers);
|
||||
router.post ('/users/tiers/grant', ctrl.grantTier);
|
||||
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.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.put ('/:id', ctrl.updatePlan);
|
||||
router.delete('/:id', ctrl.archivePlan);
|
||||
|
||||
@@ -50,6 +50,7 @@ router.use(authenticate, requireClient());
|
||||
router.get('/profile', profileCtrl.getProfile);
|
||||
router.put('/profile', ...updateProfileValidator, validate, profileCtrl.updateProfile);
|
||||
router.delete('/profile', profileCtrl.deleteAccount);
|
||||
router.patch('/profile/currency', profileCtrl.updateCurrency);
|
||||
router.post('/profile/avatar', handleAvatarUpload, profileCtrl.uploadAvatar);
|
||||
router.delete('/profile/avatar', profileCtrl.deleteAvatar);
|
||||
router.get('/sessions', profileCtrl.getSessions);
|
||||
|
||||
@@ -3,6 +3,9 @@ const router = express.Router();
|
||||
const ctrl = require('../../controllers/client/courses.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
|
||||
router.get('/in-progress', progressCtrl.getMyInProgressCourses);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const ctrl = require('../../controllers/client/tiers.controller');
|
||||
const priceCtrl = require('../../controllers/admin/plan_prices.controller');
|
||||
|
||||
// My tier
|
||||
router.get ('/me', ctrl.getMyTier);
|
||||
@@ -9,12 +10,13 @@ router.get ('/me/history', ctrl.getMyTierHistory);
|
||||
// Plans
|
||||
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/capture', ctrl.captureOrder);
|
||||
router.post('/checkout/cancel', ctrl.cancelOrder);
|
||||
|
||||
// PayPal refund
|
||||
router.post('/checkout/refund', ctrl.refundOrder);
|
||||
|
||||
// My payments
|
||||
@@ -26,4 +28,7 @@ router.get ('/categories', ctrl.getCategories);
|
||||
// System badges (public read for profile display)
|
||||
router.get ('/system-badges', ctrl.getSystemBadges);
|
||||
|
||||
// Supported currencies (public — used by currency picker in settings + checkout)
|
||||
router.get ('/currencies', priceCtrl.getCurrencies);
|
||||
|
||||
module.exports = router;
|
||||
Executable
+31
@@ -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
|
||||
@@ -15,13 +15,20 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
const { emailTemplates } = require('../data/email_body.data')
|
||||
|
||||
const port = Number(process.env.SMTP_PORT);
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT),
|
||||
port,
|
||||
secure: port === 465,
|
||||
requireTLS: port === 587,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
});
|
||||
|
||||
const sendEmail = async ({ to, type, data = {} }) => {
|
||||
@@ -39,7 +46,7 @@ const sendEmail = async ({ to, type, data = {} }) => {
|
||||
{
|
||||
from: {
|
||||
name: "STARR System",
|
||||
address: "do-not-reply@philproperties.com",
|
||||
address: process.env.EMAIL_FROM,
|
||||
},
|
||||
to,
|
||||
subject,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -10,8 +10,8 @@
|
||||
// S3_REGION – garage
|
||||
// S3_ACCESS_KEY
|
||||
// S3_SECRET_KEY
|
||||
// S3_BUCKET – philproperties
|
||||
// S3_PUBLIC_URL – https://garage.philproperties.com
|
||||
// S3_BUCKET – your-bucket-name
|
||||
// S3_PUBLIC_URL – https://cdn.yourdomain.com
|
||||
|
||||
const { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadBucketCommand } = require("@aws-sdk/client-s3");
|
||||
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
|
||||
@@ -44,7 +44,7 @@ const s3Public = new S3Client({
|
||||
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(/\/$/, "");
|
||||
|
||||
// ─── Key prefix map ───────────────────────────────────────────────────────────
|
||||
@@ -84,7 +84,7 @@ function buildKey(originalname, ownerType) {
|
||||
|
||||
// Builds the public URL for a stored object.
|
||||
// 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) {
|
||||
return `${PUBLIC_URL}/${bucket}/${key}`;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,8 @@
|
||||
/**
|
||||
* Single archive — soft delete one record
|
||||
*/
|
||||
async function archiveOne(Model, options, deletedBy, transaction) {
|
||||
const record = await Model.findOne(options);
|
||||
async function archiveOne(Model, where, deletedBy, transaction) {
|
||||
const record = await Model.findOne({ where, transaction });
|
||||
if (!record) return null;
|
||||
await record.update({ deletedBy: deletedBy ?? null }, { transaction });
|
||||
await record.destroy({ transaction });
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user