Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-08-13 19:58:52 +08:00
parent 9018d6d158
commit 2e9c2ad43f
23 changed files with 954 additions and 287 deletions
+73
View File
@@ -0,0 +1,73 @@
/***********************************************************************************************************************************************************************
* File Name: avatar.service.js
* Type of Program: Service
* Description: Shared avatar processing/orchestration for admin + client self-profile.
* Server-side authoritative resize — every avatar is normalized to a fixed
* 200x200 JPEG regardless of what the client sends, so browser-side cropping
* (see AvatarUploadDialog.jsx) is a UX convenience, not the enforcement point.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Aug. 10, 2026
***********************************************************************************************************************************************************************/
'use strict';
const sharp = require('sharp');
const { uploadFile, deleteFile } = require('./s3.service');
const AVATAR_SIZE = 200;
const JPEG_QUALITY = 90;
// ─── resizeAvatarBuffer ─────────────────────────────────────────────────────────
// Normalizes any accepted input (JPEG/PNG/WebP/GIF) into a fixed 200x200 JPEG.
// GIF animation and PNG transparency are intentionally dropped — avatars render
// in an opaque round mask, so a single static frame is all that's ever shown.
async function resizeAvatarBuffer(buffer) {
try {
return await sharp(buffer)
.rotate() // respect EXIF orientation before crop
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: 'cover', position: 'centre' })
.jpeg({ quality: JPEG_QUALITY })
.toBuffer();
} catch (err) {
throw Object.assign(new Error('Could not process the uploaded image.'), { status: 400, cause: err });
}
}
// ─── replaceUserAvatar ──────────────────────────────────────────────────────────
// Deletes the old S3 object (if any), resizes the new upload, stores it, and
// returns the avatar metadata object. Does not persist to the user row —
// callers own that so they can merge it into personal_info their own way.
async function replaceUserAvatar(user, file) {
const oldKey = user.personal_info?.avatar?.uuid;
if (oldKey) await deleteFile(oldKey).catch(() => {});
const resized = await resizeAvatarBuffer(file.buffer);
const { url, uuid } = await uploadFile({
buffer: resized,
originalname: 'avatar.jpg',
mimetype: 'image/jpeg',
ownerType: 'avatar',
});
return {
url,
uuid,
name: file.originalname,
mime_type: 'image/jpeg',
size: resized.length,
};
}
// ─── removeUserAvatar ───────────────────────────────────────────────────────────
async function removeUserAvatar(user) {
const key = user.personal_info?.avatar?.uuid;
if (!key) throw Object.assign(new Error('No avatar to remove.'), { status: 404 });
await deleteFile(key).catch(() => {});
}
module.exports = { resizeAvatarBuffer, replaceUserAvatar, removeUserAvatar };
+21 -10
View File
@@ -69,21 +69,31 @@ function resolveIp(req) {
return normalizeIp(raw);
}
function signToken(asset, userId, ip) {
// ─── signMediaToken ─────────────────────────────────────────────────────────────
//
// Low-level JWT signer shared by every media-token caller (asset previews here,
// avatar resolution in utils/resolveAvatar.util.js) so the secret-resolution +
// payload shape only lives in one place. `asset_id`/`user_id`/`ip` are optional —
// omitting `ip` means the stream endpoint's IP-pin check is skipped for that token.
function signMediaToken({ asset_id, storage_key, file_type, mime_type, user_id, ip, expiresIn = TOKEN_TTL_SEC }) {
return jwt.sign(
{
asset_id: asset.asset_id,
user_id: userId,
storage_key: asset.storage_key,
file_type: asset.file_type,
mime_type: asset.mime_type,
ip,
},
{ asset_id, user_id, storage_key, file_type, mime_type, ip },
MEDIA_SECRET,
{ expiresIn: TOKEN_TTL_SEC }
{ expiresIn }
);
}
function signToken(asset, userId, ip) {
return signMediaToken({
asset_id: asset.asset_id,
storage_key: asset.storage_key,
file_type: asset.file_type,
mime_type: asset.mime_type,
user_id: userId,
ip,
});
}
// ─── issueForAsset ─────────────────────────────────────────────────────────────
//
// Returns { token, thumbnail_url } for an S3 asset, minting + caching on first
@@ -114,4 +124,5 @@ module.exports = {
SUPPORTED_TYPES,
resolveIp,
issueForAsset,
signMediaToken,
};
+52 -21
View File
@@ -20,14 +20,22 @@ const { NOTIFICATION_REGISTRY } = require('../data/notifications.data');
const { sendEmail } = require('./email.service');
const { fmtDate } = require('../utils/datetime.util');
// Revokes every active user_tiers row tied to `plan`, replicating revokeTier's
// per-user "auto-downgrade to Free if no other active tier remains" rule
// (controllers/admin/tiers.controller.js) — a user can hold more than one
// concurrently-active plan, so this can't be a blanket status update.
async function revokePlanSubscriberAccess(plan, revokedByUserId) {
// Revokes every active user_tiers row tied to any of `plans`, replicating
// revokeTier's per-user "auto-downgrade to Free if no other active tier
// remains" rule (controllers/admin/tiers.controller.js) — a user can hold
// more than one concurrently-active plan, so this can't be a blanket status
// update. Batched into flat, count-independent queries (no per-user or
// per-plan loop hitting the DB) so this scales to any number of affected
// plans/subscribers in a fixed number of round trips.
async function revokePlanSubscriberAccessBulk(plans, revokedByUserId) {
if (!plans.length) return { revoked_user_count: 0 };
const planIds = plans.map((p) => p.plan_id);
const labelByPlanId = new Map(plans.map((p) => [String(p.plan_id), p.label]));
const activeRows = await mdl_UserTiers.findAll({
where: { plan_id: plan.plan_id, status: 'active' },
attributes: ['tier_id', 'user_id'],
where: { plan_id: planIds, status: 'active' },
attributes: ['tier_id', 'user_id', 'plan_id'],
});
if (!activeRows.length) return { revoked_user_count: 0 };
@@ -40,10 +48,19 @@ async function revokePlanSubscriberAccess(plan, revokedByUserId) {
{ where: { tier_id: tierIds } },
);
for (const user_id of userIds) {
const remainingActive = await mdl_UserTiers.count({ where: { user_id, status: 'active' } });
if (remainingActive === 0) {
await mdl_UserTiers.create({
// One grouped query replaces a per-user COUNT: finds everyone who still
// holds another active tier after the revoke above.
const stillActiveRows = await mdl_UserTiers.findAll({
where: { user_id: userIds, status: 'active' },
attributes: ['user_id'],
group: ['user_id'],
});
const stillActiveUserIds = new Set(stillActiveRows.map((r) => String(r.user_id)));
const usersToDowngrade = userIds.filter((user_id) => !stillActiveUserIds.has(user_id));
if (usersToDowngrade.length) {
await mdl_UserTiers.bulkCreate(
usersToDowngrade.map((user_id) => ({
user_id,
tier: 'free',
status: 'active',
@@ -51,16 +68,23 @@ async function revokePlanSubscriberAccess(plan, revokedByUserId) {
expires_at: null,
granted_by: revokedByUserId,
notes: 'Auto-downgrade after plan access was force-revoked.',
});
}
})),
);
}
// One row per (user, plan) relationship revoked — a user in two of the
// selected plans gets two notices/emails, one per plan label.
const revokedPairs = [...new Map(activeRows.map((r) => [`${r.user_id}:${r.plan_id}`, r])).values()];
try {
const notify = NOTIFICATION_REGISTRY.tier_plan_access_revoked.build({ label: plan.label, planId: plan.plan_id });
await UserNotification.bulkCreate(
userIds.map((user_id) => ({ user_id, ...notify, seen: false, createdAt: now, updatedAt: now })),
{ validate: false },
);
const notifications = revokedPairs.map((r) => {
const notify = NOTIFICATION_REGISTRY.tier_plan_access_revoked.build({
label: labelByPlanId.get(String(r.plan_id)),
planId: r.plan_id,
});
return { user_id: String(r.user_id), ...notify, seen: false, createdAt: now, updatedAt: now };
});
await UserNotification.bulkCreate(notifications, { validate: false });
} catch (notifyErr) {
console.error('[PLAN ACCESS REVOKE][NOTIFY]', notifyErr);
}
@@ -70,13 +94,16 @@ async function revokePlanSubscriberAccess(plan, revokedByUserId) {
where: { user_id: userIds },
attributes: ['user_id', 'email', 'personal_info'],
});
const usersById = new Map(users.map((u) => [String(u.user_id), u]));
const dateStr = fmtDate(now);
for (const u of users) {
for (const r of revokedPairs) {
const u = usersById.get(String(r.user_id));
if (!u) continue;
const name = u.personal_info?.name?.full_name ?? 'there';
sendEmail({
to: u.email,
type: 'TIER_ACCESS_REVOKED',
data: { name, label: plan.label, date: dateStr },
data: { name, label: labelByPlanId.get(String(r.plan_id)), date: dateStr },
}).catch((emailErr) => console.error('[PLAN ACCESS REVOKE][EMAIL]', emailErr));
}
} catch (emailBatchErr) {
@@ -86,4 +113,8 @@ async function revokePlanSubscriberAccess(plan, revokedByUserId) {
return { revoked_user_count: userIds.length };
}
module.exports = { revokePlanSubscriberAccess };
async function revokePlanSubscriberAccess(plan, revokedByUserId) {
return revokePlanSubscriberAccessBulk([plan], revokedByUserId);
}
module.exports = { revokePlanSubscriberAccess, revokePlanSubscriberAccessBulk };