diff --git a/apps/backend/src/lib/graphql/resolvers/divisions/judging/judging-final-deliberation.ts b/apps/backend/src/lib/graphql/resolvers/divisions/judging/judging-final-deliberation.ts index 2b92ca7ac..a7e14ff65 100644 --- a/apps/backend/src/lib/graphql/resolvers/divisions/judging/judging-final-deliberation.ts +++ b/apps/backend/src/lib/graphql/resolvers/divisions/judging/judging-final-deliberation.ts @@ -39,12 +39,12 @@ export const judgingFinalDeliberationResolver: GraphQLFieldResolver< } return { - divisionId: deliberation.divisionId, + divisionId: deliberation.division_id, stage: deliberation.stage, status: deliberation.status, - startTime: deliberation.startTime ? new Date(deliberation.startTime).toISOString() : null, - completionTime: deliberation.completionTime - ? new Date(deliberation.completionTime).toISOString() + startTime: deliberation.start_time ? new Date(deliberation.start_time).toISOString() : null, + completionTime: deliberation.completion_time + ? new Date(deliberation.completion_time).toISOString() : null, champions: JSON.stringify(deliberation.awards.champions || {}), innovationProject: deliberation.awards['innovation-project'] || [], @@ -52,8 +52,8 @@ export const judgingFinalDeliberationResolver: GraphQLFieldResolver< coreValues: deliberation.awards['core-values'] || [], robotPerformance: deliberation.awards['robot-performance'] || [], optionalAwards: JSON.stringify(deliberation.awards.optionalAwards || {}), - coreAwardsManualEligibility: deliberation.stageData?.['core-awards']?.manualEligibility || [], + coreAwardsManualEligibility: deliberation.stage_data?.['core-awards']?.manualEligibility || [], optionalAwardsManualEligibility: - deliberation.stageData?.['optional-awards']?.manualEligibility || [] + deliberation.stage_data?.['optional-awards']?.manualEligibility || [] }; }; diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/advance-final-deliberation-stage.ts b/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/advance-final-deliberation-stage.ts index 1bc2ae1a9..bb6eaa0ad 100644 --- a/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/advance-final-deliberation-stage.ts +++ b/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/advance-final-deliberation-stage.ts @@ -115,8 +115,8 @@ export const advanceFinalDeliberationStageResolver: GraphQLFieldResolver< const updated = await db.finalDeliberations.byDivision(divisionId).update({ stage: nextStage, status: 'not-started', - stageData: { - ...deliberation.stageData, + stage_data: { + ...deliberation.stage_data, [nextStage]: {} } }); @@ -134,7 +134,7 @@ export const advanceFinalDeliberationStageResolver: GraphQLFieldResolver< pubSub.publish(divisionId, RedisEventTypes.FINAL_DELIBERATION_UPDATED, { divisionId, stage: updated.stage, - stageData: updated.stageData + stageData: updated.stage_data }), pubSub.publish(divisionId, RedisEventTypes.FINAL_DELIBERATION_UPDATED, { divisionId, diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/complete-final-deliberation.ts b/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/complete-final-deliberation.ts index 181bb723d..cfc0c4986 100644 --- a/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/complete-final-deliberation.ts +++ b/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/complete-final-deliberation.ts @@ -63,7 +63,7 @@ export const completeFinalDeliberationResolver: GraphQLFieldResolver< const now = new Date(); const updated = await db.finalDeliberations.byDivision(divisionId).update({ status: 'completed', - completionTime: now + completion_time: now }); if (!updated) { @@ -88,7 +88,7 @@ export const completeFinalDeliberationResolver: GraphQLFieldResolver< pubSub.publish(divisionId, RedisEventTypes.FINAL_DELIBERATION_UPDATED, { divisionId, status: updated.status, - completionTime: updated.completionTime + completionTime: updated.completion_time }), pubSub.publish(divisionId, RedisEventTypes.FINAL_DELIBERATION_STATUS_CHANGED, { divisionId, @@ -99,7 +99,7 @@ export const completeFinalDeliberationResolver: GraphQLFieldResolver< return { status: updated.status, - completionTime: updated.completionTime?.toISOString() || now.toISOString() + completionTime: updated.completion_time?.toISOString() || now.toISOString() }; }; diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/start-final-deliberation.ts b/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/start-final-deliberation.ts index e2a055b5d..b6c3ab2a7 100644 --- a/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/start-final-deliberation.ts +++ b/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/start-final-deliberation.ts @@ -66,7 +66,7 @@ export const startFinalDeliberationResolver: GraphQLFieldResolver< const now = new Date(); const updated = await db.finalDeliberations.byDivision(divisionId).update({ status: 'in-progress', - startTime: now + start_time: now }); if (!updated) { @@ -83,7 +83,7 @@ export const startFinalDeliberationResolver: GraphQLFieldResolver< divisionId, status: updated.status, stage: updated.stage, - startTime: updated.startTime + startTime: updated.start_time }), pubSub.publish(divisionId, RedisEventTypes.FINAL_DELIBERATION_STATUS_CHANGED, { divisionId, @@ -95,6 +95,6 @@ export const startFinalDeliberationResolver: GraphQLFieldResolver< return { status: updated.status, stage: updated.stage, - startTime: updated.startTime?.toISOString() || now.toISOString() + startTime: updated.start_time?.toISOString() || now.toISOString() }; }; diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/update-manual-eligibility.ts b/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/update-manual-eligibility.ts index feacc36d9..a1c1fe99a 100644 --- a/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/update-manual-eligibility.ts +++ b/apps/backend/src/lib/graphql/resolvers/mutations/deliberations/update-manual-eligibility.ts @@ -69,16 +69,16 @@ export const updateManualEligibilityResolver: GraphQLFieldResolver< // Update stage data const updatedStageData = { - ...deliberation.stageData, + ...deliberation.stage_data, [stage]: { - ...deliberation.stageData[stage], + ...deliberation.stage_data[stage], manualEligibility: teamIds } }; // Update the deliberation const updated = await db.finalDeliberations.byDivision(divisionId).update({ - stageData: updatedStageData + stage_data: updatedStageData }); if (!updated) { @@ -92,7 +92,7 @@ export const updateManualEligibilityResolver: GraphQLFieldResolver< const pubSub = getRedisPubSub(); await pubSub.publish(divisionId, RedisEventTypes.FINAL_DELIBERATION_UPDATED, { divisionId, - stageData: updated.stageData + stageData: updated.stage_data }); return { diff --git a/libs/database/src/database.ts b/libs/database/src/database.ts index b81467a22..f98460c8a 100644 --- a/libs/database/src/database.ts +++ b/libs/database/src/database.ts @@ -133,7 +133,7 @@ export class Database { this.rooms = new RoomsRepository(this.kysely); this.judgingSessions = new JudgingSessionsRepository(this.kysely); this.judgingDeliberations = new JudgingDeliberationsRepository(this.kysely); - this.finalDeliberations = new FinalDeliberationsRepository(this.mongoDb); + this.finalDeliberations = new FinalDeliberationsRepository(this.kysely); this.rubrics = new RubricsRepository(this.kysely, this.mongoDb); this.tables = new TablesRepository(this.kysely); diff --git a/libs/database/src/migrations/032_migrate_final_deliberations_to_sql.ts b/libs/database/src/migrations/032_migrate_final_deliberations_to_sql.ts new file mode 100644 index 000000000..f3f600cbf --- /dev/null +++ b/libs/database/src/migrations/032_migrate_final_deliberations_to_sql.ts @@ -0,0 +1,107 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Kysely, sql } from 'kysely'; +import { MongoClient } from 'mongodb'; + +const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017'; +const DB_NAME = process.env.DB_NAME || 'lems-local'; + +const MONGO_COLLECTION = 'final_deliberations'; + +/** + * Shape of documents that used to live in the `final_deliberations` MongoDB collection. + */ +interface LegacyFinalDeliberation { + divisionId: string; + stage: string; + status: string; + startTime: Date | null; + completionTime: Date | null; + awards: Record; + stageData: Record; +} + +export async function up(db: Kysely): Promise { + // Create the final_deliberations table + await db.schema + .createTable('final_deliberations') + .addColumn('pk', 'serial', col => col.primaryKey()) + .addColumn('id', 'uuid', col => + col + .notNull() + .unique() + .defaultTo(sql`gen_random_uuid()`) + ) + .addColumn('division_id', 'uuid', col => col.notNull()) + .addColumn('stage', 'text', col => col.notNull().defaultTo('champions')) + .addColumn('status', 'text', col => col.notNull().defaultTo('not-started')) + .addColumn('start_time', 'timestamptz') + .addColumn('completion_time', 'timestamptz') + .addColumn('awards', 'jsonb', col => col.notNull().defaultTo(sql`'{}'::jsonb`)) + .addColumn('stage_data', 'jsonb', col => col.notNull().defaultTo(sql`'{}'::jsonb`)) + .execute(); + + // Create foreign key constraint for division_id + await db.schema + .alterTable('final_deliberations') + .addForeignKeyConstraint('fk_final_deliberations_division_id', ['division_id'], 'divisions', [ + 'id' + ]) + .onDelete('cascade') + .execute(); + + // Create unique constraint for division_id (one final deliberation per division) + await db.schema + .alterTable('final_deliberations') + .addUniqueConstraint('uq_final_deliberations_division_id', ['division_id']) + .execute(); + + // Create index + await db.schema + .createIndex('idx_final_deliberations_division_id') + .on('final_deliberations') + .column('division_id') + .execute(); + + // Migrate existing data from MongoDB's final_deliberations collection, if any is present + const mongoClient = new MongoClient(MONGODB_URI, { tlsAllowInvalidCertificates: true }); + + try { + await mongoClient.connect(); + const mongoDb = mongoClient.db(DB_NAME); + const collections = await mongoDb.listCollections({ name: MONGO_COLLECTION }).toArray(); + + if (collections.length > 0) { + const deliberations = await mongoDb + .collection(MONGO_COLLECTION) + .find({}) + .toArray(); + + for (const deliberation of deliberations) { + await db + .insertInto('final_deliberations') + .values({ + division_id: deliberation.divisionId, + stage: deliberation.stage, + status: deliberation.status, + start_time: deliberation.startTime, + completion_time: deliberation.completionTime, + awards: JSON.stringify(deliberation.awards || {}), + stage_data: JSON.stringify(deliberation.stageData || {}) + }) + .execute(); + } + + // The MongoDB collection is no longer needed now that its data lives in SQL + await mongoDb.collection(MONGO_COLLECTION).drop(); + } + } finally { + await mongoClient.close(); + } +} + +export async function down(db: Kysely): Promise { + // Note: this only reverts the schema change. The MongoDB collection dropped in `up` + // cannot be restored by this migration. + await db.schema.dropIndex('idx_final_deliberations_division_id').ifExists().execute(); + await db.schema.dropTable('final_deliberations').ifExists().execute(); +} diff --git a/libs/database/src/repositories/final-deliberations.ts b/libs/database/src/repositories/final-deliberations.ts index 54a83b2d4..6c5c8c83b 100644 --- a/libs/database/src/repositories/final-deliberations.ts +++ b/libs/database/src/repositories/final-deliberations.ts @@ -1,68 +1,60 @@ -import { Db } from 'mongodb'; -import { FinalDeliberation } from '../schema/documents/final-deliberation'; - -const COLLECTION_NAME = 'final_deliberations'; +import { Kysely } from 'kysely'; +import { KyselyDatabaseSchema } from '../schema/kysely'; +import { FinalDeliberation, FinalDeliberationUpdate } from '../schema/tables/final-deliberation'; export class FinalDeliberationSelector { constructor( - private mongoDb: Db, + private db: Kysely, private divisionId: string ) {} - async get(): Promise { - const deliberation = await this.mongoDb - .collection(COLLECTION_NAME) - .findOne({ divisionId: this.divisionId }); - return deliberation; + private getDeliberationQuery() { + return this.db + .selectFrom('final_deliberations') + .selectAll() + .where('division_id', '=', this.divisionId); } - async update( - updates: Omit, 'divisionId'> - ): Promise { - const result = await this.mongoDb - .collection(COLLECTION_NAME) - .findOneAndUpdate( - { divisionId: this.divisionId }, - { $set: updates }, - { returnDocument: 'after' } - ); + async get(): Promise { + const deliberation = await this.getDeliberationQuery().executeTakeFirst(); + return deliberation || null; + } - return result; + async update(updates: FinalDeliberationUpdate): Promise { + return this.db + .updateTable('final_deliberations') + .set(updates) + .where('division_id', '=', this.divisionId) + .returningAll() + .executeTakeFirst(); } async delete(): Promise { - await this.mongoDb - .collection(COLLECTION_NAME) - .deleteOne({ divisionId: this.divisionId }); + await this.db + .deleteFrom('final_deliberations') + .where('division_id', '=', this.divisionId) + .execute(); } } export class FinalDeliberationsRepository { - constructor(private mongoDb: Db) {} - - async create(divisionId: string): Promise { - const document: FinalDeliberation = { - divisionId, - stage: 'champions', - status: 'not-started', - startTime: null, - completionTime: null, - awards: {}, - stageData: {} - }; + constructor(private db: Kysely) {} - const result = await this.mongoDb - .collection(COLLECTION_NAME) - .insertOne(document); - - if (!result.insertedId) { - throw new Error(`Failed to create final deliberation for division ${divisionId}`); - } - - return document; + byDivision(divisionId: string): FinalDeliberationSelector { + return new FinalDeliberationSelector(this.db, divisionId); } - byDivision(divisionId: string): FinalDeliberationSelector { - return new FinalDeliberationSelector(this.mongoDb, divisionId); + async create(divisionId: string): Promise { + return this.db + .insertInto('final_deliberations') + .values({ + division_id: divisionId, + stage: 'champions', + status: 'not-started', + awards: {}, + stage_data: {} + }) + .returningAll() + .executeTakeFirstOrThrow(); } } diff --git a/libs/database/src/schema/index.ts b/libs/database/src/schema/index.ts index 543bab2c3..e2bd67d5f 100644 --- a/libs/database/src/schema/index.ts +++ b/libs/database/src/schema/index.ts @@ -30,7 +30,7 @@ export * from './tables/team-division-notifications'; // States export * from './documents/rubric'; export * from './documents/scoresheet'; -export * from './documents/final-deliberation'; export * from './tables/judging-deliberation'; +export * from './tables/final-deliberation'; export * from './tables/awards'; diff --git a/libs/database/src/schema/kysely.ts b/libs/database/src/schema/kysely.ts index cba574a54..7b0b17baf 100644 --- a/libs/database/src/schema/kysely.ts +++ b/libs/database/src/schema/kysely.ts @@ -19,6 +19,7 @@ import { RobotGameMatchParticipantsTable } from './tables/robot-game-match-parti import { AwardsTable } from './tables/awards'; import { AgendaEventsTable } from './tables/agenda-events'; import { JudgingDeliberationsTable } from './tables/judging-deliberation'; +import { FinalDeliberationsTable } from './tables/final-deliberation'; export interface KyselyDatabaseSchema { admins: AdminsTable; @@ -42,4 +43,5 @@ export interface KyselyDatabaseSchema { awards: AwardsTable; agenda_events: AgendaEventsTable; judging_deliberations: JudgingDeliberationsTable; + final_deliberations: FinalDeliberationsTable; } diff --git a/libs/database/src/schema/documents/final-deliberation.ts b/libs/database/src/schema/tables/final-deliberation.ts similarity index 68% rename from libs/database/src/schema/documents/final-deliberation.ts rename to libs/database/src/schema/tables/final-deliberation.ts index a61a29c5f..0324fd395 100644 --- a/libs/database/src/schema/documents/final-deliberation.ts +++ b/libs/database/src/schema/tables/final-deliberation.ts @@ -1,3 +1,5 @@ +import type { ColumnType, Generated, Insertable, Selectable, Updateable } from 'kysely'; + export type FinalDeliberationStage = 'champions' | 'core-awards' | 'optional-awards' | 'review'; export type FinalDeliberationStatus = 'not-started' | 'in-progress' | 'completed'; @@ -49,16 +51,18 @@ export interface FinalDeliberationStageData { }; } -/** - * MongoDB document for final deliberation. - * Stores the state of the final deliberation process for a division. - */ -export interface FinalDeliberation { - divisionId: string; // UUID of division from divisions table - stage: FinalDeliberationStage; // Current deliberation stage - status: FinalDeliberationStatus; // Overall deliberation status - startTime: Date | null; // When deliberation was started - completionTime: Date | null; // When deliberation was completed - awards: FinalDeliberationAwards; // Award assignments - stageData: FinalDeliberationStageData; // Per-stage configuration +export interface FinalDeliberationsTable { + pk: ColumnType; // Serial primary key + id: ColumnType; // UUID, generated + division_id: string; // UUID foreign key to divisions.id + stage: Generated; + status: Generated; + start_time: Date | null; + completion_time: Date | null; + awards: Generated; + stage_data: Generated; } + +export type FinalDeliberation = Selectable; +export type NewFinalDeliberation = Insertable; +export type FinalDeliberationUpdate = Updateable;