mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
ready to test
Testing Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: achievements.service.js
|
||||
* Type of Program: Service
|
||||
* Description: Registry-based achievement auto-grant system.
|
||||
* To add a new achievement — add an entry to utils/achievements.data.js.
|
||||
* To trigger one — call grantAchievement(user_id, key, metadata).
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 11, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
'use strict';
|
||||
|
||||
const { Op } = require('sequelize');
|
||||
const mdl_Achievements = require('../models/users/achievements.mdl');
|
||||
const mdl_Users = require('../models/users/users.mdl');
|
||||
const { EARLY_ACCESS_CUTOFF, ACHIEVEMENT_REGISTRY } = require('../data/achievements.data');
|
||||
const UserNotification = require('../models/notifications/user_notification.mdl');
|
||||
const { NOTIFICATION_REGISTRY } = require('../data/notifications.data');
|
||||
|
||||
// ─── Core grant function ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Grant an achievement to a user if not already granted.
|
||||
* Safe to call multiple times — idempotent via findOrCreate.
|
||||
*
|
||||
* @param {string|number} user_id
|
||||
* @param {string} key — must exist in ACHIEVEMENT_REGISTRY
|
||||
* @param {Object} metadata — optional extra data (course_id, score, etc.)
|
||||
* @param {string|number} granted_by — null = system, user_id = admin manual grant
|
||||
* @returns {{ achievement, created }} or null on error
|
||||
*/
|
||||
async function grantAchievement(user_id, key, metadata = {}, granted_by = null) {
|
||||
const def = ACHIEVEMENT_REGISTRY[key];
|
||||
if (!def) {
|
||||
console.warn(`[ACHIEVEMENTS] Unknown achievement key: "${key}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// CockroachDB doesn't support findOrCreate — use findOne + create
|
||||
const existing = await mdl_Achievements.findOne({ where: { user_id, key } });
|
||||
if (existing) return { achievement: existing, created: false };
|
||||
|
||||
const achievement = await mdl_Achievements.create({
|
||||
user_id,
|
||||
type: def.type,
|
||||
key: def.key,
|
||||
label: def.label,
|
||||
description: def.description,
|
||||
granted_by: granted_by ?? null,
|
||||
granted_at: new Date(),
|
||||
metadata,
|
||||
});
|
||||
|
||||
console.log(`[ACHIEVEMENTS] Granted "${key}" to user ${user_id}`);
|
||||
|
||||
// Fire-and-forget — notification failure never blocks the achievement grant
|
||||
UserNotification.create({
|
||||
user_id,
|
||||
...NOTIFICATION_REGISTRY.achievement.build({
|
||||
label: def.label,
|
||||
description: def.description,
|
||||
key: def.key,
|
||||
}),
|
||||
}).catch(err => console.error(`[ACHIEVEMENTS] Failed to emit notification for "${key}":`, err));
|
||||
|
||||
return { achievement, created: true };
|
||||
} catch (err) {
|
||||
// Ignore unique constraint violation (race condition) — already granted
|
||||
if (err?.parent?.code === '23505') return null;
|
||||
console.error(`[ACHIEVEMENTS] Failed to grant "${key}" to user ${user_id}:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Convenience triggers ─────────────────────────────────────────────────────
|
||||
// Call these from controllers after the relevant event occurs.
|
||||
|
||||
/** Call after verifyOTP / register succeeds */
|
||||
async function onUserRegistered(user_id) {
|
||||
const user = await mdl_Users.findByPk(user_id, { attributes: ['user_id', 'createdAt'] });
|
||||
if (!user) return;
|
||||
if (new Date(user.createdAt) <= EARLY_ACCESS_CUTOFF) {
|
||||
await grantAchievement(user_id, 'early_access');
|
||||
}
|
||||
}
|
||||
|
||||
/** Call after captureOrder succeeds and tier is activated */
|
||||
async function onTierActivated(user_id, tier) {
|
||||
if (tier === 'premium') await grantAchievement(user_id, 'premium_first_time');
|
||||
if (tier === 'exclusive') await grantAchievement(user_id, 'exclusive_first_time');
|
||||
}
|
||||
|
||||
/** Call after a course is marked complete for a user */
|
||||
async function onCourseCompleted(user_id, course_id, totalCompleted, course_title = null) {
|
||||
await grantAchievement(user_id, 'first_course_completed', { course_id, course_title });
|
||||
if (totalCompleted >= 5) await grantAchievement(user_id, 'courses_completed_5');
|
||||
if (totalCompleted >= 10) await grantAchievement(user_id, 'courses_completed_10');
|
||||
}
|
||||
|
||||
/** Call after a perfect quiz score */
|
||||
async function onPerfectQuiz(user_id, quiz_id) {
|
||||
await grantAchievement(user_id, 'perfect_quiz_score', { quiz_id });
|
||||
}
|
||||
|
||||
/** Call after profile is fully filled out */
|
||||
async function onProfileCompleted(user_id) {
|
||||
await grantAchievement(user_id, 'profile_completed');
|
||||
}
|
||||
|
||||
/** Call after a successful referral */
|
||||
async function onReferral(user_id, referred_user_id) {
|
||||
await grantAchievement(user_id, 'first_referral', { referred_user_id });
|
||||
}
|
||||
|
||||
// ─── Admin manual grant ───────────────────────────────────────────────────────
|
||||
|
||||
/** Manually grant any achievement from admin panel */
|
||||
async function adminGrantAchievement(user_id, key, admin_id, metadata = {}) {
|
||||
return grantAchievement(user_id, key, metadata, admin_id);
|
||||
}
|
||||
|
||||
// ─── Backfill ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Run once to grant early_access to all existing eligible users */
|
||||
async function backfillEarlyAccess() {
|
||||
const users = await mdl_Users.findAll({
|
||||
where: { createdAt: { [Op.lte]: EARLY_ACCESS_CUTOFF } },
|
||||
attributes: ['user_id'],
|
||||
paranoid: false,
|
||||
});
|
||||
|
||||
console.log(`[ACHIEVEMENTS] Backfilling early_access for ${users.length} users...`);
|
||||
for (const user of users) {
|
||||
await grantAchievement(user.user_id, 'early_access');
|
||||
}
|
||||
console.log('[ACHIEVEMENTS] Backfill complete.');
|
||||
}
|
||||
|
||||
// ─── Exports ──────────────────────────────────────────────────────────────────
|
||||
|
||||
module.exports = {
|
||||
ACHIEVEMENT_REGISTRY,
|
||||
grantAchievement,
|
||||
|
||||
// Convenience triggers
|
||||
onUserRegistered,
|
||||
onTierActivated,
|
||||
onCourseCompleted,
|
||||
onPerfectQuiz,
|
||||
onProfileCompleted,
|
||||
onReferral,
|
||||
|
||||
// Admin
|
||||
adminGrantAchievement,
|
||||
|
||||
// Backfill
|
||||
backfillEarlyAccess,
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: certificate.service.js
|
||||
* Type of Program: Service
|
||||
* Description: Generates a PDF certificate by compiling a Typst template.
|
||||
* Data is passed to the template via --input CLI flags (no temp files needed for input).
|
||||
* Output is written to a temp file, read into a Buffer, then cleaned up.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 18, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
'use strict';
|
||||
|
||||
const { execFile } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const os = require('os');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const TEMPLATE_PATH = path.join(__dirname, '../templates/certificate.typ');
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* name: string,
|
||||
* course: string,
|
||||
* date: string,
|
||||
* cert_no: string,
|
||||
* ref_no: string,
|
||||
* instructors?: string,
|
||||
* length?: string,
|
||||
* }} data
|
||||
* @returns {Promise<Buffer>}
|
||||
*/
|
||||
async function generateCertificate({ name, course, date, cert_no, ref_no, instructors = '', length = '' }) {
|
||||
const outPath = path.join(os.tmpdir(), `cert_${crypto.randomUUID()}.pdf`);
|
||||
|
||||
const args = [
|
||||
'compile',
|
||||
TEMPLATE_PATH,
|
||||
outPath,
|
||||
'--input', `name=${name}`,
|
||||
'--input', `course=${course}`,
|
||||
'--input', `date=${date}`,
|
||||
'--input', `cert_no=${cert_no}`,
|
||||
'--input', `ref_no=${ref_no}`,
|
||||
];
|
||||
|
||||
if (instructors) args.push('--input', `instructors=${instructors}`);
|
||||
if (length) args.push('--input', `length=${length}`);
|
||||
|
||||
try {
|
||||
await execFileAsync('typst', args);
|
||||
return await fs.readFile(outPath);
|
||||
} finally {
|
||||
await fs.unlink(outPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { generateCertificate };
|
||||
@@ -97,21 +97,22 @@ async function uploadFile({ buffer, originalname, mimetype, ownerType = "" }) {
|
||||
contentType: mimetype,
|
||||
});
|
||||
|
||||
const contentLength = await new Promise((resolve, reject) => {
|
||||
form.getLength((err, length) => (err ? reject(err) : resolve(length)));
|
||||
});
|
||||
|
||||
const data = await chibiRequest("/api/upload", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...baseHeaders(),
|
||||
...form.getHeaders(), // ← axios needs these
|
||||
...form.getHeaders(),
|
||||
"Content-Length": contentLength,
|
||||
...(albumUuid ? { albumuuid: albumUuid } : {}),
|
||||
},
|
||||
data: form,
|
||||
});
|
||||
|
||||
return {
|
||||
uuid: data.uuid,
|
||||
url: data.url,
|
||||
name: data.name,
|
||||
};
|
||||
return { uuid: data.uuid, url: data.url, name: data.name };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: course_reading_progress.service.js
|
||||
* Type of Program: Service
|
||||
* Description: UPSERT-based progress tracking for user reading activity across the course hierarchy.
|
||||
*
|
||||
* upsertLessonRead — main entry point, called when a user reads a lesson.
|
||||
* UPSERTs the lesson row, then derives and UPSERTs the parent unit and course rows.
|
||||
* All three writes run in a single transaction.
|
||||
*
|
||||
* UPSERT key: (user_id, type, reference_id)
|
||||
*
|
||||
* Derivation rules:
|
||||
* unit → completed when ALL its non-deleted lessons have a completed row for this user
|
||||
* course → completed when ALL its non-deleted units have a completed row for this user
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 21, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
'use strict';
|
||||
|
||||
const sequelize = require('../config/db.config');
|
||||
const CourseReadingProgress = require('../models/courses/course_reading_progress.mdl');
|
||||
const Lesson = require('../models/courses/lessons.mdl');
|
||||
const Unit = require('../models/courses/units.mdl');
|
||||
|
||||
// ─── Core UPSERT ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function upsertProgress({ userId, courseId, type, referenceId, status }, t) {
|
||||
const now = new Date();
|
||||
const [record] = await CourseReadingProgress.upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
course_id: courseId,
|
||||
reference_id: referenceId,
|
||||
type,
|
||||
status,
|
||||
completed_at: status === 'completed' ? now : null,
|
||||
last_accessed_at: now,
|
||||
createdBy: userId,
|
||||
updatedBy: userId,
|
||||
},
|
||||
{
|
||||
conflictFields: ['user_id', 'type', 'reference_id'],
|
||||
returning: true,
|
||||
transaction: t,
|
||||
}
|
||||
);
|
||||
return record;
|
||||
}
|
||||
|
||||
// ─── Derivation helpers ───────────────────────────────────────────────────────
|
||||
|
||||
// Unit is completed when every non-deleted lesson under it has a completed row for this user.
|
||||
async function deriveUnitStatus(userId, courseId, unitId, t) {
|
||||
const lessons = await Lesson.findAll({
|
||||
where: { unit_id: unitId },
|
||||
attributes: ['uuid'],
|
||||
transaction: t,
|
||||
});
|
||||
if (!lessons.length) return 'in_progress';
|
||||
|
||||
const lessonUuids = lessons.map(l => l.uuid);
|
||||
const completedCount = await CourseReadingProgress.count({
|
||||
where: {
|
||||
user_id: userId,
|
||||
course_id: courseId,
|
||||
type: 'lesson',
|
||||
reference_id: lessonUuids,
|
||||
status: 'completed',
|
||||
},
|
||||
transaction: t,
|
||||
});
|
||||
|
||||
return completedCount === lessons.length ? 'completed' : 'in_progress';
|
||||
}
|
||||
|
||||
// Course is completed when every non-deleted unit under it has a completed row for this user.
|
||||
async function deriveCourseStatus(userId, courseId, t) {
|
||||
const units = await Unit.findAll({
|
||||
where: { course_id: courseId },
|
||||
attributes: ['uuid'],
|
||||
transaction: t,
|
||||
});
|
||||
if (!units.length) return 'in_progress';
|
||||
|
||||
const unitUuids = units.map(u => u.uuid);
|
||||
const completedCount = await CourseReadingProgress.count({
|
||||
where: {
|
||||
user_id: userId,
|
||||
course_id: courseId,
|
||||
type: 'unit',
|
||||
reference_id: unitUuids,
|
||||
status: 'completed',
|
||||
},
|
||||
transaction: t,
|
||||
});
|
||||
|
||||
return completedCount === units.length ? 'completed' : 'in_progress';
|
||||
}
|
||||
|
||||
// ─── Main entry point ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Called when a user reads (or finishes reading) a lesson.
|
||||
* Writes three rows in one transaction: lesson → unit → course.
|
||||
*
|
||||
* @param {number} userId
|
||||
* @param {Object} payload
|
||||
* @param {number} payload.courseId — course BIGINT PK (FK on course_reading_progress)
|
||||
* @param {string} payload.courseUuid — course UUID (reference_id for the course row)
|
||||
* @param {number} payload.unitId — unit BIGINT PK (used to query sibling lessons)
|
||||
* @param {string} payload.unitUuid — unit UUID (reference_id for the unit row)
|
||||
* @param {string} payload.lessonUuid — lesson UUID (reference_id for the lesson row)
|
||||
* @param {string} payload.lessonStatus — 'in_progress' | 'completed'
|
||||
* @returns {{ lesson, unit, course }} — status snapshot for each level
|
||||
*/
|
||||
async function upsertLessonRead(userId, { courseId, courseUuid, unitId, unitUuid, lessonUuid, lessonStatus = 'in_progress' }) {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
// 1. Lesson
|
||||
await upsertProgress({
|
||||
userId,
|
||||
courseId,
|
||||
type: 'lesson',
|
||||
referenceId: lessonUuid,
|
||||
status: lessonStatus,
|
||||
}, t);
|
||||
|
||||
// 2. Unit — derived from all sibling lessons
|
||||
const unitStatus = await deriveUnitStatus(userId, courseId, unitId, t);
|
||||
await upsertProgress({
|
||||
userId,
|
||||
courseId,
|
||||
type: 'unit',
|
||||
referenceId: unitUuid,
|
||||
status: unitStatus,
|
||||
}, t);
|
||||
|
||||
// 3. Course — derived from all units
|
||||
const courseStatus = await deriveCourseStatus(userId, courseId, t);
|
||||
await upsertProgress({
|
||||
userId,
|
||||
courseId,
|
||||
type: 'course',
|
||||
referenceId: courseUuid,
|
||||
status: courseStatus,
|
||||
}, t);
|
||||
|
||||
await t.commit();
|
||||
|
||||
return {
|
||||
lesson: { reference_id: lessonUuid, status: lessonStatus },
|
||||
unit: { reference_id: unitUuid, status: unitStatus },
|
||||
course: { reference_id: courseUuid, status: courseStatus },
|
||||
};
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Exports ──────────────────────────────────────────────────────────────────
|
||||
|
||||
module.exports = {
|
||||
upsertLessonRead,
|
||||
upsertProgress,
|
||||
deriveUnitStatus,
|
||||
deriveCourseStatus,
|
||||
};
|
||||
@@ -24,44 +24,6 @@ const transporter = nodemailer.createTransport({
|
||||
},
|
||||
});
|
||||
|
||||
const buildEmailTemplate = ({ title, body }) => {
|
||||
return `
|
||||
<div style="font-family:Arial,sans-serif;max-width:520px;margin:auto;border:1px solid #e5e7eb;border-radius:10px;overflow:hidden">
|
||||
|
||||
<!-- LOGO -->
|
||||
<div style="padding:20px;text-align:center;background:#232f3e">
|
||||
<img src="${process.env.APP_LOGO_URL}" alt="Philproperties" style="height:42px" />
|
||||
</div>
|
||||
|
||||
<!-- CONTENT -->
|
||||
<div style="padding:26px">
|
||||
<h2 style="color:#111827;margin-bottom:14px">${title}</h2>
|
||||
${body}
|
||||
|
||||
<!-- FORMAL CLOSING -->
|
||||
<div style="margin-top:24px">
|
||||
<p style="margin:0">Regards,</p>
|
||||
<p style="margin:4px 0 0;font-weight:600;color:#111827">
|
||||
Philproperties IT Team
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<div style="padding:14px;text-align:center;font-size:12px;color:#6b7280;border-top:1px solid #e5e7eb">
|
||||
This is an automated message from STARR System. Please do not reply.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends a 6-digit OTP to the given email address.
|
||||
* @param {string} to - recipient email
|
||||
* @param {string} otp - 6-digit code
|
||||
* @param {number} expiryMinutes
|
||||
*/
|
||||
const sendEmail = async ({ to, type, data = {} }) => {
|
||||
try {
|
||||
const templateFn = emailTemplates[type];
|
||||
@@ -70,9 +32,7 @@ const sendEmail = async ({ to, type, data = {} }) => {
|
||||
throw new Error(`Email template "${type}" not found`);
|
||||
}
|
||||
|
||||
const { subject, title, body } = templateFn(data);
|
||||
|
||||
const html = buildEmailTemplate({ title, body });
|
||||
const { subject, text } = templateFn(data);
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
transporter.sendMail(
|
||||
@@ -83,7 +43,7 @@ const sendEmail = async ({ to, type, data = {} }) => {
|
||||
},
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
},
|
||||
(err, info) => {
|
||||
if (err) return reject(err);
|
||||
@@ -96,4 +56,6 @@ const sendEmail = async ({ to, type, data = {} }) => {
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = sendEmail;
|
||||
const ping = () => transporter.verify();
|
||||
|
||||
module.exports = { sendEmail, ping };
|
||||
@@ -0,0 +1,244 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: health.service.js
|
||||
* Type of Program: Service
|
||||
* Description: Health check registry — runs all dependency checks and builds two response formats.
|
||||
*
|
||||
* runDashboard() → rich human-readable payload (for GET /api/health)
|
||||
* runReadiness() → compact machine payload (for GET /api/health/ready)
|
||||
*
|
||||
* Both call runChecks() internally and share the same check results.
|
||||
*
|
||||
* Check registry (CHECKS array):
|
||||
* Each entry is self-contained — name, criticality, enable condition, metadata, and runner.
|
||||
* To add a new infrastructure check: push one entry here. Nothing else changes.
|
||||
*
|
||||
* Criticality model:
|
||||
* critical: true → failure → overall "unhealthy" → HTTP 503
|
||||
* critical: false → failure → overall "degraded" → HTTP 200
|
||||
*
|
||||
* Checks included:
|
||||
* ✓ database — PostgreSQL via Sequelize (CRITICAL)
|
||||
* ✓ cache — Redis PING; skipped in memory mode (optional)
|
||||
* ✓ storage — S3/Garage HeadBucket (optional)
|
||||
* ✓ smtp — Nodemailer connection verify (optional)
|
||||
*
|
||||
* Checks intentionally excluded:
|
||||
* ✗ chibisafe — third-party CDN; not owned infrastructure
|
||||
* ✗ paypal — third-party payment gateway; not owned infrastructure
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 20, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
"use strict";
|
||||
|
||||
const os = require('os');
|
||||
|
||||
const sequelize = require('../config/db.config');
|
||||
const redis = require('../config/redis.config');
|
||||
const { ping: pingS3 } = require('./s3.service');
|
||||
const { ping: pingSmtp } = require('./email.service');
|
||||
|
||||
const PKG = require('../package.json');
|
||||
const TIMEOUT_MS = 3000;
|
||||
|
||||
// ─── Connection status labels (internal status → human label) ─────────────────
|
||||
|
||||
const CONNECTION_LABEL = {
|
||||
healthy: 'established',
|
||||
unhealthy: 'unavailable',
|
||||
timeout: 'timeout',
|
||||
skipped: 'not configured',
|
||||
};
|
||||
|
||||
// ─── Check registry ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Fields:
|
||||
// name {string} — key used in response JSON
|
||||
// critical {boolean} — true: 503 on failure; false: degraded (200)
|
||||
// enabled {boolean} — false: skipped entirely
|
||||
// skipNote {string} — shown in dashboard when enabled = false
|
||||
// meta {object} — extra context shown in the dashboard (provider, host, port, etc.)
|
||||
// run {async fn} — throws on failure, resolves on success
|
||||
|
||||
const CHECKS = [
|
||||
{
|
||||
name: 'database',
|
||||
critical: true,
|
||||
enabled: true,
|
||||
meta: { dialect: 'postgresql', host: process.env.DB_HOST, port: Number(process.env.DB_PORT) || 5432 },
|
||||
run: () => sequelize.authenticate(),
|
||||
},
|
||||
{
|
||||
name: 'cache',
|
||||
critical: false,
|
||||
enabled: !!redis,
|
||||
skipNote: 'CACHE_DRIVER=memory — Redis is not used in this environment',
|
||||
meta: { driver: 'redis', url: process.env.REDIS_URL || 'redis://127.0.0.1:6379' },
|
||||
run: () => redis.ping(),
|
||||
},
|
||||
{
|
||||
name: 'storage',
|
||||
critical: false,
|
||||
enabled: !!process.env.S3_ENDPOINT,
|
||||
skipNote: 'S3_ENDPOINT is not configured',
|
||||
meta: { provider: 's3', endpoint: process.env.S3_ENDPOINT, bucket: process.env.S3_BUCKET },
|
||||
run: pingS3,
|
||||
},
|
||||
{
|
||||
name: 'smtp',
|
||||
critical: false,
|
||||
enabled: !!process.env.SMTP_HOST,
|
||||
skipNote: 'SMTP_HOST is not configured',
|
||||
meta: { host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT) || 587 },
|
||||
run: pingSmtp,
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const mb = (bytes) => Math.round(bytes / 1024 / 1024 * 10) / 10;
|
||||
|
||||
function formatUptime(seconds) {
|
||||
if (seconds < 60) return `${seconds} secs`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)} mins`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours`;
|
||||
if (seconds < 2592000) return `${Math.floor(seconds / 86400)} days`;
|
||||
if (seconds < 31536000) return `${Math.floor(seconds / 2592000)} months`;
|
||||
return `${Math.floor(seconds / 31536000)} years`;
|
||||
}
|
||||
|
||||
function memoryInfo() {
|
||||
const m = process.memoryUsage();
|
||||
return {
|
||||
heap_used_mb: mb(m.heapUsed),
|
||||
heap_total_mb: mb(m.heapTotal),
|
||||
rss_mb: mb(m.rss),
|
||||
external_mb: mb(m.external),
|
||||
};
|
||||
}
|
||||
|
||||
function systemInfo() {
|
||||
const load = os.loadavg();
|
||||
return {
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
cpus: os.cpus().length,
|
||||
load_avg: {
|
||||
'1m': Math.round(load[0] * 100) / 100,
|
||||
'5m': Math.round(load[1] * 100) / 100,
|
||||
'15m': Math.round(load[2] * 100) / 100,
|
||||
},
|
||||
memory: memoryInfo(),
|
||||
};
|
||||
}
|
||||
|
||||
function appInfo() {
|
||||
return {
|
||||
name: PKG.name,
|
||||
version: PKG.version,
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
node_version: process.version,
|
||||
pid: process.pid,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Check runner ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function runCheck(check) {
|
||||
const { name, critical, enabled, skipNote, run } = check;
|
||||
|
||||
if (!enabled) {
|
||||
return [name, { status: 'skipped', note: skipNote, critical }];
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
await Promise.race([
|
||||
run(),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('timeout')), TIMEOUT_MS)
|
||||
),
|
||||
]);
|
||||
return [name, { status: 'healthy', latency_ms: Date.now() - start, critical }];
|
||||
} catch (err) {
|
||||
return [name, {
|
||||
status: err.message === 'timeout' ? 'timeout' : 'unhealthy',
|
||||
latency_ms: Date.now() - start,
|
||||
critical,
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
async function runChecks() {
|
||||
const results = await Promise.all(CHECKS.map(runCheck));
|
||||
const checks = Object.fromEntries(results);
|
||||
|
||||
const criticalFailed = results.some(
|
||||
([, r]) => r.critical && r.status !== 'healthy' && r.status !== 'skipped'
|
||||
);
|
||||
const anyDegraded = results.some(
|
||||
([, r]) => !r.critical && r.status !== 'healthy' && r.status !== 'skipped'
|
||||
);
|
||||
|
||||
const overallStatus = criticalFailed ? 'unhealthy'
|
||||
: anyDegraded ? 'degraded'
|
||||
: 'healthy';
|
||||
|
||||
return { checks, criticalFailed, overallStatus };
|
||||
}
|
||||
|
||||
// ─── Dashboard payload (GET /api/health) ─────────────────────────────────────
|
||||
//
|
||||
// Human-readable — full context: app info, system info, service connection details.
|
||||
// Uses "established / unavailable / timeout / not configured" language.
|
||||
|
||||
async function runDashboard() {
|
||||
const { checks, criticalFailed, overallStatus } = await runChecks();
|
||||
|
||||
const services = {};
|
||||
for (const check of CHECKS) {
|
||||
const result = checks[check.name];
|
||||
services[check.name] = {
|
||||
connection: CONNECTION_LABEL[result.status] ?? result.status,
|
||||
...(result.latency_ms !== undefined && { latency_ms: result.latency_ms }),
|
||||
...(result.note !== undefined && { note: result.note }),
|
||||
...(check.meta !== undefined && check.meta),
|
||||
critical: result.critical,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
httpStatus: criticalFailed ? 503 : 200,
|
||||
body: {
|
||||
status: overallStatus,
|
||||
app: appInfo(),
|
||||
uptime: formatUptime(Math.floor(process.uptime())),
|
||||
timestamp: new Date().toISOString(),
|
||||
system: systemInfo(),
|
||||
services,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Readiness payload (GET /api/health/ready) ───────────────────────────────
|
||||
//
|
||||
// Compact — intended for machines (Kubernetes probe, deployment scripts, CI gates).
|
||||
// Uses "healthy / unhealthy / timeout / skipped" language.
|
||||
|
||||
async function runReadiness() {
|
||||
const { checks, criticalFailed, overallStatus } = await runChecks();
|
||||
|
||||
return {
|
||||
httpStatus: criticalFailed ? 503 : 200,
|
||||
body: {
|
||||
status: overallStatus,
|
||||
version: PKG.version,
|
||||
uptime: formatUptime(Math.floor(process.uptime())),
|
||||
timestamp: new Date().toISOString(),
|
||||
checks,
|
||||
memory: memoryInfo(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { runDashboard, runReadiness };
|
||||
@@ -0,0 +1,75 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: paypal.service.js
|
||||
* Type of Program: Service
|
||||
* Description: PayPal Orders API helpers — create order, capture order.
|
||||
* Uses client-side JS SDK button → server capture flow.
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 6, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
const axios = require('axios');
|
||||
|
||||
const BASE_URL = process.env.PAYPAL_ENV === 'live'
|
||||
? 'https://api-m.paypal.com'
|
||||
: 'https://api-m.sandbox.paypal.com';
|
||||
|
||||
const getAccessToken = async () => {
|
||||
const { data } = await axios.post(
|
||||
`${BASE_URL}/v1/oauth2/token`,
|
||||
'grant_type=client_credentials',
|
||||
{
|
||||
auth: {
|
||||
username: process.env.PAYPAL_CLIENT_ID,
|
||||
password: process.env.PAYPAL_CLIENT_SECRET,
|
||||
},
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
}
|
||||
);
|
||||
return data.access_token;
|
||||
};
|
||||
|
||||
exports.createOrder = async ({ amount, currency = 'USD', referenceId, returnUrl, cancelUrl }) => {
|
||||
const token = await getAccessToken();
|
||||
const { data } = await axios.post(
|
||||
`${BASE_URL}/v2/checkout/orders`,
|
||||
{
|
||||
intent: 'CAPTURE',
|
||||
purchase_units: [{
|
||||
reference_id: referenceId,
|
||||
amount: { currency_code: currency, value: String(amount) },
|
||||
}],
|
||||
application_context: {
|
||||
return_url: returnUrl ?? `${process.env.FRONTEND_URL}/plans/checkout`,
|
||||
cancel_url: cancelUrl ?? `${process.env.FRONTEND_URL}/plans/checkout?cancelled=true`,
|
||||
brand_name: 'Philproperties',
|
||||
user_action: 'PAY_NOW',
|
||||
},
|
||||
},
|
||||
{ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
exports.captureOrder = async (orderId) => {
|
||||
const token = await getAccessToken();
|
||||
const { data } = await axios.post(
|
||||
`${BASE_URL}/v2/checkout/orders/${orderId}/capture`,
|
||||
{},
|
||||
{ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
return data; // { id, status, purchase_units, payer }
|
||||
};
|
||||
|
||||
exports.refundCapture = async (captureId, amount, currency = 'USD') => {
|
||||
const token = await getAccessToken();
|
||||
const { data } = await axios.post(
|
||||
`${BASE_URL}/v2/payments/captures/${captureId}/refund`,
|
||||
{
|
||||
amount: {
|
||||
value: String(amount),
|
||||
currency_code: currency,
|
||||
},
|
||||
},
|
||||
{ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } }
|
||||
);
|
||||
return data; // { id, status, amount, ... }
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
// services/s3.service.js
|
||||
//
|
||||
// S3-compatible storage service targeting Garage (self-hosted).
|
||||
// Exposes the same interface as chibisafe.service.js:
|
||||
// uploadFile({ buffer, originalname, mimetype, ownerType }) → { url, uuid }
|
||||
// deleteFile(key)
|
||||
//
|
||||
// Required .env vars:
|
||||
// S3_ENDPOINT – http://127.0.0.1:3900
|
||||
// S3_REGION – garage
|
||||
// S3_ACCESS_KEY
|
||||
// S3_SECRET_KEY
|
||||
// S3_BUCKET – philproperties
|
||||
// S3_PUBLIC_URL – https://garage.philproperties.com
|
||||
|
||||
const { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadBucketCommand } = require("@aws-sdk/client-s3");
|
||||
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
|
||||
const crypto = require("crypto");
|
||||
const path = require("path");
|
||||
|
||||
// ─── Client ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const s3 = new S3Client({
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
region: process.env.S3_REGION || "garage",
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY,
|
||||
secretAccessKey: process.env.S3_SECRET_KEY,
|
||||
},
|
||||
forcePathStyle: true, // required — Garage does not support DNS-style bucket addressing
|
||||
});
|
||||
|
||||
const DEFAULT_BUCKET = process.env.S3_BUCKET || "philproperties";
|
||||
const PUBLIC_URL = (process.env.S3_PUBLIC_URL || "").replace(/\/$/, "");
|
||||
|
||||
// ─── Key prefix map ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// ownerType mirrors what the controller already passes to chibisafe
|
||||
// (image, video, audio, document, avatar, thumbnail).
|
||||
// Maps to folder prefixes inside the bucket.
|
||||
//
|
||||
const PREFIX_MAP = {
|
||||
image: "images",
|
||||
video: "videos",
|
||||
audio: "audios",
|
||||
document: "documents",
|
||||
avatar: "avatars",
|
||||
thumbnail: "thumbnails",
|
||||
};
|
||||
|
||||
function resolvePrefix(ownerType = "image") {
|
||||
return PREFIX_MAP[ownerType] || "others";
|
||||
}
|
||||
|
||||
function resolveExtension(originalname = "") {
|
||||
return path.extname(originalname).replace(".", "").toLowerCase() || "bin";
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Builds the S3 object key: {prefix}/{uuid}.{ext}
|
||||
// e.g. images/3f2a1b4c-uuid.jpg
|
||||
function buildKey(originalname, ownerType) {
|
||||
const prefix = resolvePrefix(ownerType);
|
||||
const ext = resolveExtension(originalname);
|
||||
const uuid = crypto.randomUUID();
|
||||
return `${prefix}/${uuid}.${ext}`;
|
||||
}
|
||||
|
||||
// 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
|
||||
function buildPublicUrl(key, bucket = DEFAULT_BUCKET) {
|
||||
return `${PUBLIC_URL}/${bucket}/${key}`;
|
||||
}
|
||||
|
||||
// ─── uploadFile ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Matches chibisafe.service.js interface exactly.
|
||||
// input: { buffer, originalname, mimetype, ownerType? }
|
||||
// output: { url, uuid } ← uuid = the S3 key, stored as storage_key in DB
|
||||
//
|
||||
async function uploadFile({ buffer, originalname, mimetype, ownerType = "image" }) {
|
||||
if (!buffer) {
|
||||
throw Object.assign(new Error("File buffer is required for S3 uploads."), { status: 400 });
|
||||
}
|
||||
|
||||
const bucket = DEFAULT_BUCKET;
|
||||
const key = buildKey(originalname, ownerType);
|
||||
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: mimetype,
|
||||
}));
|
||||
|
||||
return {
|
||||
url: buildPublicUrl(key, bucket),
|
||||
uuid: key, // used as storage_key in DB — mirrors chibi_uuid usage
|
||||
};
|
||||
}
|
||||
|
||||
// ─── deleteFile ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Matches chibisafe.service.js interface.
|
||||
// input: key — the value stored in storage_key column (e.g. "images/uuid.jpg")
|
||||
//
|
||||
async function deleteFile(key) {
|
||||
if (!key) return;
|
||||
await s3.send(new DeleteObjectCommand({
|
||||
Bucket: DEFAULT_BUCKET,
|
||||
Key: key,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── getSignedDownloadUrl ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Generates a short-lived pre-signed GET URL.
|
||||
// Useful if you ever need gated access to private assets (is_public = false).
|
||||
//
|
||||
async function getSignedDownloadUrl(key, expiresInSeconds = 3600) {
|
||||
return getSignedUrl(
|
||||
s3,
|
||||
new GetObjectCommand({ Bucket: DEFAULT_BUCKET, Key: key }),
|
||||
{ expiresIn: expiresInSeconds }
|
||||
);
|
||||
}
|
||||
|
||||
// ─── getObjectStream ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Fetches an S3 object and returns its readable stream + metadata, for use
|
||||
// in a server-side download proxy (Content-Disposition: attachment).
|
||||
//
|
||||
// input: key — the storage_key (e.g. "images/uuid.jpg")
|
||||
// output: { stream, contentType, contentLength }
|
||||
//
|
||||
async function getObjectStream(key) {
|
||||
if (!key) {
|
||||
throw Object.assign(new Error("Storage key is required."), { status: 400 });
|
||||
}
|
||||
|
||||
const result = await s3.send(new GetObjectCommand({
|
||||
Bucket: DEFAULT_BUCKET,
|
||||
Key: key,
|
||||
}));
|
||||
|
||||
return {
|
||||
stream: result.Body, // Node.js Readable stream
|
||||
contentType: result.ContentType,
|
||||
contentLength: result.ContentLength,
|
||||
};
|
||||
}
|
||||
|
||||
async function ping() {
|
||||
await s3.send(new HeadBucketCommand({ Bucket: DEFAULT_BUCKET }));
|
||||
}
|
||||
|
||||
module.exports = { uploadFile, deleteFile, getSignedDownloadUrl, getObjectStream, ping };
|
||||
Reference in New Issue
Block a user