diff --git a/controllers/admin/user_groups.controller.js b/controllers/admin/user_groups.controller.js index 1ac26f7..d0fc068 100644 --- a/controllers/admin/user_groups.controller.js +++ b/controllers/admin/user_groups.controller.js @@ -24,6 +24,7 @@ const { getFieldValues } = require('../../utils/fieldValues.util'); const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas } = require('../../models/users/users.attributes'); const { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas, computedAttributes: groupComputed } = require('../../models/users/user_groups.attributes'); const logActivity = require('../../utils/logActivity.util'); +const { dropDefaultGroupMembership, reconcileDefaultGroup } = require('../../utils/defaultGroup.util'); // ─── Helper — generate a unique group code ──────────────────────────────────── /** @@ -416,6 +417,8 @@ exports.addUserToGroup = async (req, res) => { { ignoreDuplicates: true } ); + await dropDefaultGroupMembership(user_ids, group_id, { updatedBy: req.user.user_id }); + logActivity(req.user.user_id, 'add_user_to_group', { entityType: 'group', entityId: Number(group_id), details: { user_ids } }); return R.success(res, 'Users added to group.'); } catch (err) { @@ -447,6 +450,8 @@ exports.removeUserFromGroup = async (req, res) => { ); await mdl_UserGroupMembers.destroy({ where: { user_id: user_ids, group_id } }); + await reconcileDefaultGroup(user_ids, { createdBy: req.user.user_id }); + logActivity(req.user.user_id, 'remove_user_from_group', { entityType: 'group', entityId: Number(group_id), details: { user_ids } }); return R.success(res, 'Users removed from group.'); } catch (err) { diff --git a/controllers/auth.controller.js b/controllers/auth.controller.js index 8b78fde..7cbdc60 100644 --- a/controllers/auth.controller.js +++ b/controllers/auth.controller.js @@ -39,6 +39,7 @@ const mdl_Users = require('../models/users/users.mdl'); const mdl_UserSessions = require('../models/users/user_sessions.mdl'); const { checkAccountStatus } = require('../services/accountStatus.service'); const { mdl_UserGroups, mdl_UserGroupMembers } = require('../models/users/user_groups.mdl') +const { NOGRP_CODE, getDefaultGroupId, enrollDefaultGroup } = require('../utils/defaultGroup.util'); const { generateTokens, verifyRefreshToken, hashToken, shouldRotateRefreshToken } = require('../utils/token.util'); const { generateState, generateNonce, generatePKCE, buildAuthUrl, exchangeCode, verifyIdToken } = require('../utils/google_oidc.util'); const { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util'); @@ -135,8 +136,7 @@ exports.register = async (req, res) => { } // ── Resolve enroll target (explicit group or NOGRP fallback) ────────────── - const enrollGroup = group - ?? await mdl_UserGroups.findOne({ where: { group_code: 'NOGRP', is_active: true } }); + const enrollGroupId = group?.group_id ?? await getDefaultGroupId(); // ── Create user ─────────────────────────────────────────────────────────── const hashed = await bcrypt.hash(password, 12); @@ -160,9 +160,9 @@ exports.register = async (req, res) => { }, { transaction }); // ── Enroll into group ───────────────────────────────────────────────────── - if (enrollGroup) { + if (enrollGroupId) { await mdl_UserGroupMembers.create({ - group_id: enrollGroup.group_id, + group_id: enrollGroupId, user_id: user.user_id, createdBy: null, }, { transaction }); @@ -255,7 +255,7 @@ exports.verifyOTP = async (req, res) => { updatedAt: now, }, ]; - if (grp?.group_code === 'NOGRP') { + if (grp?.group_code === NOGRP_CODE) { notifications.push({ user_id: user.user_id, ...NOTIFICATION_REGISTRY.nogrp_welcome.build(), @@ -465,14 +465,7 @@ exports.googleCallback = async (req, res) => { }, }, { transaction: t }); - const noGrp = await mdl_UserGroups.findOne({ where: { group_code: 'NOGRP', is_active: true }, transaction: t }); - if (noGrp) { - await mdl_UserGroupMembers.create({ - group_id: noGrp.group_id, - user_id: user.user_id, - createdBy: null, - }, { transaction: t }); - } + await enrollDefaultGroup(user.user_id, { transaction: t }); await t.commit(); diff --git a/database/migrations/20260715000003-create-certificates.js b/database/migrations/20260715000003-create-certificates.js new file mode 100644 index 0000000..83c22bc --- /dev/null +++ b/database/migrations/20260715000003-create-certificates.js @@ -0,0 +1,40 @@ +'use strict'; + +// Backfill migration: this table already exists live (created outside of +// sequelize-cli before migration coverage was complete). Written to match +// the live CockroachDB schema exactly so a fresh install/cutover recreates +// it. On the existing production DB, mark this filename as already applied +// in SequelizeMeta instead of running it — see project notes. + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('certificates', { + certificate_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true }, + uuid: { type: Sequelize.UUID, allowNull: false, unique: true, defaultValue: Sequelize.literal('gen_random_uuid()') }, + cert_no: { type: Sequelize.STRING(25), allowNull: false, unique: true }, + ref_no: { type: Sequelize.STRING(50), allowNull: false }, + user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' }, + course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' }, + instructors: { type: Sequelize.TEXT, allowNull: true }, + score: { type: Sequelize.BIGINT, allowNull: true }, + length_str: { type: Sequelize.STRING(50), allowNull: true }, + issued_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW }, + created_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW }, + updated_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW }, + }); + + await queryInterface.addConstraint('certificates', { + fields: ['user_id', 'course_id'], + type: 'unique', + name: 'certificates_user_id_course_id_key', + }); + + await queryInterface.addIndex('certificates', ['course_id'], { name: 'idx_certificates_course_id' }); + await queryInterface.addIndex('certificates', ['user_id'], { name: 'idx_certificates_user_id' }); + await queryInterface.addIndex('certificates', ['uuid'], { name: 'idx_certificates_uuid' }); + }, + + async down(queryInterface) { + await queryInterface.dropTable('certificates'); + }, +}; diff --git a/database/migrations/20260715000004-create-course-instructors.js b/database/migrations/20260715000004-create-course-instructors.js new file mode 100644 index 0000000..14b0f8a --- /dev/null +++ b/database/migrations/20260715000004-create-course-instructors.js @@ -0,0 +1,43 @@ +'use strict'; + +// Backfill migration: this table already exists live (created outside of +// sequelize-cli before migration coverage was complete). Written to match +// the live CockroachDB schema exactly so a fresh install/cutover recreates +// it. On the existing production DB, mark this filename as already applied +// in SequelizeMeta instead of running it — see project notes. + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('course_instructors', { + id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true }, + course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' }, + user_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' }, + order_index: { type: Sequelize.BIGINT, allowNull: false, defaultValue: 0 }, + display_name: { type: Sequelize.STRING(255), allowNull: false, defaultValue: '' }, + created_by: { type: Sequelize.BIGINT, allowNull: true }, + created_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW }, + updated_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW }, + }); + + await queryInterface.addConstraint('course_instructors', { + fields: ['course_id', 'user_id'], + type: 'unique', + name: 'course_instructors_course_id_user_id_key', + }); + + await queryInterface.addIndex('course_instructors', ['course_id'], { name: 'idx_course_instructors_course' }); + + // Partial unique index — same columns as the constraint above, scoped to + // non-null user_id, matching what's live. Raw SQL since queryInterface + // addIndex's `where` support is unreliable for IS NOT NULL across dialects. + await queryInterface.sequelize.query(` + CREATE UNIQUE INDEX idx_course_instructors_user + ON course_instructors (course_id, user_id) + WHERE user_id IS NOT NULL; + `); + }, + + async down(queryInterface) { + await queryInterface.dropTable('course_instructors'); + }, +}; diff --git a/database/migrations/20260715000005-create-quiz-sessions.js b/database/migrations/20260715000005-create-quiz-sessions.js new file mode 100644 index 0000000..a2894ec --- /dev/null +++ b/database/migrations/20260715000005-create-quiz-sessions.js @@ -0,0 +1,38 @@ +'use strict'; + +// Backfill migration: this table already exists live (created outside of +// sequelize-cli before migration coverage was complete). Written to match +// the live CockroachDB schema exactly so a fresh install/cutover recreates +// it. On the existing production DB, mark this filename as already applied +// in SequelizeMeta instead of running it — see project notes. +// +// Note: started_at/last_saved_at/createdAt/updatedAt are TIMESTAMP WITHOUT +// TIME ZONE live (unlike most other tables' TIMESTAMPTZ columns), so raw +// type strings are used instead of Sequelize.DATE to match exactly. + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('quiz_sessions', { + session_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true }, + uuid: { type: Sequelize.UUID, allowNull: false, unique: true, defaultValue: Sequelize.literal('gen_random_uuid()') }, + user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' }, + quiz_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'unit_quizzes', key: 'quiz_id' }, onDelete: 'CASCADE' }, + course_id: { type: Sequelize.BIGINT, allowNull: true }, + unit_id: { type: Sequelize.BIGINT, allowNull: true }, + status: { type: Sequelize.STRING(20), allowNull: false, defaultValue: 'in_progress' }, + draft_answers: { type: Sequelize.JSONB, allowNull: true }, + started_at: { type: 'TIMESTAMP', allowNull: false, defaultValue: Sequelize.NOW }, + last_saved_at: { type: 'TIMESTAMP', allowNull: true }, + createdAt: { type: 'TIMESTAMP', allowNull: false, defaultValue: Sequelize.NOW }, + updatedAt: { type: 'TIMESTAMP', allowNull: false, defaultValue: Sequelize.NOW }, + }); + + await queryInterface.addIndex('quiz_sessions', ['quiz_id'], { name: 'idx_qs_quiz_id' }); + await queryInterface.addIndex('quiz_sessions', ['status'], { name: 'idx_qs_status' }); + await queryInterface.addIndex('quiz_sessions', ['user_id'], { name: 'idx_qs_user_id' }); + }, + + async down(queryInterface) { + await queryInterface.dropTable('quiz_sessions'); + }, +}; diff --git a/database/migrations/20260715000006-create-user-bans.js b/database/migrations/20260715000006-create-user-bans.js new file mode 100644 index 0000000..a41bb38 --- /dev/null +++ b/database/migrations/20260715000006-create-user-bans.js @@ -0,0 +1,47 @@ +'use strict'; + +// Backfill migration: this table already exists live (created outside of +// sequelize-cli before migration coverage was complete). Written to match +// the live CockroachDB schema exactly so a fresh install/cutover recreates +// it. On the existing production DB, mark this filename as already applied +// in SequelizeMeta instead of running it — see project notes. + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('user_bans', { + ban_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true }, + user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' }, + banned_by: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' } }, + reason: { type: Sequelize.TEXT, allowNull: false }, + ban_type: { type: Sequelize.STRING(20), allowNull: false }, + banned_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW }, + expires_at: { type: Sequelize.DATE, allowNull: true }, + is_lifted: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false }, + lifted_at: { type: Sequelize.DATE, allowNull: true }, + lifted_by: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'users', key: 'user_id' } }, + lift_reason: { type: Sequelize.TEXT, allowNull: true }, + createdAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW }, + updatedAt: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW }, + }); + + await queryInterface.addConstraint('user_bans', { + fields: ['ban_type'], + type: 'check', + name: 'check_ban_type', + where: { ban_type: { [Sequelize.Op.in]: ['temporary', 'permanent'] } }, + }); + + await queryInterface.addIndex('user_bans', ['user_id'], { name: 'idx_user_bans_user_id' }); + + // Partial index — only rows relevant to the ban-expiry cron sweep. + await queryInterface.sequelize.query(` + CREATE INDEX idx_user_bans_expires_at + ON user_bans (expires_at) + WHERE is_lifted = false AND ban_type = 'temporary'; + `); + }, + + async down(queryInterface) { + await queryInterface.dropTable('user_bans'); + }, +}; diff --git a/utils/defaultGroup.util.js b/utils/defaultGroup.util.js new file mode 100644 index 0000000..13617dd --- /dev/null +++ b/utils/defaultGroup.util.js @@ -0,0 +1,68 @@ +/** + * Every user belongs to exactly one group at all times — a real group, or the + * NOGRP placeholder if they haven't been assigned one yet. NOGRP is a normal + * row-backed group (not a computed "users with no group" view), so every + * writer that moves a user in or out of a real group must also keep their + * NOGRP row in sync. This util is the single place that invariant is + * enforced — see project memory "NOGRP dual-membership fix" (2026-07-15) for + * the bug this replaced (users silently stuck in both NOGRP and a real group). + */ +const { mdl_UserGroups, mdl_UserGroupMembers } = require('../models/users/user_groups.mdl'); + +const NOGRP_CODE = 'NOGRP'; + +async function getDefaultGroupId() { + const nogrp = await mdl_UserGroups.findOne({ where: { group_code: NOGRP_CODE, is_active: true }, attributes: ['group_id'] }); + return nogrp ? nogrp.group_id : null; +} + +/** Registration fallback: enroll a brand-new user into NOGRP. */ +async function enrollDefaultGroup(user_id, { transaction, createdBy = null } = {}) { + const group_id = await getDefaultGroupId(); + if (!group_id) return null; + return mdl_UserGroupMembers.create({ group_id, user_id, createdBy }, { transaction }); +} + +/** + * Call after adding user_ids to a real group: drops their stale NOGRP row so + * they don't sit in both simultaneously. No-op if group_id being added to IS + * NOGRP itself. + */ +async function dropDefaultGroupMembership(user_ids, group_id, { updatedBy = null } = {}) { + const nogrpId = await getDefaultGroupId(); + if (!nogrpId || Number(nogrpId) === Number(group_id)) return; + + await mdl_UserGroupMembers.update( + { deletedBy: updatedBy }, + { where: { user_id: user_ids, group_id: nogrpId } } + ); + await mdl_UserGroupMembers.destroy({ where: { user_id: user_ids, group_id: nogrpId } }); +} + +/** + * Call after removing user_ids from a group: re-enrolls into NOGRP whichever + * of those users are left with zero active group memberships. + */ +async function reconcileDefaultGroup(user_ids, { createdBy = null } = {}) { + const nogrpId = await getDefaultGroupId(); + if (!nogrpId) return; + + const remaining = await mdl_UserGroupMembers.findAll({ + where: { user_id: user_ids }, attributes: ['user_id'], group: ['user_id'], + }); + const stillGrouped = new Set(remaining.map((m) => m.user_id)); + const orphaned = user_ids.filter((id) => !stillGrouped.has(id)); + if (!orphaned.length) return; + + await mdl_UserGroupMembers.restore({ where: { user_id: orphaned, group_id: nogrpId } }); + await mdl_UserGroupMembers.update( + { deletedBy: null, updatedBy: createdBy }, + { where: { user_id: orphaned, group_id: nogrpId }, paranoid: false } + ); + await mdl_UserGroupMembers.bulkCreate( + orphaned.map((user_id) => ({ user_id, group_id: nogrpId, createdBy })), + { ignoreDuplicates: true } + ); +} + +module.exports = { NOGRP_CODE, getDefaultGroupId, enrollDefaultGroup, dropDefaultGroupMembership, reconcileDefaultGroup };