From 6b8a441f0dacdb5b3fec0a5006ae160cf831c48a Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Wed, 3 Jun 2026 01:28:51 -0400 Subject: [PATCH 01/11] Add SQL for creating active classes view. --- src/sql/create_active_class_view.sql | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 src/sql/create_active_class_view.sql diff --git a/src/sql/create_active_class_view.sql b/src/sql/create_active_class_view.sql new file mode 100644 index 00000000..bd630845 --- /dev/null +++ b/src/sql/create_active_class_view.sql @@ -0,0 +1,4 @@ +CREATE VIEW ActiveClasses AS +SELECT * +FROM Classes +WHERE active = 1; From 732295911c77ddc3ac5750bf15ac76896215e24d Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Wed, 3 Jun 2026 01:34:00 -0400 Subject: [PATCH 02/11] Add model for active class. Refactor code in class model to facilitate not duplicating code. --- src/models/active_class.ts | 10 +++ src/models/class.ts | 152 +++++++++++++++++++------------------ 2 files changed, 89 insertions(+), 73 deletions(-) create mode 100644 src/models/active_class.ts diff --git a/src/models/active_class.ts b/src/models/active_class.ts new file mode 100644 index 00000000..6d970277 --- /dev/null +++ b/src/models/active_class.ts @@ -0,0 +1,10 @@ +import { Class, CLASS_ATTRIBUTES, classOptions } from "./class"; +import { Sequelize } from "sequelize"; + +// NB: This model is actually a view defined on the Classes table +// where `active = 1` +export class ActiveClass extends Class {} + +export function initializeActiveClassModel(sequelize: Sequelize) { + ActiveClass.init(CLASS_ATTRIBUTES, classOptions(sequelize)); +} diff --git a/src/models/class.ts b/src/models/class.ts index 4bbc5034..f39e1955 100644 --- a/src/models/class.ts +++ b/src/models/class.ts @@ -1,7 +1,7 @@ import { Educator } from "./educator"; import { Sequelize, DataTypes, Model, InferAttributes, InferCreationAttributes, CreationOptional } from "sequelize"; -const CLASS_STATUS_VALUES = ["seed", "real_good_data", "real_bad_data", "test_good_data", "test_bad_data"] as const; +export const CLASS_STATUS_VALUES = ["seed", "real_good_data", "real_bad_data", "test_good_data", "test_bad_data"] as const; type ClassStatus = typeof CLASS_STATUS_VALUES[number]; export class Class extends Model, InferCreationAttributes> { @@ -20,82 +20,88 @@ export class Class extends Model, InferCreationAttributes declare status: CreationOptional; } -export function initializeClassModel(sequelize: Sequelize) { - Class.init({ - id: { - type: DataTypes.INTEGER.UNSIGNED, - allowNull: false, - autoIncrement: true, - primaryKey: true - }, - name: { - type: DataTypes.STRING, - allowNull: false - }, - educator_id: { - type: DataTypes.INTEGER.UNSIGNED, - allowNull: false, - references: { - model: Educator, - key: "id" - } - }, - created: { - type: DataTypes.DATE, - allowNull: false, - defaultValue: Sequelize.literal("CURRENT_TIMESTAMP") - }, - updated: { - type: DataTypes.DATE, - allowNull: true, - defaultValue: null - }, - active: { - type: DataTypes.BOOLEAN, - allowNull: false, - defaultValue: false - }, - code: { - type: DataTypes.STRING, - allowNull: false - }, - asynchronous: { - type: DataTypes.BOOLEAN, - allowNull: false, - defaultValue: false - }, - test: { - type: DataTypes.BOOLEAN, - allowNull: false, - defaultValue: 0 - }, - seed: { - type: DataTypes.BOOLEAN, - allowNull: false, - defaultValue: 0, - }, - expected_size: { - type: DataTypes.INTEGER.UNSIGNED, - allowNull: false, - }, - small_class: { - type: DataTypes.VIRTUAL, - // type: "tinyint(1) GENERATED ALWAYS AS (expected_size < 15) VIRTUAL", - get() { - return this.expected_size < 15; - } - }, - status: { - type: DataTypes.ENUM(...CLASS_STATUS_VALUES), - allowNull: true, - defaultValue: null, - }, - }, { +export const CLASS_ATTRIBUTES = { + id: { + type: DataTypes.INTEGER.UNSIGNED, + allowNull: false, + autoIncrement: true, + primaryKey: true + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, + educator_id: { + type: DataTypes.INTEGER.UNSIGNED, + allowNull: false, + references: { + model: Educator, + key: "id" + } + }, + created: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: Sequelize.literal("CURRENT_TIMESTAMP") + }, + updated: { + type: DataTypes.DATE, + allowNull: true, + defaultValue: null + }, + active: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + code: { + type: DataTypes.STRING, + allowNull: false + }, + asynchronous: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + test: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: 0 + }, + seed: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: 0, + }, + expected_size: { + type: DataTypes.INTEGER.UNSIGNED, + allowNull: false, + }, + small_class: { + type: DataTypes.VIRTUAL, + // type: "tinyint(1) GENERATED ALWAYS AS (expected_size < 15) VIRTUAL", + get() { + return this.expected_size < 15; + } + }, + status: { + type: DataTypes.ENUM(...CLASS_STATUS_VALUES), + allowNull: true, + defaultValue: null, + }, +}; + +export function classOptions(sequelize: Sequelize) { + return { sequelize, indexes: [ { fields: ["code"], } ] - }); + }; +} + +export function initializeClassModel(sequelize: Sequelize) { + Class.init(CLASS_ATTRIBUTES, classOptions(sequelize)); } From 726d84ceb128779e5f14299536f78cc2607458b9 Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Wed, 3 Jun 2026 01:37:07 -0400 Subject: [PATCH 03/11] Update default value of active to be true. --- src/models/class.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/class.ts b/src/models/class.ts index f39e1955..0394f9d9 100644 --- a/src/models/class.ts +++ b/src/models/class.ts @@ -52,7 +52,7 @@ export const CLASS_ATTRIBUTES = { active: { type: DataTypes.BOOLEAN, allowNull: false, - defaultValue: false + defaultValue: true, }, code: { type: DataTypes.STRING, From 74ed97130aaa6f4d0ceba6223baba15749ac34a6 Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Wed, 3 Jun 2026 01:43:46 -0400 Subject: [PATCH 04/11] Set class to be not active in deletion endpoint, rather than destroying the entry. --- src/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server.ts b/src/server.ts index 08d552a0..f4d5f7d0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1225,7 +1225,7 @@ export function createApp(db: Sequelize, options?: AppOptions): Express { return; } - cls.destroy() + cls.update({ active: false }) .then(() => res.status(204).end()) .catch(error => { console.log(error); From fc2acb9a90a3f8309ea9f1e3f3deb1517a07afe4 Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Wed, 3 Jun 2026 01:45:52 -0400 Subject: [PATCH 05/11] Export active class from models. --- src/database.ts | 1 + src/models/index.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/database.ts b/src/database.ts index f253fe04..c3aae2d3 100644 --- a/src/database.ts +++ b/src/database.ts @@ -5,6 +5,7 @@ import { createNamespace } from "cls-hooked"; import * as S from "@effect/schema/Schema"; import { + ActiveClass, Class, Educator, ClassStories, diff --git a/src/models/index.ts b/src/models/index.ts index 9dd19ca5..38f0d630 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -1,5 +1,6 @@ import { APIKeyRole, initializeAPIKeyRoleModel } from "./api_key_role"; import { Class, initializeClassModel } from "./class"; +import { ActiveClass, initializeActiveClassModel } from "./active_class"; import { DashboardClassGroup, initializeDashboardClassGroupModel } from "./dashboard_class_group"; import { DummyClass, initializeDummyClassModel } from "./dummy_class"; import { Educator, initializeEducatorModel } from "./educator"; @@ -26,6 +27,7 @@ import { initializeUserExperienceRatingModel } from "./user_experience"; export { APIKeyRole, Class, + ActiveClass, ClassStories, CosmicDSSession, DashboardClassGroup, @@ -54,6 +56,7 @@ export function initializeModels(db: Sequelize) { initializeSessionModel(db); initializeEducatorModel(db); initializeClassModel(db); + initializeActiveClassModel(db); initializeStudentModel(db); initializeStoryModel(db); initializeClassStoryModel(db); From c80f3c9f9314d61ef5cd252145aaafab6aeedc8c Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Wed, 3 Jun 2026 15:11:08 -0400 Subject: [PATCH 06/11] Start working on only using active classes. --- src/database.ts | 10 ++++++---- src/server.ts | 8 +++++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/database.ts b/src/database.ts index c3aae2d3..e75ea448 100644 --- a/src/database.ts +++ b/src/database.ts @@ -651,16 +651,18 @@ export async function deleteStageState(studentID: number, storyName: string, sta }); } -export async function getClassesForEducator(educatorID: number): Promise { - return Class.findAll({ +export async function getClassesForEducator(educatorID: number, activeOnly=true): Promise { + const model = activeOnly ? ActiveClass : Class; + return model.findAll({ where: { educator_id: educatorID } }); } -export async function getClassesForStudent(studentID: number): Promise { - return Class.findAll({ +export async function getClassesForStudent(studentID: number, activeOnly=true): Promise { + const model = activeOnly ? ActiveClass : Class; + return model.findAll({ include: [{ model: Student, where: { diff --git a/src/server.ts b/src/server.ts index f4d5f7d0..3498fced 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2177,6 +2177,10 @@ export function createApp(db: Sequelize, options?: AppOptions): Express { * required: true * schema: * type: integer + * - name: active_only + * in: query + * schema: + * type: boolean * responses: * 200: * description: The given educator exists. Returns an object containing the educator ID and a list of Class objects @@ -2200,6 +2204,8 @@ export function createApp(db: Sequelize, options?: AppOptions): Express { */ app.get("/educator-classes/:educatorID", async (req, res) => { const params = req.params; + const activeString = req.query.active as string | undefined; + const active = activeString?.toLowerCase() === "true"; const educatorID = Number(params.educatorID); const educator = await findEducatorById(educatorID); if (educator === null) { @@ -2208,7 +2214,7 @@ export function createApp(db: Sequelize, options?: AppOptions): Express { }); return; } - const classes = await getClassesForEducator(educatorID); + const classes = await getClassesForEducator(educatorID, active); res.json({ educator_id: educatorID, classes, From c2047c930dc7902957ef1d1aaa5a5d58dec7566e Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Thu, 4 Jun 2026 13:41:33 -0400 Subject: [PATCH 07/11] Allow class-finding methods to look for active classes only. Add function for deactivating a class. --- src/database.ts | 27 +++++++++++++++++++-------- src/server.ts | 3 ++- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/database.ts b/src/database.ts index e75ea448..3f2c36ab 100644 --- a/src/database.ts +++ b/src/database.ts @@ -683,30 +683,41 @@ export async function getStudentsForClass(classID: number): Promise { }); } +export async function deactivateClass(cls: Class): Promise { + return cls.update({ active: false }); +} + +export async function deactiveClassByID(id: number): Promise { + return Class.update({ active: false }, { where: { id } }) + .then(result => result[0] > 0); +} + export async function deleteClass(id: number): Promise { return Class.destroy({ where: { id } }); } -export async function findClassByCode(code: string): Promise { - return Class.findOne({ +export async function findClassByCode(code: string, activeOnly=false): Promise { + const model = activeOnly ? ActiveClass : Class; + return model.findOne({ where: { code } }); } -export async function findClassById(id: number): Promise { - return Class.findOne({ +export async function findClassById(id: number, activeOnly=false): Promise { + const model = activeOnly ? ActiveClass : Class; + return model.findOne({ where: { id } }); } -export async function findClassByIdOrCode(identifier: string): Promise { +export async function findClassByIdOrCode(identifier: string, activeOnly=false): Promise { const id = Number(identifier); if (isNaN(id)) { - return findClassByCode(identifier); + return findClassByCode(identifier, activeOnly); } else { - return findClassById(id); + return findClassById(id, activeOnly); } } @@ -918,7 +929,7 @@ export async function isClassStoryActive(classID: number, storyName: string): Pr export async function setClassStoryActive(classID: number, storyName: string, active: boolean): Promise { const result = await ClassStories.update( - { active }, + { active }, { where: { class_id: classID, diff --git a/src/server.ts b/src/server.ts index 3498fced..3e18968e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -34,6 +34,7 @@ import { updateStageState, deleteStageState, findClassById, + deactivateClass, getStages, getStory, getStageStates, @@ -1225,7 +1226,7 @@ export function createApp(db: Sequelize, options?: AppOptions): Express { return; } - cls.update({ active: false }) + deactivateClass(cls) .then(() => res.status(204).end()) .catch(error => { console.log(error); From a4291539f90ff5c853acf05fbf4cde2997833141 Mon Sep 17 00:00:00 2001 From: Jonathan Carifio Date: Thu, 4 Jun 2026 17:12:31 -0400 Subject: [PATCH 08/11] Fix build issues with updated Class model setup. --- src/models/class.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/models/class.ts b/src/models/class.ts index 0394f9d9..bef37e9f 100644 --- a/src/models/class.ts +++ b/src/models/class.ts @@ -16,7 +16,7 @@ export class Class extends Model, InferCreationAttributes declare test: CreationOptional; declare seed: CreationOptional; declare expected_size: CreationOptional; - declare small_class: CreationOptional; + declare readonly small_class: CreationOptional; declare status: CreationOptional; } @@ -80,7 +80,7 @@ export const CLASS_ATTRIBUTES = { small_class: { type: DataTypes.VIRTUAL, // type: "tinyint(1) GENERATED ALWAYS AS (expected_size < 15) VIRTUAL", - get() { + get(this: Class): boolean { return this.expected_size < 15; } }, From db9ba4fbf07b82d6d3b59e1be2518c2b1c4f90c2 Mon Sep 17 00:00:00 2001 From: Jonathan Carifio Date: Thu, 4 Jun 2026 17:15:45 -0400 Subject: [PATCH 09/11] Add Student <--> ActiveClass association. --- src/associations.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/associations.ts b/src/associations.ts index d093cf04..cc7e0227 100644 --- a/src/associations.ts +++ b/src/associations.ts @@ -1,5 +1,6 @@ import { - APIKeyRole, + APIKeyRole, + ActiveClass, Class, ClassStories, IgnoreClass, @@ -35,6 +36,26 @@ export function setUpAssociations() { onDelete: "CASCADE" }); + Student.belongsToMany(ActiveClass, { + through: StudentsClasses, + sourceKey: "id", + targetKey: "id", + foreignKey: "student_id", + otherKey: "class_id", + onUpdate: "CASCADE", + onDelete: "CASCADE" + }); + ActiveClass.belongsToMany(Student, { + through: StudentsClasses, + sourceKey: "id", + targetKey: "id", + foreignKey: "class_id", + otherKey: "student_id", + onUpdate: "CASCADE", + onDelete: "CASCADE" + }); + + Story.belongsToMany(Class, { through: ClassStories, sourceKey: "name", From 2cf4d440dd4015ca3de948f8721e4cbf0f378b01 Mon Sep 17 00:00:00 2001 From: Jonathan Carifio Date: Mon, 8 Jun 2026 12:34:59 -0400 Subject: [PATCH 10/11] Tweak how we handle the active classes view. Create it with an explicit query in test setup. --- src/associations.ts | 9 ++++----- src/models/active_class.ts | 4 ++-- src/models/class.ts | 10 +++------- tests/utils.ts | 13 +++++++++++-- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/associations.ts b/src/associations.ts index cc7e0227..8a770eb7 100644 --- a/src/associations.ts +++ b/src/associations.ts @@ -17,7 +17,7 @@ import { APIKey } from "./models/api_key"; export function setUpAssociations() { - Student.belongsToMany(Class, { + Student.belongsToMany(ActiveClass, { through: StudentsClasses, sourceKey: "id", targetKey: "id", @@ -26,7 +26,7 @@ export function setUpAssociations() { onUpdate: "CASCADE", onDelete: "CASCADE" }); - Class.belongsToMany(Student, { + ActiveClass.belongsToMany(Student, { through: StudentsClasses, sourceKey: "id", targetKey: "id", @@ -36,7 +36,7 @@ export function setUpAssociations() { onDelete: "CASCADE" }); - Student.belongsToMany(ActiveClass, { + Student.belongsToMany(Class, { through: StudentsClasses, sourceKey: "id", targetKey: "id", @@ -45,7 +45,7 @@ export function setUpAssociations() { onUpdate: "CASCADE", onDelete: "CASCADE" }); - ActiveClass.belongsToMany(Student, { + Class.belongsToMany(Student, { through: StudentsClasses, sourceKey: "id", targetKey: "id", @@ -55,7 +55,6 @@ export function setUpAssociations() { onDelete: "CASCADE" }); - Story.belongsToMany(Class, { through: ClassStories, sourceKey: "name", diff --git a/src/models/active_class.ts b/src/models/active_class.ts index 6d970277..5d3026c9 100644 --- a/src/models/active_class.ts +++ b/src/models/active_class.ts @@ -1,4 +1,4 @@ -import { Class, CLASS_ATTRIBUTES, classOptions } from "./class"; +import { Class, CLASS_ATTRIBUTES } from "./class"; import { Sequelize } from "sequelize"; // NB: This model is actually a view defined on the Classes table @@ -6,5 +6,5 @@ import { Sequelize } from "sequelize"; export class ActiveClass extends Class {} export function initializeActiveClassModel(sequelize: Sequelize) { - ActiveClass.init(CLASS_ATTRIBUTES, classOptions(sequelize)); + ActiveClass.init(CLASS_ATTRIBUTES, { sequelize }); } diff --git a/src/models/class.ts b/src/models/class.ts index bef37e9f..63ec0333 100644 --- a/src/models/class.ts +++ b/src/models/class.ts @@ -91,17 +91,13 @@ export const CLASS_ATTRIBUTES = { }, }; -export function classOptions(sequelize: Sequelize) { - return { +export function initializeClassModel(sequelize: Sequelize) { + Class.init(CLASS_ATTRIBUTES, { sequelize, indexes: [ { fields: ["code"], } ] - }; -} - -export function initializeClassModel(sequelize: Sequelize) { - Class.init(CLASS_ATTRIBUTES, classOptions(sequelize)); + }); } diff --git a/tests/utils.ts b/tests/utils.ts index a60bcf14..45f8dfc4 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -76,7 +76,7 @@ export async function setupTestDatabase(): Promise { // See https://github.com/sequelize/sequelize/issues/7953 // and https://stackoverflow.com/a/45114507 // db.sync({ force: true, match: /test/ }).finally(() => db.close()); - await syncTables(); + await syncTables(db); return db; } @@ -86,7 +86,7 @@ export async function teardownTestDatabase(): Promise { await connection.query("DROP DATABASE test;"); } -export async function syncTables(force=false): Promise { +export async function syncTables(db: Sequelize, force=false): Promise { const options = { force, alter: false }; await APIKey.sync(options); await Student.sync(options); @@ -103,6 +103,15 @@ export async function syncTables(force=false): Promise { await StageState.sync(options); await IgnoreStudent.sync(options); await IgnoreClass.sync(options); + + // ActiveClasses is a view, which Sequelize is not great at handling + // so we just create it ourselves here + await db.query(` + CREATE VIEW ActiveClasses AS + SELECT * + FROM Classes + WHERE active = 1; + `); } export async function addAdminAPIKey(): Promise { From 5ca9c6d73b6ac5783db43072eaa1f103007084d5 Mon Sep 17 00:00:00 2001 From: Jonathan Carifio Date: Mon, 8 Jun 2026 12:44:32 -0400 Subject: [PATCH 11/11] Only look for active classes for student by default as well. --- src/server.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/server.ts b/src/server.ts index 3e18968e..65c0ac10 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2205,8 +2205,8 @@ export function createApp(db: Sequelize, options?: AppOptions): Express { */ app.get("/educator-classes/:educatorID", async (req, res) => { const params = req.params; - const activeString = req.query.active as string | undefined; - const active = activeString?.toLowerCase() === "true"; + const activeOnlyString = req.query.active_only as string | undefined; + const activeOnly = activeOnlyString?.toLowerCase() !== "false"; const educatorID = Number(params.educatorID); const educator = await findEducatorById(educatorID); if (educator === null) { @@ -2215,7 +2215,7 @@ export function createApp(db: Sequelize, options?: AppOptions): Express { }); return; } - const classes = await getClassesForEducator(educatorID, active); + const classes = await getClassesForEducator(educatorID, activeOnly); res.json({ educator_id: educatorID, classes, @@ -2236,6 +2236,10 @@ export function createApp(db: Sequelize, options?: AppOptions): Express { * required: true * schema: * type: integer + * - name: active_only + * in: query + * schema: + * type: boolean * responses: * 200: * description: Returns an object containing the student ID, as well as a list of Class items, one for each of the student's classes @@ -2259,8 +2263,10 @@ export function createApp(db: Sequelize, options?: AppOptions): Express { */ app.get("/student-classes/:studentID", async (req, res) => { const params = req.params; + const activeOnlyString = req.query.active_only as string | undefined; + const activeOnly = activeOnlyString?.toLowerCase() !== "false"; const studentID = Number(params.studentID); - const classes = await getClassesForStudent(studentID); + const classes = await getClassesForStudent(studentID, activeOnly); res.json({ student_id: studentID, classes: classes