ready to test

Testing

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-22 10:06:58 +08:00
parent bf48c95467
commit 439bb33f77
189 changed files with 17559 additions and 686 deletions
+159
View File
@@ -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,
};
+60
View File
@@ -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 };
+7 -6
View File
@@ -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 };
}
/**
+169
View File
@@ -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,
};
+5 -43
View File
@@ -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 };
+244
View File
@@ -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 };
+75
View File
@@ -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, ... }
};
+159
View File
@@ -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 };