@@ -1,38 +0,0 @@
|
||||
require('dotenv').config();
|
||||
const { Sequelize } = require('sequelize');
|
||||
const mdl_Users = require('./models/users/users.mdl');
|
||||
const sequelize = require('./config/db.config');
|
||||
|
||||
const qg = sequelize.getQueryInterface().queryGenerator;
|
||||
|
||||
const order = [[Sequelize.json('personal_info.name.full_name'), 'ASC']];
|
||||
|
||||
try {
|
||||
const sql = qg.selectQuery(mdl_Users.getTableName(), {
|
||||
model: mdl_Users,
|
||||
attributes: ['user_id'],
|
||||
order,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
}, mdl_Users);
|
||||
console.log('SORT SQL:\n', sql);
|
||||
} catch (e) {
|
||||
console.error('SORT ERROR:', e.message);
|
||||
}
|
||||
|
||||
// filter test
|
||||
const { Op } = require('sequelize');
|
||||
const whereCond = Sequelize.where(Sequelize.json('personal_info.name.full_name'), { [Op.iLike]: '%a%' });
|
||||
try {
|
||||
const sql2 = qg.selectQuery(mdl_Users.getTableName(), {
|
||||
model: mdl_Users,
|
||||
attributes: ['user_id'],
|
||||
where: whereCond,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
}, mdl_Users);
|
||||
console.log('FILTER SQL:\n', sql2);
|
||||
} catch (e) {
|
||||
console.error('FILTER ERROR:', e.message);
|
||||
}
|
||||
process.exit(0);
|
||||
@@ -1,37 +0,0 @@
|
||||
const { Sequelize } = require('sequelize');
|
||||
const mdl_Users = require('./models/users/users.mdl');
|
||||
const { mdl_UserGroups, mdl_UserGroupMembers } = require('./models/users/user_groups.mdl');
|
||||
const sequelize = require('./config/db.config');
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await sequelize.authenticate();
|
||||
console.log('DB connected OK');
|
||||
|
||||
// Find any group_id to test with
|
||||
const anyGroup = await mdl_UserGroups.findOne({ attributes: ['group_id'], paranoid: false });
|
||||
console.log('sample group_id:', anyGroup?.group_id);
|
||||
const group_id = anyGroup?.group_id;
|
||||
|
||||
const order = [[Sequelize.json('personal_info.name.full_name'), 'ASC']];
|
||||
|
||||
const result = await mdl_Users.findAndCountAll({
|
||||
attributes: ['user_id'],
|
||||
order,
|
||||
limit: 3,
|
||||
offset: 0,
|
||||
include: [{
|
||||
model: mdl_UserGroupMembers,
|
||||
where: { group_id },
|
||||
attributes: [],
|
||||
required: true,
|
||||
}],
|
||||
logging: (sql) => console.log('\n[SQL]', sql),
|
||||
});
|
||||
console.log('SORT+JOIN RESULT COUNT:', result.count, 'rows:', result.rows.length);
|
||||
} catch (e) {
|
||||
console.error('ERROR:', e.message);
|
||||
} finally {
|
||||
await sequelize.close();
|
||||
}
|
||||
})();
|
||||
@@ -337,7 +337,10 @@ exports.getUsersNotInGroup = async (req, res) => {
|
||||
const memberIds = members.map((m) => m.user_id);
|
||||
|
||||
const users = await mdl_Users.findAll({
|
||||
where: { user_id: { [Op.notIn]: memberIds.length ? memberIds : [0] } },
|
||||
where: {
|
||||
user_id: { [Op.notIn]: memberIds.length ? memberIds : [0] },
|
||||
acc_type: 'user', // exclude staff/admin — only regular users can be added to a group
|
||||
},
|
||||
attributes: [
|
||||
'user_id',
|
||||
[Sequelize.literal(`("User"."personal_info"->'name'->>'full_name')`), 'full_name'],
|
||||
|
||||
@@ -186,6 +186,12 @@ exports.deactivateUser = async (req, res) => {
|
||||
await user.update({ is_active: false, deletedBy: req.user.user_id });
|
||||
await user.destroy();
|
||||
|
||||
await mdl_UserGroupMembers.update(
|
||||
{ deletedBy: req.user.user_id },
|
||||
{ where: { user_id: req.params.id } }
|
||||
);
|
||||
await mdl_UserGroupMembers.destroy({ where: { user_id: req.params.id } });
|
||||
|
||||
await mdl_UserSessions.update(
|
||||
{ is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id } },
|
||||
{ where: { user_id: req.params.id } }
|
||||
@@ -226,6 +232,13 @@ exports.bulkDeactivateUsers = async (req, res) => {
|
||||
{ where: { user_id: activeIds } }
|
||||
);
|
||||
await mdl_Users.destroy({ where: { user_id: activeIds } });
|
||||
|
||||
await mdl_UserGroupMembers.update(
|
||||
{ deletedBy: req.user.user_id },
|
||||
{ where: { user_id: activeIds } }
|
||||
);
|
||||
await mdl_UserGroupMembers.destroy({ where: { user_id: activeIds } });
|
||||
|
||||
await mdl_UserSessions.update(
|
||||
{ is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id } },
|
||||
{ where: { user_id: activeIds } }
|
||||
@@ -310,6 +323,7 @@ exports.bulkRestoreUsers = async (req, res) => {
|
||||
// so a user who has ever banned someone can't be nulled out or hard-deleted —
|
||||
// getBannerBlockedIds() below identifies those and callers must exclude them.
|
||||
async function purgeUserDependents(userIds, t) {
|
||||
await mdl_UserGroupMembers.destroy({ where: { user_id: userIds }, force: true, transaction: t });
|
||||
await mdl_Achievements.destroy({ where: { user_id: userIds }, transaction: t });
|
||||
await mdl_QuizAttempt.destroy({ where: { user_id: userIds }, transaction: t });
|
||||
await mdl_UserTiers.destroy({ where: { user_id: userIds }, transaction: t });
|
||||
@@ -458,7 +472,7 @@ exports.terminateSession = async (req, res) => {
|
||||
|
||||
exports.getUserFieldValues = getFieldValues(mdl_Users, "USER", {
|
||||
blockedFields: ["password", "otp_code", "otp_expires_at", "personal_info"],
|
||||
extraDateFields: ["modifiedAt"],
|
||||
extraDateFields: ["modifiedAt", "ban_expires_at"],
|
||||
allowJsonb: true,
|
||||
selfJoin: true,
|
||||
});
|
||||
|
||||
@@ -27,8 +27,10 @@ const computedAttributes = [
|
||||
literal: `(
|
||||
SELECT CAST(COUNT(*) AS INTEGER)
|
||||
FROM "user_group_members"
|
||||
JOIN "users" ON "users"."user_id" = "user_group_members"."user_id"
|
||||
WHERE "user_group_members"."group_id" = "UserGroup"."group_id"
|
||||
AND "user_group_members"."deletedAt" IS NULL
|
||||
AND "users"."deletedAt" IS NULL
|
||||
)`,
|
||||
},
|
||||
];
|
||||
|
||||
+12
-3
@@ -6,7 +6,14 @@
|
||||
// deleteFile(key)
|
||||
//
|
||||
// Required .env vars:
|
||||
// S3_ENDPOINT – http://127.0.0.1:3900 (internal — always used for uploads/deletes)
|
||||
// S3_ENDPOINT – address this backend uses for uploads/deletes.
|
||||
// http://127.0.0.1:3900 when co-located with Garage.
|
||||
// Otherwise (backend runs on a different machine than
|
||||
// Garage) point this at a network-reachable address that
|
||||
// terminates on Garage — e.g. the same tunneled domain as
|
||||
// S3_PUBLIC_URL, since garage-anon-proxy re-signs any
|
||||
// non-presigned request with real credentials before
|
||||
// forwarding. Never leave this blank.
|
||||
// S3_REGION – garage
|
||||
// S3_ACCESS_KEY
|
||||
// S3_SECRET_KEY
|
||||
@@ -28,8 +35,10 @@ const credentials = {
|
||||
};
|
||||
|
||||
// Internal client — uploads, deletes, direct streams from the server itself.
|
||||
// Always targets S3_ENDPOINT: these calls originate from this machine, so the
|
||||
// internal address is the correct (and only) one to use.
|
||||
// Always targets S3_ENDPOINT. When this backend is co-located with Garage,
|
||||
// that's a local address; when it isn't, S3_ENDPOINT must instead be a
|
||||
// network-reachable address that reaches Garage (e.g. the tunneled proxy
|
||||
// domain) — see the .env docs above. Do not assume co-location here.
|
||||
const s3 = new S3Client({
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
region: process.env.S3_REGION || "garage",
|
||||
|
||||
@@ -29,6 +29,19 @@ const getFieldValues = (Model, logTag, options = {}) => async (req, res) => {
|
||||
if (!allowedFields.includes(field) && !dateFields.includes(field))
|
||||
return R.error(res, "Invalid or restricted field.", 400);
|
||||
|
||||
// ─── ENUM / BOOLEAN fields — return the canonical value set, not a DISTINCT
|
||||
// scan of current rows. A DISTINCT query silently omits valid values
|
||||
// that just don't happen to exist yet (e.g. is_banned when nobody is
|
||||
// currently banned), which produces an incomplete filter picklist.
|
||||
const rawType = Model.rawAttributes[field]?.type;
|
||||
const typeKey = rawType?.constructor?.key || rawType?.key;
|
||||
if (typeKey === "ENUM") {
|
||||
return R.success(res, "Field values retrieved.", rawType.values ?? []);
|
||||
}
|
||||
if (typeKey === "BOOLEAN") {
|
||||
return R.success(res, "Field values retrieved.", ["true", "false"]);
|
||||
}
|
||||
|
||||
if (auditByFields.includes(field)) {
|
||||
// Filtering must match the audit column's real (bigint) id — returning
|
||||
// just the display name here previously made buildWhere() compare a
|
||||
|
||||
@@ -95,6 +95,15 @@ async function paginate(model, req, {
|
||||
const jsonbExclude = excludeAttributes.filter((f) => f.includes('.'));
|
||||
const jsonbAttr = jsonbColumn ? excludeJsonbPaths(jsonbColumn, jsonbExclude) : null;
|
||||
|
||||
// The stripped jsonbAttr projection is aliased to the same name as the raw
|
||||
// column (e.g. "personal_info") — without excluding the raw column too, it
|
||||
// gets selected twice under the same alias, which Postgres/CockroachDB
|
||||
// accept in a plain SELECT but reject as ambiguous the moment that alias is
|
||||
// referenced in ORDER BY (e.g. sorting a jsonb-path column).
|
||||
if (jsonbAttr && jsonbColumn && !topLevelExclude.includes(jsonbColumn)) {
|
||||
topLevelExclude.push(jsonbColumn);
|
||||
}
|
||||
|
||||
const attributes = modelToAttributes(model, { exclude: excludeAttributes, jsonbSchemas, context });
|
||||
const ALLOWED_FIELDS = attributes.map((a) => a.field);
|
||||
const computedFieldKeys = computedAttributes.map((c) => c.key);
|
||||
|
||||
Reference in New Issue
Block a user