mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
initial
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
// utils/queryBuilder.js
|
||||
const { Sequelize, Op } = require("sequelize");
|
||||
|
||||
/**
|
||||
* Builds a Sequelize `where` clause from an array of filters.
|
||||
*
|
||||
* @param {Array<{ id: string, value: any }>} filters
|
||||
* @returns {Object} Sequelize where clause
|
||||
*/
|
||||
function buildWhere(filters = [], allowedFields = new Set()) {
|
||||
const where = [];
|
||||
|
||||
for (const { id, value } of filters) {
|
||||
if (!id || value === undefined || value === null || value === "") continue;
|
||||
|
||||
// Reject fields not in whitelist (if whitelist is provided)
|
||||
if (allowedFields.size && !allowedFields.has(id)) continue;
|
||||
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
|
||||
const conditions = values.map((v) =>
|
||||
id.startsWith("personal_info.")
|
||||
? Sequelize.where(
|
||||
Sequelize.json(`personal_info.${id.replace("personal_info.", "")}`),
|
||||
{ [Op.iLike]: `%${v}%` }
|
||||
)
|
||||
: Sequelize.where(
|
||||
Sequelize.cast(Sequelize.col(id), "TEXT"),
|
||||
{ [Op.iLike]: `%${v}%` }
|
||||
)
|
||||
);
|
||||
|
||||
where.push({ [Op.or]: conditions });
|
||||
}
|
||||
|
||||
return where.length ? { [Op.and]: where } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Sequelize `order` clause from an array of sort descriptors.
|
||||
* Falls back to [["createdAt", "DESC"]] if no valid sort entries.
|
||||
*
|
||||
* @param {Array<{ id: string, desc: boolean }>} sort
|
||||
* @returns {Array} Sequelize order clause
|
||||
*/
|
||||
function buildOrder(sort = [], allowedFields = new Set()) {
|
||||
const order = [];
|
||||
|
||||
for (const { id, desc } of sort) {
|
||||
if (!id) continue;
|
||||
|
||||
// Reject fields not in whitelist (if whitelist is provided)
|
||||
if (allowedFields.size && !allowedFields.has(id)) continue;
|
||||
|
||||
order.push(
|
||||
id.startsWith("personal_info.")
|
||||
? [Sequelize.json(id), desc ? "DESC" : "ASC"]
|
||||
: [id, desc ? "DESC" : "ASC"]
|
||||
);
|
||||
}
|
||||
|
||||
return order.length ? order : [["createdAt", "DESC"]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper — returns both where and order in one call.
|
||||
*
|
||||
* @param {Array} filters
|
||||
* @param {Array} sort
|
||||
* @returns {{ where: Object, order: Array }}
|
||||
*/
|
||||
function buildQuery(filters = [], sort = [], allowedFields = []) {
|
||||
const fieldSet = new Set(allowedFields);
|
||||
|
||||
return {
|
||||
where: buildWhere(filters, fieldSet),
|
||||
order: buildOrder(sort, fieldSet),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { buildWhere, buildOrder, buildQuery };
|
||||
@@ -0,0 +1,73 @@
|
||||
const { Sequelize } = require('sequelize')
|
||||
|
||||
/**
|
||||
* Strips a key from every element in a JSONB array using PostgreSQL's
|
||||
* jsonb_agg + #- operator in a subquery.
|
||||
*
|
||||
* @param {string} column - JSONB column e.g. "personal_info"
|
||||
* @param {string} arrayField - array field name e.g. "addresses"
|
||||
* @param {string[]} keys - keys to strip from each array element e.g. ["street", "zip"]
|
||||
* @returns {string} SQL fragment
|
||||
*/
|
||||
function buildArrayStrip(column, arrayField, keys) {
|
||||
const keyRemovals = keys.reduce(
|
||||
(acc, key) => `(${acc} #- '{${key}}')`,
|
||||
"elem"
|
||||
);
|
||||
|
||||
return `(
|
||||
SELECT jsonb_agg(${keyRemovals})
|
||||
FROM jsonb_array_elements("${column}"->'${arrayField}') AS elem
|
||||
)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Sequelize literal that strips JSONB paths at the DB level.
|
||||
* Supports:
|
||||
* - nested keys: "personal_info.name.given_name"
|
||||
* - array item keys: "personal_info.addresses[].street"
|
||||
*
|
||||
* @param {string} column - JSONB column name e.g. "personal_info"
|
||||
* @param {string[]} excludePaths - dot-notation paths
|
||||
* @returns {Array|null} Sequelize literal attribute tuple
|
||||
*/
|
||||
function excludeJsonbPaths(column, excludePaths = []) {
|
||||
// Separate nested paths from array paths
|
||||
const nestedPaths = excludePaths.filter(
|
||||
(p) => p.startsWith(`${column}.`) && !p.includes("[]")
|
||||
);
|
||||
const arrayPaths = excludePaths.filter(
|
||||
(p) => p.startsWith(`${column}.`) && p.includes("[]")
|
||||
);
|
||||
|
||||
// Group array paths by their field name
|
||||
// e.g. { addresses: ["street", "zip"], phone_number: ["country_code"] }
|
||||
const arrayGroups = {};
|
||||
for (const path of arrayPaths) {
|
||||
const stripped = path.replace(`${column}.`, ""); // addresses[].street
|
||||
const [arrayField, key] = stripped.split("[]."); // ["addresses", "street"]
|
||||
if (!arrayGroups[arrayField]) arrayGroups[arrayField] = [];
|
||||
arrayGroups[arrayField].push(key);
|
||||
}
|
||||
|
||||
if (!nestedPaths.length && !Object.keys(arrayGroups).length) return null;
|
||||
|
||||
// Start with the column and chain #- for nested paths
|
||||
let literal = `"${column}"`;
|
||||
|
||||
// Strip nested keys first
|
||||
for (const path of nestedPaths) {
|
||||
const keys = path.replace(`${column}.`, "").split(".");
|
||||
literal = `(${literal} #- '{${keys.join(",")}}')`;
|
||||
}
|
||||
|
||||
// Then rebuild array fields with stripped keys using jsonb_set
|
||||
for (const [arrayField, keys] of Object.entries(arrayGroups)) {
|
||||
const arrayStrip = buildArrayStrip(column, arrayField, keys);
|
||||
literal = `jsonb_set(${literal}, '{${arrayField}}', COALESCE(${arrayStrip}, '[]'))`;
|
||||
}
|
||||
|
||||
return [Sequelize.literal(literal), column];
|
||||
}
|
||||
|
||||
module.exports = { excludeJsonbPaths };
|
||||
@@ -0,0 +1,162 @@
|
||||
// utils/modelToAttributes.js
|
||||
const { DataTypes } = require("sequelize");
|
||||
|
||||
/**
|
||||
* Maps Sequelize DataType to a simple UI type string.
|
||||
*/
|
||||
function resolveType(dataType) {
|
||||
if (!dataType) return "text";
|
||||
|
||||
const type = dataType.constructor?.key || dataType.key || "";
|
||||
|
||||
if (["BIGINT", "INTEGER", "FLOAT", "DOUBLE", "DECIMAL"].includes(type)) return "number";
|
||||
if (["DATE", "DATEONLY"].includes(type)) return "date";
|
||||
if (["BOOLEAN"].includes(type)) return "enum";
|
||||
if (["ENUM"].includes(type)) return "enum";
|
||||
if (["JSONB", "JSON"].includes(type)) return "jsonb";
|
||||
|
||||
return "text";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves options (e.g. enum choices) from a Sequelize field definition.
|
||||
*/
|
||||
function resolveOptions(dataType) {
|
||||
if (!dataType) return {};
|
||||
|
||||
const type = dataType.constructor?.key || dataType.key || "";
|
||||
|
||||
if (type === "ENUM") {
|
||||
return { choices: dataType.values ?? [] };
|
||||
}
|
||||
|
||||
if (type === "BOOLEAN") {
|
||||
return { choices: ["true", "false"] };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a camelCase or snake_case field name to a readable label.
|
||||
*/
|
||||
function toLabel(field) {
|
||||
return field
|
||||
.replace(/_/g, " ")
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens JSONB field paths from a schema definition.
|
||||
*
|
||||
* @param {Object} jsonbSchema - e.g. { name: { given_name, full_name }, date_of_birth }
|
||||
* @param {string} prefix - e.g. "personal_info"
|
||||
* @returns {Array} flat attribute entries for each leaf path
|
||||
*/
|
||||
function flattenJsonb(jsonbSchema = {}, prefix = "", exclude = []) {
|
||||
const result = [];
|
||||
|
||||
for (const [key, value] of Object.entries(jsonbSchema)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (exclude.includes(path)) continue;
|
||||
|
||||
// Nested object (no `type` key = it's a group, not a leaf)
|
||||
if (value && typeof value === "object" && !value.type) {
|
||||
result.push(...flattenJsonb(value, path, exclude));
|
||||
} else {
|
||||
result.push({
|
||||
name: value?.label || toLabel(key), // <-- prefer label
|
||||
type: value?.type ?? "text",
|
||||
field: path,
|
||||
options: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an attributes array from a Sequelize model + optional JSONB schema map.
|
||||
*
|
||||
* @param {Object} model - Sequelize model (e.g. mdl_Users)
|
||||
* @param {Object} jsonbSchemas - map of JSONB field names to their schema definition
|
||||
* e.g. { personal_info: { name: { full_name: "text", given_name: "text" }, date_of_birth: "date" } }
|
||||
* @param {string[]} exclude - field names to exclude (e.g. ["password", "otp_code"])
|
||||
* @returns {Array} attributes array
|
||||
*/
|
||||
function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLabels = {} } = {}) {
|
||||
const rawAttrs = model.rawAttributes || model.tableAttributes;
|
||||
const attributes = [];
|
||||
|
||||
const defaultTimestampLabels = {
|
||||
createdAt: "Created",
|
||||
modifiedAt: "Modified",
|
||||
updatedAt: "Modified",
|
||||
deletedAt: "Deleted",
|
||||
createdBy: "Created By",
|
||||
updatedBy: "Modified By",
|
||||
deletedBy: "Deleted By",
|
||||
...timestampLabels,
|
||||
};
|
||||
|
||||
// Audit fields excluded from normal loop — handled separately at the end
|
||||
const auditFields = [
|
||||
'createdAt', 'updatedAt', 'modifiedAt',
|
||||
'deletedAt',
|
||||
'createdBy', 'updatedBy', 'deletedBy',
|
||||
];
|
||||
|
||||
// Ordered audit sequence
|
||||
const auditSequence = [
|
||||
{ field: 'updatedAt', type: 'date' },
|
||||
{ field: 'modifiedAt', type: 'date' },
|
||||
{ field: 'updatedBy', type: 'text' },
|
||||
{ field: 'createdAt', type: 'date' },
|
||||
{ field: 'createdBy', type: 'text' },
|
||||
{ field: 'deletedAt', type: 'date' },
|
||||
{ field: 'deletedBy', type: 'text' },
|
||||
];
|
||||
|
||||
// ── Normal fields (excluding audit) ─────────────────────────────────────────
|
||||
for (const [field, def] of Object.entries(rawAttrs)) {
|
||||
if (exclude.includes(field)) continue;
|
||||
if (auditFields.includes(field)) continue; // skip audit — added later in order
|
||||
|
||||
const dataType = def.type;
|
||||
const type = resolveType(def);
|
||||
|
||||
if (type === "jsonb" && jsonbSchemas[field]) {
|
||||
attributes.push(...flattenJsonb(jsonbSchemas[field], field, exclude));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "jsonb") continue;
|
||||
|
||||
attributes.push({
|
||||
name: defaultTimestampLabels[field] ?? def.label ?? toLabel(field),
|
||||
type,
|
||||
field,
|
||||
options: resolveOptions(def),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Audit fields in correct sequence ────────────────────────────────────────
|
||||
for (const { field, type } of auditSequence) {
|
||||
if (exclude.includes(field)) continue;
|
||||
if (!rawAttrs[field]) continue; // skip if field doesn't exist on model
|
||||
|
||||
attributes.push({
|
||||
name: defaultTimestampLabels[field],
|
||||
type,
|
||||
field,
|
||||
options: {},
|
||||
});
|
||||
}
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
module.exports = { modelToAttributes };
|
||||
@@ -0,0 +1,44 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: otp.util.js
|
||||
* Type of Program: Utility
|
||||
* Description: One-Time Password (OTP) generation and validation helpers.
|
||||
* - generateOTP() → 6-digit numeric string
|
||||
* - getOTPExpiry() → Date object N minutes from now
|
||||
* - isOTPExpired() → boolean check on the stored expiry
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* const { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util');
|
||||
* user.otp_code = generateOTP();
|
||||
* user.otp_expires_at = getOTPExpiry();
|
||||
***********************************************************************************************************************************************************************/
|
||||
const crypto = require('crypto');
|
||||
|
||||
/**
|
||||
* Generates a cryptographically secure 6-digit OTP.
|
||||
* @returns {string} e.g. "048291"
|
||||
*/
|
||||
const generateOTP = () => {
|
||||
const bytes = crypto.randomBytes(3); // 3 bytes = 0–16777215
|
||||
const num = bytes.readUIntBE(0, 3) % 1_000_000; // force to 0–999999
|
||||
return num.toString().padStart(6, '0');
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a Date object N minutes in the future.
|
||||
* @param {number} [minutes=10]
|
||||
* @returns {Date}
|
||||
*/
|
||||
const getOTPExpiry = (minutes = Number(process.env.OTP_EXPIRY_MINUTES) || 10) => {
|
||||
return new Date(Date.now() + minutes * 60 * 1000);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks whether the stored OTP has expired.
|
||||
* @param {Date|string} expiresAt
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isOTPExpired = (expiresAt) => !expiresAt || new Date() > new Date(expiresAt);
|
||||
|
||||
module.exports = { generateOTP, getOTPExpiry, isOTPExpired };
|
||||
@@ -0,0 +1,120 @@
|
||||
// utils/paginate.util.js
|
||||
const { Sequelize } = require('sequelize');
|
||||
const { modelToAttributes } = require('./modelToAttributes');
|
||||
const { excludeJsonbPaths } = require('./excludeJsonbPaths');
|
||||
const { buildQuery } = require('./buildQuery.util');
|
||||
|
||||
const PAGE_START = 1;
|
||||
const PAGE_SIZE = 10;
|
||||
const MAX_LIMIT = 100;
|
||||
|
||||
function safeParseJSON(value, fallback = []) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates audit subquery attributes for createdBy, updatedBy, deletedBy
|
||||
*
|
||||
* @param {Object} mdl_Users - Users model
|
||||
* @param {string} parentAlias - Sequelize model alias e.g. 'User', 'UserGroup'
|
||||
* @returns {Array} - Sequelize attribute include array
|
||||
*/
|
||||
function auditInclude(mdl_Users, parentAlias = 'User') {
|
||||
const tableName = mdl_Users.getTableName();
|
||||
|
||||
const fullNameSubquery = (foreignKey) =>
|
||||
Sequelize.literal(`(
|
||||
SELECT (u."personal_info"->>'name')::jsonb->>'full_name'
|
||||
FROM "${tableName}" AS u
|
||||
WHERE u."user_id" = "${parentAlias}"."${foreignKey}"
|
||||
LIMIT 1
|
||||
)`);
|
||||
|
||||
return {
|
||||
attributes: [
|
||||
[fullNameSubquery('createdBy'), 'createdByName'],
|
||||
[fullNameSubquery('updatedBy'), 'updatedByName'],
|
||||
[fullNameSubquery('deletedBy'), 'deletedByName'],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable paginated findAndCountAll
|
||||
*
|
||||
* @param {Object} model - Sequelize model
|
||||
* @param {Object} req - Express request
|
||||
* @param {Object} options
|
||||
* @param {string[]} options.excludeAttributes - fields to exclude
|
||||
* @param {Object} options.jsonbSchemas - JSONB schema map
|
||||
* @param {string} options.jsonbColumn - JSONB column name e.g. "personal_info"
|
||||
* @param {Object} options.findOptions - extra Sequelize options (include, where, etc.)
|
||||
* @param {Object} options.auditOptions - { mdl_Users, parentAlias } to auto-include audit subqueries
|
||||
*/
|
||||
async function paginate(model, req, {
|
||||
excludeAttributes = [],
|
||||
jsonbSchemas = {},
|
||||
jsonbColumn = null,
|
||||
findOptions = {},
|
||||
auditOptions = null, // ← { mdl_Users, parentAlias }
|
||||
} = {}) {
|
||||
const page = Math.max(PAGE_START, parseInt(req.query.page, 10) || PAGE_START);
|
||||
const limit = Math.min(parseInt(req.query.limit, 10) || PAGE_SIZE, MAX_LIMIT);
|
||||
const offset = (page - PAGE_START) * limit;
|
||||
|
||||
const filters = safeParseJSON(req.query.filters);
|
||||
const sort = safeParseJSON(req.query.sort);
|
||||
|
||||
const topLevelExclude = excludeAttributes.filter((f) => !f.includes('.'));
|
||||
const jsonbExclude = excludeAttributes.filter((f) => f.includes('.'));
|
||||
const jsonbAttr = jsonbColumn ? excludeJsonbPaths(jsonbColumn, jsonbExclude) : null;
|
||||
|
||||
const attributes = modelToAttributes(model, { exclude: excludeAttributes, jsonbSchemas });
|
||||
const ALLOWED_FIELDS = attributes.map((a) => a.field);
|
||||
|
||||
const { where, order } = buildQuery(filters, sort, ALLOWED_FIELDS);
|
||||
|
||||
// Build attribute includes: jsonb + audit subqueries + any extra from findOptions
|
||||
const baseIncludes = jsonbAttr ? [jsonbAttr] : [];
|
||||
const auditAttrs = auditOptions
|
||||
? auditInclude(auditOptions.mdl_Users, auditOptions.parentAlias).attributes
|
||||
: [];
|
||||
const extraIncludes = findOptions.attributes?.include ?? [];
|
||||
const mergedAttributeIncludes = [...baseIncludes, ...auditAttrs, ...extraIncludes];
|
||||
|
||||
const { attributes: _attr, ...restFindOptions } = findOptions;
|
||||
|
||||
const { count, rows } = await model.findAndCountAll({
|
||||
...restFindOptions,
|
||||
where: { ...where, ...(restFindOptions.where ?? {}) },
|
||||
order,
|
||||
limit,
|
||||
offset,
|
||||
attributes: {
|
||||
exclude: topLevelExclude,
|
||||
include: mergedAttributeIncludes,
|
||||
},
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(count / limit);
|
||||
|
||||
return {
|
||||
data: rows,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
totalRecords: count,
|
||||
totalPages,
|
||||
hasPrevPage: page > PAGE_START,
|
||||
hasNextPage: page < totalPages,
|
||||
},
|
||||
attributes,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { paginate, auditInclude, safeParseJSON };
|
||||
@@ -0,0 +1,59 @@
|
||||
// utils/personalInfo.util.js
|
||||
|
||||
/**
|
||||
* Computes full_name, full_address, full_number
|
||||
* from a personal_info object before saving to DB.
|
||||
*
|
||||
* @param {Object} personalInfo
|
||||
* @returns {Object} enriched personal_info
|
||||
*/
|
||||
function enrichPersonalInfo(personalInfo = {}) {
|
||||
if (!personalInfo || typeof personalInfo !== 'object') return personalInfo;
|
||||
|
||||
const pi = { ...personalInfo };
|
||||
|
||||
// ── Full Name ────────────────────────────────────────────────────────────────
|
||||
if (pi.name) {
|
||||
const { last_name, given_name, middle_name, extension_name } = pi.name;
|
||||
|
||||
pi.name = {
|
||||
...pi.name,
|
||||
full_name: [
|
||||
last_name ? `${last_name},` : null,
|
||||
given_name,
|
||||
middle_name,
|
||||
extension_name,
|
||||
]
|
||||
.filter((v) => v && v.trim() !== '')
|
||||
.join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Full Addresses ───────────────────────────────────────────────────────────
|
||||
if (Array.isArray(pi.addresses)) {
|
||||
pi.addresses = pi.addresses.map((addr) => ({
|
||||
...addr,
|
||||
full_address: [
|
||||
addr.street,
|
||||
addr.city,
|
||||
addr.state,
|
||||
addr.country,
|
||||
addr.zip,
|
||||
]
|
||||
.filter((v) => v && v.trim() !== '')
|
||||
.join(', '),
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Full Phone Numbers ───────────────────────────────────────────────────────
|
||||
if (Array.isArray(pi.phone_number)) {
|
||||
pi.phone_number = pi.phone_number.map((phone) => ({
|
||||
...phone,
|
||||
full_number: `${phone.country_code || ''}${phone.number || ''}`,
|
||||
}));
|
||||
}
|
||||
|
||||
return pi;
|
||||
}
|
||||
|
||||
module.exports = { enrichPersonalInfo };
|
||||
@@ -0,0 +1,28 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: response.util.js
|
||||
* Type of Program: Utility
|
||||
* Description: Standardised HTTP response helpers.
|
||||
* Wraps all responses in a consistent envelope:
|
||||
* { status, message, data?, errors? }
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* const R = require('../utils/response.util');
|
||||
* return R.success(res, 'User created', user, 201);
|
||||
* return R.error(res, 'Not found', 404);
|
||||
***********************************************************************************************************************************************************************/
|
||||
|
||||
const success = (res, message = 'OK', data = null, statusCode = 200) =>
|
||||
res.status(statusCode).json({ status: 'success', message, data });
|
||||
|
||||
const error = (res, message = 'An error occurred', statusCode = 500, errors = null) => {
|
||||
const body = { status: 'error', message };
|
||||
if (errors) body.errors = errors;
|
||||
return res.status(statusCode).json(body);
|
||||
};
|
||||
|
||||
const validationError = (res, errors) =>
|
||||
res.status(422).json({ status: 'error', message: 'Validation failed', errors });
|
||||
|
||||
module.exports = { success, error, validationError };
|
||||
@@ -0,0 +1,70 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: token.util.js
|
||||
* Type of Program: Utility
|
||||
* Description: JWT access & refresh token helpers.
|
||||
* - generateTokens() → produces { accessToken, refreshToken }
|
||||
* - verifyAccessToken() → validates and returns decoded payload
|
||||
* - verifyRefreshToken() → validates the long-lived refresh token
|
||||
* - hashToken() → SHA-256 hash used to store refresh tokens in DB
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* const { generateTokens, verifyAccessToken } = require('../utils/token.util');
|
||||
* const { accessToken, refreshToken } = generateTokens(user);
|
||||
***********************************************************************************************************************************************************************/
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
|
||||
/**
|
||||
* Generates a signed access token and a signed refresh token.
|
||||
* @param {object} user - Sequelize User instance
|
||||
* @returns {{ accessToken: string, refreshToken: string }}
|
||||
*/
|
||||
const generateTokens = (user) => {
|
||||
const payload = {
|
||||
user_id: user.user_id,
|
||||
email: user.email,
|
||||
acc_type: user.acc_type,
|
||||
reg_type: user.reg_type,
|
||||
};
|
||||
|
||||
const accessToken = jwt.sign(payload, process.env.JWT_SECRET, {
|
||||
expiresIn: process.env.JWT_EXPIRES_IN || '1d',
|
||||
});
|
||||
|
||||
const refreshToken = jwt.sign(
|
||||
{ user_id: user.user_id },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
{ expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d' }
|
||||
);
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
};
|
||||
|
||||
/**
|
||||
* Verifies an access token.
|
||||
* @param {string} token
|
||||
* @returns {object} decoded payload
|
||||
* @throws if invalid / expired
|
||||
*/
|
||||
const verifyAccessToken = (token) =>
|
||||
jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
/**
|
||||
* Verifies a refresh token.
|
||||
* @param {string} token
|
||||
* @returns {object} decoded payload
|
||||
*/
|
||||
const verifyRefreshToken = (token) =>
|
||||
jwt.verify(token, process.env.JWT_REFRESH_SECRET);
|
||||
|
||||
/**
|
||||
* SHA-256 hash a token string for safe DB storage.
|
||||
* @param {string} token
|
||||
* @returns {string} hex digest
|
||||
*/
|
||||
const hashToken = (token) =>
|
||||
crypto.createHash('sha256').update(token).digest('hex');
|
||||
|
||||
module.exports = { generateTokens, verifyAccessToken, verifyRefreshToken, hashToken };
|
||||
Reference in New Issue
Block a user