diff --git a/apps/backend/src/lib/graphql/resolvers/divisions/field/audience-display.ts b/apps/backend/src/lib/graphql/resolvers/divisions/field/audience-display.ts index 03e3557b0..897620e06 100644 --- a/apps/backend/src/lib/graphql/resolvers/divisions/field/audience-display.ts +++ b/apps/backend/src/lib/graphql/resolvers/divisions/field/audience-display.ts @@ -1,5 +1,5 @@ import { GraphQLFieldResolver } from 'graphql'; -import { AudienceDisplayScreen } from '@lems/database'; +import { AudienceDisplay } from '@lems/database'; import db from '../../../../database'; interface FieldWithDivisionId { @@ -8,26 +8,23 @@ interface FieldWithDivisionId { /** * Resolver for Division.audienceDisplay field. - * Fetches field information for a division from the division_states collection. + * Fetches field information for a division from the divisions table's state column. */ export const audienceDisplayResolver: GraphQLFieldResolver< FieldWithDivisionId, unknown, unknown, - Promise> | null> + Promise > = async (field: FieldWithDivisionId) => { try { - const divisionState = await db.raw.mongo - .collection('division_states') - .findOne({ divisionId: field.divisionId }); + const division = await db.divisions.byId(field.divisionId).get(); + const divisionState = division?.state; if (!divisionState) { throw new Error(`Division state not found for division ID: ${field.divisionId}`); } - return divisionState.audienceDisplay ?? { - activeDisplay: 'logo' - }; + return divisionState.audienceDisplay; } catch (error) { console.error('Error fetching audience display for division:', field.divisionId, error); throw error; diff --git a/apps/backend/src/lib/graphql/resolvers/divisions/field/current-stage.ts b/apps/backend/src/lib/graphql/resolvers/divisions/field/current-stage.ts index 814393b95..505392c9e 100644 --- a/apps/backend/src/lib/graphql/resolvers/divisions/field/current-stage.ts +++ b/apps/backend/src/lib/graphql/resolvers/divisions/field/current-stage.ts @@ -16,12 +16,11 @@ export const currentStageResolver: GraphQLFieldResolver< Promise > = async (field: FieldWithDivisionId) => { try { - const divisionState = await db.raw.mongo - .collection('division_states') - .findOne({ divisionId: field.divisionId }); + const division = await db.divisions.byId(field.divisionId).get(); + const divisionState = division?.state; - if (!divisionState || !divisionState.field?.currentStage) { - // Default to PRACTICE if no current stage is set + if (!divisionState) { + // Default to PRACTICE if the division state is not found return 'PRACTICE'; } diff --git a/apps/backend/src/lib/graphql/resolvers/divisions/field/field.ts b/apps/backend/src/lib/graphql/resolvers/divisions/field/field.ts index 7506d68fe..d5657415b 100644 --- a/apps/backend/src/lib/graphql/resolvers/divisions/field/field.ts +++ b/apps/backend/src/lib/graphql/resolvers/divisions/field/field.ts @@ -13,7 +13,7 @@ interface DivisionWithId { /** * Resolver for Division.field field. - * Fetches field information for a division from the division_states collection. + * Fetches field information for a division from the divisions table's state column. */ export const divisionFieldResolver: GraphQLFieldResolver< DivisionWithId, @@ -22,9 +22,8 @@ export const divisionFieldResolver: GraphQLFieldResolver< Promise > = async (division: DivisionWithId) => { try { - const divisionState = await db.raw.mongo - .collection('division_states') - .findOne({ divisionId: division.id }); + const divisionRow = await db.divisions.byId(division.id).get(); + const divisionState = divisionRow?.state; if (!divisionState) { throw new Error(`Division state not found for division ID: ${division.id}`); @@ -32,8 +31,8 @@ export const divisionFieldResolver: GraphQLFieldResolver< return { divisionId: division.id, - loadedMatch: divisionState.field?.loadedMatch ?? null, - activeMatch: divisionState.field?.activeMatch ?? null + loadedMatch: divisionState.field.loadedMatch, + activeMatch: divisionState.field.activeMatch }; } catch (error) { console.error('Error fetching field for division:', division.id, error); diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/audience-display/switch-active-display.ts b/apps/backend/src/lib/graphql/resolvers/mutations/audience-display/switch-active-display.ts index a4f5781a1..f527858e0 100644 --- a/apps/backend/src/lib/graphql/resolvers/mutations/audience-display/switch-active-display.ts +++ b/apps/backend/src/lib/graphql/resolvers/mutations/audience-display/switch-active-display.ts @@ -1,7 +1,7 @@ import { GraphQLFieldResolver } from 'graphql'; import { RedisEventTypes } from '@lems/types/api/lems/redis'; import { MutationError, MutationErrorCode } from '@lems/types/api/lems'; -import { DivisionState, AudienceDisplayScreen } from '@lems/database'; +import { AudienceDisplayScreen } from '@lems/database'; import type { GraphQLContext } from '../../../apollo-server'; import db from '../../../../database'; import { getRedisPubSub } from '../../../../redis/redis-pubsub'; @@ -40,16 +40,12 @@ export const switchActiveDisplayResolver: GraphQLFieldResolver< } } - // Update the division's active display in MongoDB - const result = await db.raw.mongo.collection('division_states').findOneAndUpdate( - { divisionId }, - { - $set: { - 'audienceDisplay.activeDisplay': newDisplay - } - }, - { returnDocument: 'after' } - ); + // Update the division's active display + const result = await db.divisions.byId(divisionId).updateState({ + audienceDisplay: { + activeDisplay: newDisplay + } + }); if (!result) { throw new MutationError( diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/audience-display/update-presentation.ts b/apps/backend/src/lib/graphql/resolvers/mutations/audience-display/update-presentation.ts index 7a865c9cb..e2ac090d3 100644 --- a/apps/backend/src/lib/graphql/resolvers/mutations/audience-display/update-presentation.ts +++ b/apps/backend/src/lib/graphql/resolvers/mutations/audience-display/update-presentation.ts @@ -1,7 +1,7 @@ import { GraphQLFieldResolver } from 'graphql'; import { RedisEventTypes } from '@lems/types/api/lems/redis'; import { MutationError, MutationErrorCode } from '@lems/types/api/lems'; -import { DivisionState, AwardsPresentation } from '@lems/database'; +import { AwardsPresentation } from '@lems/database'; import type { GraphQLContext } from '../../../apollo-server'; import db from '../../../../database'; import { getRedisPubSub } from '../../../../redis/redis-pubsub'; @@ -41,19 +41,15 @@ export const updatePresentationResolver: GraphQLFieldResolver< } } - // Update the division's active display in MongoDB - const result = await db.raw.mongo.collection('division_states').findOneAndUpdate( - { divisionId }, - { - $set: { - 'audienceDisplay.awardsPresentation': { - slideIndex, - stepIndex - } + // Update the division's awards presentation + const result = await db.divisions.byId(divisionId).updateState({ + audienceDisplay: { + awardsPresentation: { + slideIndex, + stepIndex } - }, - { returnDocument: 'after' } - ); + } + }); if (!result) { throw new MutationError( diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/audience-display/update-setting.ts b/apps/backend/src/lib/graphql/resolvers/mutations/audience-display/update-setting.ts index 1d8be8d10..350a73552 100644 --- a/apps/backend/src/lib/graphql/resolvers/mutations/audience-display/update-setting.ts +++ b/apps/backend/src/lib/graphql/resolvers/mutations/audience-display/update-setting.ts @@ -1,7 +1,7 @@ import { GraphQLFieldResolver } from 'graphql'; import { RedisEventTypes } from '@lems/types/api/lems/redis'; import { MutationError, MutationErrorCode } from '@lems/types/api/lems'; -import { DivisionState, AudienceDisplayScreen } from '@lems/database'; +import { AudienceDisplayScreen } from '@lems/database'; import type { GraphQLContext } from '../../../apollo-server'; import db from '../../../../database'; import { getRedisPubSub } from '../../../../redis/redis-pubsub'; @@ -33,16 +33,16 @@ export const updateAudienceDisplaySettingResolver: GraphQLFieldResolver< try { await authorizeAudienceDisplayAccess(context, divisionId); - // Update the division's settings in MongoDB - const result = await db.raw.mongo.collection('division_states').findOneAndUpdate( - { divisionId }, - { - $set: { - [`audienceDisplay.settings.${display}.${settingKey}`]: settingValue + // Update the division's audience display settings + const result = await db.divisions.byId(divisionId).updateState({ + audienceDisplay: { + settings: { + [display]: { + [settingKey]: settingValue + } } - }, - { returnDocument: 'after' } - ); + } + }); if (!result) { throw new MutationError( diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/audience-display/utils.ts b/apps/backend/src/lib/graphql/resolvers/mutations/audience-display/utils.ts index f93cabdb8..c05e04734 100644 --- a/apps/backend/src/lib/graphql/resolvers/mutations/audience-display/utils.ts +++ b/apps/backend/src/lib/graphql/resolvers/mutations/audience-display/utils.ts @@ -34,9 +34,8 @@ export const authorizeAudienceDisplayAccess = async ( } // Check 4: Division must have division state - const divisionState = await db.raw.mongo - .collection('division_states') - .findOne({ divisionId }); + const division = await db.divisions.byId(divisionId).get(); + const divisionState = division?.state; if (!divisionState) { throw new MutationError( diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/matches/abort-match.ts b/apps/backend/src/lib/graphql/resolvers/mutations/matches/abort-match.ts index b8046356b..167295fb9 100644 --- a/apps/backend/src/lib/graphql/resolvers/mutations/matches/abort-match.ts +++ b/apps/backend/src/lib/graphql/resolvers/mutations/matches/abort-match.ts @@ -1,7 +1,6 @@ import { GraphQLFieldResolver } from 'graphql'; import { RedisEventTypes } from '@lems/types/api/lems/redis'; import { MutationError, MutationErrorCode } from '@lems/types/api/lems'; -import { DivisionState } from '@lems/database'; import type { GraphQLContext } from '../../../apollo-server'; import db from '../../../../database'; import { getRedisPubSub } from '../../../../redis/redis-pubsub'; @@ -60,18 +59,12 @@ export const abortMatchResolver: GraphQLFieldResolver< // The dequeue failure should be monitored separately } - const divisionStateResult = await db.raw.mongo - .collection('division_states') - .findOneAndUpdate( - { divisionId }, - { - $set: { - 'field.activeMatch': null, - 'field.loadedMatch': match.stage === 'TEST' ? null : matchId - } - }, - { returnDocument: 'after' } - ); + const divisionStateResult = await db.divisions.byId(divisionId).updateState({ + field: { + activeMatch: null, + loadedMatch: match.stage === 'TEST' ? null : matchId + } + }); if (!divisionStateResult) { throw new MutationError( diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/matches/load-match.ts b/apps/backend/src/lib/graphql/resolvers/mutations/matches/load-match.ts index f17c4e674..0318b0e42 100644 --- a/apps/backend/src/lib/graphql/resolvers/mutations/matches/load-match.ts +++ b/apps/backend/src/lib/graphql/resolvers/mutations/matches/load-match.ts @@ -1,7 +1,6 @@ import { GraphQLFieldResolver } from 'graphql'; import { RedisEventTypes } from '@lems/types/api/lems/redis'; import { MutationError, MutationErrorCode } from '@lems/types/api/lems'; -import { DivisionState } from '@lems/database'; import type { GraphQLContext } from '../../../apollo-server'; import db from '../../../../database'; import { getRedisPubSub } from '../../../../redis/redis-pubsub'; @@ -49,16 +48,12 @@ export const loadMatchResolver: GraphQLFieldResolver< ); } - // Update the division's loaded match in MongoDB - const result = await db.raw.mongo.collection('division_states').findOneAndUpdate( - { divisionId }, - { - $set: { - 'field.loadedMatch': matchId - } - }, - { returnDocument: 'after' } - ); + // Update the division's loaded match + const result = await db.divisions.byId(divisionId).updateState({ + field: { + loadedMatch: matchId + } + }); if (!result) { throw new MutationError( diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/matches/start-match.ts b/apps/backend/src/lib/graphql/resolvers/mutations/matches/start-match.ts index 238418ea7..fff73b0c6 100644 --- a/apps/backend/src/lib/graphql/resolvers/mutations/matches/start-match.ts +++ b/apps/backend/src/lib/graphql/resolvers/mutations/matches/start-match.ts @@ -2,7 +2,6 @@ import dayjs from 'dayjs'; import { GraphQLFieldResolver } from 'graphql'; import { RedisEventTypes } from '@lems/types/api/lems/redis'; import { MutationError, MutationErrorCode } from '@lems/types/api/lems'; -import { DivisionState } from '@lems/database'; import type { GraphQLContext } from '../../../apollo-server'; import db from '../../../../database'; import { getRedisPubSub } from '../../../../redis/redis-pubsub'; @@ -45,13 +44,11 @@ export const startMatchResolver: GraphQLFieldResolver< ); } - const divisionState = await db.raw.mongo - .collection('division_states') - .findOne({ divisionId }); + const divisionState = division.state; // Advance stage if this is the first ranking match being started const shouldAdvanceStage = - match.stage === 'RANKING' && divisionState?.field?.currentStage === 'PRACTICE'; + match.stage === 'RANKING' && divisionState.field.currentStage === 'PRACTICE'; const startTime = dayjs(); const scheduledTime = dayjs(match.scheduled_time); @@ -72,18 +69,12 @@ export const startMatchResolver: GraphQLFieldResolver< } // Update division state with active match - const divisionUpdateResult = await db.raw.mongo - .collection('division_states') - .findOneAndUpdate( - { divisionId }, - { - $set: { - 'field.activeMatch': matchId, - ...(shouldAdvanceStage && { 'field.currentStage': 'RANKING' }) - } - }, - { returnDocument: 'after' } - ); + const divisionUpdateResult = await db.divisions.byId(divisionId).updateState({ + field: { + activeMatch: matchId, + ...(shouldAdvanceStage && { currentStage: 'RANKING' }) + } + }); if (!divisionUpdateResult) { throw new MutationError( @@ -107,13 +98,9 @@ export const startMatchResolver: GraphQLFieldResolver< try { const autoLoadedMatchId = await getAutoLoadMatch(divisionId, currentStage, matchId); if (autoLoadedMatchId) { - await db.raw.mongo - .collection('division_states') - .findOneAndUpdate( - { divisionId }, - { $set: { 'field.loadedMatch': autoLoadedMatchId } }, - { returnDocument: 'after' } - ); + await db.divisions.byId(divisionId).updateState({ + field: { loadedMatch: autoLoadedMatchId } + }); console.log( `[StartMatch] Auto-loaded match ${autoLoadedMatchId} for division ${divisionId}` ); diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/schedule/set-match-participant-team.ts b/apps/backend/src/lib/graphql/resolvers/mutations/schedule/set-match-participant-team.ts index cabb2040d..4fab2421a 100644 --- a/apps/backend/src/lib/graphql/resolvers/mutations/schedule/set-match-participant-team.ts +++ b/apps/backend/src/lib/graphql/resolvers/mutations/schedule/set-match-participant-team.ts @@ -1,6 +1,5 @@ import { GraphQLFieldResolver } from 'graphql'; import { MutationError, MutationErrorCode } from '@lems/types/api/lems'; -import { DivisionState } from '@lems/database'; import type { GraphQLContext } from '../../../apollo-server'; import db from '../../../../database'; @@ -87,11 +86,10 @@ export const setMatchParticipantTeamResolver: GraphQLFieldResolver< } // Check 3: Match must not be currently loaded - const divisionState = await db.raw.mongo - .collection('division_states') - .findOne({ divisionId }); + const division = await db.divisions.byId(divisionId).get(); + const divisionState = division?.state; - if (divisionState?.field?.loadedMatch === matchId) { + if (divisionState?.field.loadedMatch === matchId) { throw new MutationError( MutationErrorCode.CONFLICT, 'Cannot change teams in currently loaded match' diff --git a/apps/backend/src/lib/graphql/resolvers/mutations/schedule/swap-match-teams.ts b/apps/backend/src/lib/graphql/resolvers/mutations/schedule/swap-match-teams.ts index bd3195aac..e4fb2604c 100644 --- a/apps/backend/src/lib/graphql/resolvers/mutations/schedule/swap-match-teams.ts +++ b/apps/backend/src/lib/graphql/resolvers/mutations/schedule/swap-match-teams.ts @@ -1,6 +1,5 @@ import { GraphQLFieldResolver } from 'graphql'; import { MutationError, MutationErrorCode } from '@lems/types/api/lems'; -import { DivisionState } from '@lems/database'; import type { GraphQLContext } from '../../../apollo-server'; import db from '../../../../database'; @@ -87,11 +86,10 @@ export const swapMatchTeamsResolver: GraphQLFieldResolver< } // Check 3: Match must not be currently loaded - const divisionState = await db.raw.mongo - .collection('division_states') - .findOne({ divisionId }); + const division = await db.divisions.byId(divisionId).get(); + const divisionState = division?.state; - if (divisionState?.field?.loadedMatch === matchId) { + if (divisionState?.field.loadedMatch === matchId) { throw new MutationError( MutationErrorCode.CONFLICT, 'Cannot swap teams in currently loaded match' diff --git a/apps/backend/src/lib/queues/handlers/match-completed.ts b/apps/backend/src/lib/queues/handlers/match-completed.ts index 6d28f54d1..bb6d03215 100644 --- a/apps/backend/src/lib/queues/handlers/match-completed.ts +++ b/apps/backend/src/lib/queues/handlers/match-completed.ts @@ -41,13 +41,9 @@ export async function handleMatchCompleted(job: Job): Promise> { - const divisionState = await db.raw.mongo - .collection('division_states') - .findOne({ divisionId }); + const division = await db.divisions.byId(divisionId).get(); + if (!division) { + throw new Error(`Division not found: ${divisionId}`); + } + const divisionState = division.state; const scoresheets = await db.scoresheets .byDivision(divisionId) diff --git a/libs/database/src/database.ts b/libs/database/src/database.ts index 5fd1944b3..b81467a22 100644 --- a/libs/database/src/database.ts +++ b/libs/database/src/database.ts @@ -126,7 +126,7 @@ export class Database { this.seasons = new SeasonsRepository(this.kysely, this.space); this.teams = new TeamsRepository(this.kysely, this.space); this.events = new EventsRepository(this.kysely); - this.divisions = new DivisionsRepository(this.kysely, this.space, this.mongoDb); + this.divisions = new DivisionsRepository(this.kysely, this.space); this.eventUsers = new EventUsersRepository(this.kysely); this.integrations = new EventIntegrationsRepository(this.kysely); diff --git a/libs/database/src/migrations/031_migrate_division_state_to_sql.ts b/libs/database/src/migrations/031_migrate_division_state_to_sql.ts new file mode 100644 index 000000000..61197611a --- /dev/null +++ b/libs/database/src/migrations/031_migrate_division_state_to_sql.ts @@ -0,0 +1,73 @@ +/* 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 = 'division_states'; + +/** + * Shape of documents that used to live in the `division_states` MongoDB collection. + */ +interface LegacyDivisionState { + divisionId: string; + field?: { + loadedMatch: string | null; + activeMatch: string | null; + currentStage: 'PRACTICE' | 'RANKING'; + }; + audienceDisplay?: { + activeDisplay: string; + awardsPresentation?: { slideIndex: number; stepIndex: number }; + settings?: Record>; + }; +} + +export async function up(db: Kysely): Promise { + // Add new column to divisions to hold what used to live in MongoDB. + // Defaults to an empty object rather than a fully-populated default state - the + // application layer (DivisionsRepository) is responsible for initializing the + // default state shape on division creation, so this default never needs to change + // in lockstep with future changes to the default state shape. + await db.schema + .alterTable('divisions') + .addColumn('state', 'jsonb', col => col.notNull().defaultTo(sql`'{}'::jsonb`)) + .execute(); + + // Migrate existing data from MongoDB's division_states 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 states = await mongoDb + .collection(MONGO_COLLECTION) + .find({}) + .toArray(); + + for (const state of states) { + const { divisionId, ...rest } = state; + await db + .updateTable('divisions') + .set({ state: JSON.stringify(rest) }) + .where('id', '=', divisionId) + .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.alterTable('divisions').dropColumn('state').execute(); +} diff --git a/libs/database/src/repositories/divisions.ts b/libs/database/src/repositories/divisions.ts index 7cccb9e81..63db3a625 100644 --- a/libs/database/src/repositories/divisions.ts +++ b/libs/database/src/repositories/divisions.ts @@ -1,13 +1,13 @@ import { Kysely } from 'kysely'; -import { Db as MongoDb } from 'mongodb'; import { KyselyDatabaseSchema } from '../schema/kysely'; import { ObjectStorage } from '../object-storage'; -import { DivisionState } from '../schema/documents/division-state'; +import { deepMerge, DeepPartial } from '../utils/deep-merge'; import { InsertableDivision, Division, UpdateableDivision, - DivisionSummary + DivisionSummary, + DivisionState } from '../schema/tables/divisions'; import { AgendaEvent, @@ -94,7 +94,6 @@ class DivisionSelector { constructor( private db: Kysely, private space: ObjectStorage, - private mongo: MongoDb, private selector: { type: 'id'; value: string } ) {} @@ -117,11 +116,6 @@ class DivisionSelector { } async delete(): Promise { - // Delete division state from MongoDB - await this.mongo - .collection('division_states') - .deleteOne({ divisionId: this.selector.value }); - const result = await this.db .deleteFrom('divisions') .where(this.selector.type, '=', this.selector.value) @@ -129,6 +123,22 @@ class DivisionSelector { return result.length > 0; } + async updateState(patch: DeepPartial): Promise { + const division = await this.get(); + if (!division) return null; + + const mergedState = deepMerge(division.state, patch); + + const updatedDivision = await this.db + .updateTable('divisions') + .set({ state: mergedState }) + .where(this.selector.type, '=', this.selector.value) + .returningAll() + .executeTakeFirst(); + + return updatedDivision?.state ?? null; + } + async updatePitMap(pitMap: Buffer): Promise { const division = await this.get(); if (!division) return null; @@ -203,12 +213,11 @@ class DivisionsSelector { export class DivisionsRepository { constructor( private db: Kysely, - private space: ObjectStorage, - private mongo: MongoDb + private space: ObjectStorage ) {} byId(id: string): DivisionSelector { - return new DivisionSelector(this.db, this.space, this.mongo, { type: 'id', value: id }); + return new DivisionSelector(this.db, this.space, { type: 'id', value: id }); } byEventId(eventId: string): DivisionsSelector { @@ -218,27 +227,10 @@ export class DivisionsRepository { async create(division: InsertableDivision): Promise { const [createdDivision] = await this.db .insertInto('divisions') - .values(division) + .values({ ...division, state: DEFAULT_DIVISION_STATE }) .returningAll() .execute(); - // Create division state in MongoDB with default audience display - await this.mongo.collection('division_states').insertOne({ - divisionId: createdDivision.id, - field: { - loadedMatch: null, - activeMatch: null, - currentStage: 'PRACTICE' - }, - audienceDisplay: { - activeDisplay: 'logo', - awardsPresentation: { - slideIndex: 0, - stepIndex: 0 - } - } - }); - return createdDivision; } @@ -249,31 +241,25 @@ export class DivisionsRepository { const createdDivisions = await this.db .insertInto('divisions') - .values(divisions) + .values(divisions.map(division => ({ ...division, state: DEFAULT_DIVISION_STATE }))) .returningAll() .execute(); - // Create division states in MongoDB for each division with default audience display - const divisionStates: DivisionState[] = createdDivisions.map(division => ({ - divisionId: division.id, - field: { - loadedMatch: null, - activeMatch: null, - currentStage: 'PRACTICE' - }, - audienceDisplay: { - activeDisplay: 'logo', - awardsPresentation: { - slideIndex: 0, - stepIndex: 0 - } - } - })); - - if (divisionStates.length > 0) { - await this.mongo.collection('division_states').insertMany(divisionStates); - } - return createdDivisions; } } + +const DEFAULT_DIVISION_STATE: DivisionState = { + field: { + loadedMatch: null, + activeMatch: null, + currentStage: 'PRACTICE' + }, + audienceDisplay: { + activeDisplay: 'logo', + awardsPresentation: { + slideIndex: 0, + stepIndex: 0 + } + } +}; diff --git a/libs/database/src/schema/documents/division-state.ts b/libs/database/src/schema/documents/division-state.ts deleted file mode 100644 index 588fc2025..000000000 --- a/libs/database/src/schema/documents/division-state.ts +++ /dev/null @@ -1,26 +0,0 @@ -export type AudienceDisplayScreen = - | 'scoreboard' - | 'match_preview' - | 'sponsors' - | 'logo' - | 'message' - | 'awards'; - -export interface AwardsPresentation { - slideIndex: number; - stepIndex: number; -} - -export interface DivisionState { - divisionId: string; - field?: { - loadedMatch: string | null; - activeMatch: string | null; - currentStage: 'PRACTICE' | 'RANKING'; - }; - audienceDisplay?: { - activeDisplay: AudienceDisplayScreen; - awardsPresentation?: AwardsPresentation; - settings?: Record>; - }; -} diff --git a/libs/database/src/schema/index.ts b/libs/database/src/schema/index.ts index 787569a19..543bab2c3 100644 --- a/libs/database/src/schema/index.ts +++ b/libs/database/src/schema/index.ts @@ -28,7 +28,6 @@ export * from './tables/team-divisions'; export * from './tables/team-division-notifications'; // States -export * from './documents/division-state'; export * from './documents/rubric'; export * from './documents/scoresheet'; export * from './documents/final-deliberation'; diff --git a/libs/database/src/schema/tables/divisions.ts b/libs/database/src/schema/tables/divisions.ts index 0aaa14e40..9e72be5b7 100644 --- a/libs/database/src/schema/tables/divisions.ts +++ b/libs/database/src/schema/tables/divisions.ts @@ -9,6 +9,29 @@ export interface DivisionScheduleSettings { timezone?: string; // IANA timezone identifier (e.g., 'Europe/Warsaw') } +export type AudienceDisplayScreen = + 'scoreboard' | 'match_preview' | 'sponsors' | 'logo' | 'message' | 'awards'; + +export interface AwardsPresentation { + slideIndex: number; + stepIndex: number; +} + +export interface AudienceDisplay { + activeDisplay: AudienceDisplayScreen; + awardsPresentation: AwardsPresentation; + settings?: Record>; +} + +export interface DivisionState { + field: { + loadedMatch: string | null; + activeMatch: string | null; + currentStage: 'PRACTICE' | 'RANKING'; + }; + audienceDisplay: AudienceDisplay; +} + export interface DivisionsTable { pk: ColumnType; // Serial primary key id: ColumnType; // UUID, generated @@ -21,6 +44,7 @@ export interface DivisionsTable { has_users: Generated; // Default false awards_assigned: Generated; // Default false schedule_settings: DivisionScheduleSettings | null; + state: Generated; } export type Division = Selectable; diff --git a/libs/database/src/utils/deep-merge.ts b/libs/database/src/utils/deep-merge.ts new file mode 100644 index 000000000..8532fb828 --- /dev/null +++ b/libs/database/src/utils/deep-merge.ts @@ -0,0 +1,33 @@ +/** + * Recursively makes all properties of `T` (including nested objects) optional. + */ +export type DeepPartial = T extends object + ? { + [K in keyof T]?: DeepPartial; + } + : T; + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Deep merges `source` onto `target`, recursively merging plain object values. + * Non-object values (including arrays) in `source` fully replace the corresponding value in `target`. + */ +export function deepMerge(target: T, source: DeepPartial): T { + const result: Record = { ...(target as Record) }; + + for (const key of Object.keys(source)) { + const sourceValue = (source as Record)[key]; + const targetValue = result[key]; + + if (isPlainObject(sourceValue) && isPlainObject(targetValue)) { + result[key] = deepMerge(targetValue, sourceValue); + } else { + result[key] = sourceValue; + } + } + + return result as T; +}