diff --git a/_scratch_test_sort.js b/_scratch_test_sort.js deleted file mode 100644 index f4bff65..0000000 --- a/_scratch_test_sort.js +++ /dev/null @@ -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); diff --git a/_scratch_test_sort2.js b/_scratch_test_sort2.js deleted file mode 100644 index 78a7fc0..0000000 --- a/_scratch_test_sort2.js +++ /dev/null @@ -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(); - } -})(); diff --git a/controllers/admin/user_groups.controller.js b/controllers/admin/user_groups.controller.js index 3e4c694..1ac26f7 100644 --- a/controllers/admin/user_groups.controller.js +++ b/controllers/admin/user_groups.controller.js @@ -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'], diff --git a/controllers/admin/users.controller.js b/controllers/admin/users.controller.js index 6ffbf23..26cf720 100644 --- a/controllers/admin/users.controller.js +++ b/controllers/admin/users.controller.js @@ -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, }); diff --git a/models/users/user_groups.attributes.js b/models/users/user_groups.attributes.js index 9d1aa51..03e2885 100644 --- a/models/users/user_groups.attributes.js +++ b/models/users/user_groups.attributes.js @@ -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 )`, }, ]; diff --git a/services/s3.service.js b/services/s3.service.js index 1ff97ba..0024c86 100644 --- a/services/s3.service.js +++ b/services/s3.service.js @@ -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", diff --git a/utils/fieldValues.util.js b/utils/fieldValues.util.js index 89a1863..582676c 100644 --- a/utils/fieldValues.util.js +++ b/utils/fieldValues.util.js @@ -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 diff --git a/utils/paginate.util.js b/utils/paginate.util.js index 17197ae..6c0a9d9 100644 --- a/utils/paginate.util.js +++ b/utils/paginate.util.js @@ -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);