diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..58b94b31 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Read(//Users/ian/Code/combat-command-components/src/components/Wizard/**)", + "Read(//Users/ian/Code/combat-command-components/**)", + "Bash(npm run lint:*)" + ], + "additionalDirectories": [ + "/Users/ian/Code/combat-command-components/src/components/Wizard" + ] + } +} diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 5d6f6e61..758532bd 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -42,6 +42,7 @@ import type * as _model_common_tournamentPairingConfig from "../_model/common/to import type * as _model_common_tournamentStatus from "../_model/common/tournamentStatus.js"; import type * as _model_common_types from "../_model/common/types.js"; import type * as _model_files_index from "../_model/files/index.js"; +import type * as _model_files_queries_getFileMetadata from "../_model/files/queries/getFileMetadata.js"; import type * as _model_files_queries_getFileUrl from "../_model/files/queries/getFileUrl.js"; import type * as _model_friendships__helpers_deepenFriendship from "../_model/friendships/_helpers/deepenFriendship.js"; import type * as _model_friendships_index from "../_model/friendships/index.js"; @@ -81,8 +82,10 @@ import type * as _model_leagues_queries_getLeagues from "../_model/leagues/queri import type * as _model_leagues_table from "../_model/leagues/table.js"; import type * as _model_leagues_types from "../_model/leagues/types.js"; import type * as _model_lists__helpers_deepenList from "../_model/lists/_helpers/deepenList.js"; +import type * as _model_lists_actions_extractListData from "../_model/lists/actions/extractListData.js"; +import type * as _model_lists_actions_importList from "../_model/lists/actions/importList.js"; import type * as _model_lists_index from "../_model/lists/index.js"; -import type * as _model_lists_mutations_importListData from "../_model/lists/mutations/importListData.js"; +import type * as _model_lists_mutations_createList from "../_model/lists/mutations/createList.js"; import type * as _model_lists_queries_getList from "../_model/lists/queries/getList.js"; import type * as _model_lists_table from "../_model/lists/table.js"; import type * as _model_matchResultComments__helpers_deepenMatchResultComment from "../_model/matchResultComments/_helpers/deepenMatchResultComment.js"; @@ -132,6 +135,7 @@ import type * as _model_tournamentCompetitors_mutations_createTournamentCompetit import type * as _model_tournamentCompetitors_mutations_deleteTournamentCompetitor from "../_model/tournamentCompetitors/mutations/deleteTournamentCompetitor.js"; import type * as _model_tournamentCompetitors_mutations_toggleTournamentCompetitorActive from "../_model/tournamentCompetitors/mutations/toggleTournamentCompetitorActive.js"; import type * as _model_tournamentCompetitors_mutations_updateTournamentCompetitor from "../_model/tournamentCompetitors/mutations/updateTournamentCompetitor.js"; +import type * as _model_tournamentCompetitors_queries_getAvailableTournamentCompetitorActions from "../_model/tournamentCompetitors/queries/getAvailableTournamentCompetitorActions.js"; import type * as _model_tournamentCompetitors_queries_getTournamentCompetitor from "../_model/tournamentCompetitors/queries/getTournamentCompetitor.js"; import type * as _model_tournamentCompetitors_queries_getTournamentCompetitors from "../_model/tournamentCompetitors/queries/getTournamentCompetitors.js"; import type * as _model_tournamentCompetitors_queries_getTournamentCompetitorsByTournament from "../_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.js"; @@ -356,6 +360,7 @@ declare const fullApi: ApiFromModules<{ "_model/common/tournamentStatus": typeof _model_common_tournamentStatus; "_model/common/types": typeof _model_common_types; "_model/files/index": typeof _model_files_index; + "_model/files/queries/getFileMetadata": typeof _model_files_queries_getFileMetadata; "_model/files/queries/getFileUrl": typeof _model_files_queries_getFileUrl; "_model/friendships/_helpers/deepenFriendship": typeof _model_friendships__helpers_deepenFriendship; "_model/friendships/index": typeof _model_friendships_index; @@ -395,8 +400,10 @@ declare const fullApi: ApiFromModules<{ "_model/leagues/table": typeof _model_leagues_table; "_model/leagues/types": typeof _model_leagues_types; "_model/lists/_helpers/deepenList": typeof _model_lists__helpers_deepenList; + "_model/lists/actions/extractListData": typeof _model_lists_actions_extractListData; + "_model/lists/actions/importList": typeof _model_lists_actions_importList; "_model/lists/index": typeof _model_lists_index; - "_model/lists/mutations/importListData": typeof _model_lists_mutations_importListData; + "_model/lists/mutations/createList": typeof _model_lists_mutations_createList; "_model/lists/queries/getList": typeof _model_lists_queries_getList; "_model/lists/table": typeof _model_lists_table; "_model/matchResultComments/_helpers/deepenMatchResultComment": typeof _model_matchResultComments__helpers_deepenMatchResultComment; @@ -446,6 +453,7 @@ declare const fullApi: ApiFromModules<{ "_model/tournamentCompetitors/mutations/deleteTournamentCompetitor": typeof _model_tournamentCompetitors_mutations_deleteTournamentCompetitor; "_model/tournamentCompetitors/mutations/toggleTournamentCompetitorActive": typeof _model_tournamentCompetitors_mutations_toggleTournamentCompetitorActive; "_model/tournamentCompetitors/mutations/updateTournamentCompetitor": typeof _model_tournamentCompetitors_mutations_updateTournamentCompetitor; + "_model/tournamentCompetitors/queries/getAvailableTournamentCompetitorActions": typeof _model_tournamentCompetitors_queries_getAvailableTournamentCompetitorActions; "_model/tournamentCompetitors/queries/getTournamentCompetitor": typeof _model_tournamentCompetitors_queries_getTournamentCompetitor; "_model/tournamentCompetitors/queries/getTournamentCompetitors": typeof _model_tournamentCompetitors_queries_getTournamentCompetitors; "_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament": typeof _model_tournamentCompetitors_queries_getTournamentCompetitorsByTournament; diff --git a/convex/_model/common/_helpers/getDocStrict.ts b/convex/_model/common/_helpers/getDocStrict.ts new file mode 100644 index 00000000..55938320 --- /dev/null +++ b/convex/_model/common/_helpers/getDocStrict.ts @@ -0,0 +1,27 @@ +import { GenericDatabaseReader } from 'convex/server'; +import { ConvexError, GenericId } from 'convex/values'; + +import { + DataModel, + Doc, + TableNames, +} from '../../../_generated/dataModel'; + +/** + * Fetches a document by ID, throwing a `ConvexError` if it doesn't exist. + * + * @param ctx - A context object with a database reader. + * @param id - The ID of the document to fetch. + * @returns The document. + * @throws {ConvexError} If no document exists with the given ID. + */ +export const getDocStrict = async ( + ctx: { db: GenericDatabaseReader }, + id: GenericId, +): Promise> => { + const doc = await ctx.db.get(id); + if (!doc) { + throw new ConvexError({ message: `Document not found: ${id}`, code: 'DOCUMENT_NOT_FOUND' }); + } + return doc; +}; diff --git a/convex/_model/files/queries/getFileMetadata.ts b/convex/_model/files/queries/getFileMetadata.ts new file mode 100644 index 00000000..4d0988e7 --- /dev/null +++ b/convex/_model/files/queries/getFileMetadata.ts @@ -0,0 +1,25 @@ +import { GenericDoc } from '@convex-dev/auth/server'; +import { SystemDataModel } from 'convex/server'; +import { + ConvexError, + Infer, + v, +} from 'convex/values'; + +import { QueryCtx } from '../../../_generated/server'; +import { getErrorMessage } from '../../common/errors'; + +export const getFileMetadataArgs = v.object({ + id: v.id('_storage'), +}); + +export const getFileMetadata = async ( + ctx: QueryCtx, + args: Infer, +): Promise | null> => { + const fileMetadata = await ctx.db.system.get(args.id); + if (!fileMetadata) { + throw new ConvexError(getErrorMessage('FILE_NOT_FOUND')); + } + return fileMetadata; +}; diff --git a/convex/_model/lists/_helpers/checkListVisibility.ts b/convex/_model/lists/_helpers/checkListVisibility.ts new file mode 100644 index 00000000..2a7cef26 --- /dev/null +++ b/convex/_model/lists/_helpers/checkListVisibility.ts @@ -0,0 +1,24 @@ +import { getAuthUserId } from '@convex-dev/auth/server'; + +import { Doc } from '../../../_generated/dataModel'; +import { QueryCtx } from '../../../_generated/server'; +import { getDocStrict } from '../../common/_helpers/getDocStrict'; +import { checkUsersAreTeammates } from '../../tournamentCompetitors/_helpers/checkUsersAreTeammates'; +import { checkUserIsTournamentOrganizer } from '../../tournamentOrganizers'; + +export const checkListVisibility = async ( + ctx: QueryCtx, + doc: Doc<'lists'>, +): Promise => { + const userId = await getAuthUserId(ctx); + const tournamentRegistration = await getDocStrict(ctx, doc.tournamentRegistrationId); + const tournament = await getDocStrict(ctx, tournamentRegistration.tournamentId); + const isOrganizer = await checkUserIsTournamentOrganizer(ctx, tournament._id, userId); + const isTeammate = await checkUsersAreTeammates(ctx, tournamentRegistration.userId, userId); + + if (isOrganizer || isTeammate || tournament.listsRevealed) { + return true; + } + + return false; +}; diff --git a/convex/_model/lists/_helpers/deepenList.ts b/convex/_model/lists/_helpers/deepenList.ts index e7a6daff..1443ba6b 100644 --- a/convex/_model/lists/_helpers/deepenList.ts +++ b/convex/_model/lists/_helpers/deepenList.ts @@ -1,10 +1,11 @@ -import { GameSystem } from '@ianpaschal/combat-command-game-systems/common'; -import { ConvexError } from 'convex/values'; +/* eslint-disable arrow-body-style */ +// import { GameSystem } from '@ianpaschal/combat-command-game-systems/common'; +// import { ConvexError } from 'convex/values'; import { Doc } from '../../../_generated/dataModel'; import { QueryCtx } from '../../../_generated/server'; -import { getErrorMessage } from '../../common/errors'; -import { FlamesOfWarV4 } from '../../gameSystems'; +// import { getErrorMessage } from '../../common/errors'; +// import { FlamesOfWarV4 } from '../../gameSystems'; /* eslint-disable @typescript-eslint/explicit-function-return-type */ /** @@ -19,19 +20,26 @@ import { FlamesOfWarV4 } from '../../gameSystems'; */ export const deepenList = async ( ctx: QueryCtx, - list: Doc<'lists'>, + doc: Doc<'lists'>, ) => { - // TODO-250: Add Team Yankee support here. - if (list.gameSystem === GameSystem.FlamesOfWarV4) { - return { - ...list, - data: FlamesOfWarV4.deepenListData(list.data), - }; - } - - // If no matcher found, throw an error: - throw new ConvexError(getErrorMessage('CANNOT_ADD_ANOTHER_PLAYER')); + // // TODO-250: Add Team Yankee support here. + // if (list.gameSystem === GameSystem.FlamesOfWarV4) { + // return { + // ...list, + // data: FlamesOfWarV4.deepenListData(list.data), + // }; + // } + + // if (list.gameSystem === GameSystem.TeamYankeeV2) { + // return { + // ...list, + // data: TeamYankeeV2.deepenListData(list.data), + // }; + // } + // // If no matcher found, throw an error: + // throw new ConvexError(getErrorMessage('CANNOT_ADD_ANOTHER_PLAYER')); + return doc; }; /** diff --git a/convex/_model/gameSystems/battlefront/flamesOfWarV4/_helpers/deepenListData.ts b/convex/_model/lists/gameSystems/battlefront/flamesOfWarV4/_helpers/deepenListData.ts similarity index 88% rename from convex/_model/gameSystems/battlefront/flamesOfWarV4/_helpers/deepenListData.ts rename to convex/_model/lists/gameSystems/battlefront/flamesOfWarV4/_helpers/deepenListData.ts index 663cb67d..3b31fc1a 100644 --- a/convex/_model/gameSystems/battlefront/flamesOfWarV4/_helpers/deepenListData.ts +++ b/convex/_model/lists/gameSystems/battlefront/flamesOfWarV4/_helpers/deepenListData.ts @@ -1,6 +1,6 @@ import { factions, forceDiagrams } from '@ianpaschal/combat-command-game-systems/flamesOfWarV4'; -import { Doc } from '../../../../../_generated/dataModel'; +import { Doc } from '../../../../../../_generated/dataModel'; export type DeepFowV4ListData = ReturnType; diff --git a/convex/_model/gameSystems/battlefront/flamesOfWarV4/_model/listData.ts b/convex/_model/lists/gameSystems/battlefront/flamesOfWarV4/_model/listData.ts similarity index 82% rename from convex/_model/gameSystems/battlefront/flamesOfWarV4/_model/listData.ts rename to convex/_model/lists/gameSystems/battlefront/flamesOfWarV4/_model/listData.ts index 3f9aef03..03f93ef3 100644 --- a/convex/_model/gameSystems/battlefront/flamesOfWarV4/_model/listData.ts +++ b/convex/_model/lists/gameSystems/battlefront/flamesOfWarV4/_model/listData.ts @@ -1,13 +1,12 @@ import { ForceDiagram, Unit } from '@ianpaschal/combat-command-game-systems/flamesOfWarV4'; import { Infer, v } from 'convex/values'; -import { getStaticEnumConvexValidator } from '../../../../common/_helpers/getStaticEnumConvexValidator'; +import { getStaticEnumConvexValidator } from '../../../../../common/_helpers/getStaticEnumConvexValidator'; const forceDiagram = getStaticEnumConvexValidator(ForceDiagram); const unit = getStaticEnumConvexValidator(Unit); export const listData = v.object({ - tournamentRegistrationId: v.optional(v.id('tournamentRegistrations')), meta: v.object({ forceDiagram, pointsLimit: v.number(), diff --git a/convex/_model/gameSystems/battlefront/flamesOfWarV4/index.ts b/convex/_model/lists/gameSystems/battlefront/flamesOfWarV4/index.ts similarity index 100% rename from convex/_model/gameSystems/battlefront/flamesOfWarV4/index.ts rename to convex/_model/lists/gameSystems/battlefront/flamesOfWarV4/index.ts diff --git a/convex/_model/gameSystems/index.ts b/convex/_model/lists/gameSystems/index.ts similarity index 100% rename from convex/_model/gameSystems/index.ts rename to convex/_model/lists/gameSystems/index.ts diff --git a/convex/_model/lists/index.ts b/convex/_model/lists/index.ts index 22089660..a26ab965 100644 --- a/convex/_model/lists/index.ts +++ b/convex/_model/lists/index.ts @@ -3,10 +3,17 @@ import { Id } from '../../_generated/dataModel'; export type ListId = Id<'lists'>; export { - importListData, - importListDataArgs, -} from './mutations/importListData'; + type DeepList, +} from './_helpers/deepenList'; +export { + createList, + createListArgs, +} from './mutations/createList'; export { getList, getListArgs, } from './queries/getList'; +export { + getListsByTournamentRegistration, + getListsByTournamentRegistrationArgs, +} from './queries/getListsByTournamentRegistration'; diff --git a/convex/_model/lists/mutations/createList.ts b/convex/_model/lists/mutations/createList.ts new file mode 100644 index 00000000..bc7e2623 --- /dev/null +++ b/convex/_model/lists/mutations/createList.ts @@ -0,0 +1,41 @@ +import { + ConvexError, + Infer, + v, +} from 'convex/values'; + +import { Id } from '../../../_generated/dataModel'; +import { MutationCtx } from '../../../_generated/server'; +import { getErrorMessage } from '../../common/errors'; +import { editableFields } from '../table'; + +// const forceDiagram = getStaticEnumConvexValidator(ForceDiagram); +// const formation = getStaticEnumConvexValidator(Unit); + +// const nanoid = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz', 6); + +export const createListArgs = v.object(editableFields); + +export const createList = async ( + ctx: MutationCtx, + args: Infer, +): Promise> => { + const tournamentRegistration = await ctx.db.get(args.tournamentRegistrationId); + if (!tournamentRegistration) { + throw new ConvexError(getErrorMessage('TOURNAMENT_REGISTRATION_NOT_FOUND')); + } + const existingLists = await ctx.db.query('lists') + .withIndex('by_tournament_registration', (q) => q.eq('tournamentRegistrationId', args.tournamentRegistrationId)) + .collect(); + const tournament = await ctx.db.get(tournamentRegistration.tournamentId); + if (!tournament) { + throw new ConvexError(getErrorMessage('TOURNAMENT_NOT_FOUND')); + } + const { listSubmissionClosesAt } = tournament; + + return await ctx.db.insert('lists', { + ...args, + locked: Date.now() > listSubmissionClosesAt, + primary: existingLists.length === 0, + }); +}; diff --git a/convex/_model/lists/mutations/importListData.ts b/convex/_model/lists/mutations/importListData.ts deleted file mode 100644 index 3aa6d69c..00000000 --- a/convex/_model/lists/mutations/importListData.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { GameSystem } from '@ianpaschal/combat-command-game-systems/common'; -import { ForceDiagram, Unit } from '@ianpaschal/combat-command-game-systems/flamesOfWarV4'; -import { Infer, v } from 'convex/values'; -import { customAlphabet } from 'nanoid'; - -import { MutationCtx } from '../../../_generated/server'; -import { getStaticEnumConvexValidator } from '../../common/_helpers/getStaticEnumConvexValidator'; - -const forceDiagram = getStaticEnumConvexValidator(ForceDiagram); -const formation = getStaticEnumConvexValidator(Unit); - -const nanoid = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz', 6); - -export const importListDataArgs = v.object({ - pointsLimit: v.number(), - data: v.array(v.object({ - displayName: v.optional(v.string()), // Not used, but allowed because its handy in the raw data. - forceDiagram, - formations: v.array(formation), - playerUserId: v.id('users'), - tournamentRegistrationId: v.id('tournamentRegistrations'), - })), - locked: v.boolean(), -}); - -export const importListData = async ( - ctx: MutationCtx, - args: Infer, -): Promise => { - for (const row of args.data) { - const listId = await ctx.db.insert('lists', { - gameSystem: GameSystem.FlamesOfWarV4, - ownerUserId: row.playerUserId, - data: { - meta: { - forceDiagram: row.forceDiagram, - pointsLimit: args.pointsLimit, - }, - formations: row.formations.map((sourceId) => ({ - id: nanoid(), - sourceId, - })), - units: [], - commandCards: [], - }, - locked: args.locked, - }); - - await ctx.db.patch(row.tournamentRegistrationId, { listId }); - } -}; diff --git a/convex/_model/lists/queries/getList.ts b/convex/_model/lists/queries/getList.ts index 31f1bda2..206b77f8 100644 --- a/convex/_model/lists/queries/getList.ts +++ b/convex/_model/lists/queries/getList.ts @@ -1,6 +1,7 @@ import { Infer, v } from 'convex/values'; import { QueryCtx } from '../../../_generated/server'; +import { checkListVisibility } from '../_helpers/checkListVisibility'; import { deepenList, DeepList } from '../_helpers/deepenList'; export const getListArgs = v.object({ @@ -19,9 +20,12 @@ export const getList = async ( ctx: QueryCtx, args: Infer, ): Promise => { - const list = await ctx.db.get(args.id); - if (!list) { + const result = await ctx.db.get(args.id); + if (!result) { return null; } - return await deepenList(ctx, list); + if (await checkListVisibility(ctx, result)) { + return await deepenList(ctx, result); + } + return null; }; diff --git a/convex/_model/lists/queries/getListsByTournamentRegistration.ts b/convex/_model/lists/queries/getListsByTournamentRegistration.ts new file mode 100644 index 00000000..7fbc4b8d --- /dev/null +++ b/convex/_model/lists/queries/getListsByTournamentRegistration.ts @@ -0,0 +1,34 @@ +import { Infer, v } from 'convex/values'; + +import { QueryCtx } from '../../../_generated/server'; +import { notNullOrUndefined } from '../../common/_helpers/notNullOrUndefined'; +import { checkListVisibility } from '../_helpers/checkListVisibility'; +import { deepenList, DeepList } from '../_helpers/deepenList'; + +export const getListsByTournamentRegistrationArgs = v.object({ + tournamentRegistrationId: v.id('tournamentRegistrations'), +}); + +/** + * Gets a list by tournament registration. + * + * @param ctx - Convex query context + * @param args - Convex query args + * @param args.id - ID of the tournament registration + * @returns - An array of deepened lists + */ +export const getListsByTournamentRegistration = async ( + ctx: QueryCtx, + args: Infer, +): Promise => { + const results = await ctx.db.query('lists') + .withIndex('by_tournament_registration', (q) => q.eq('tournamentRegistrationId', args.tournamentRegistrationId)) + .collect(); + const deepResults = await Promise.all(results.map(async (r) => { + if (await checkListVisibility(ctx, r)) { + return await deepenList(ctx, r); + } + return null; + })); + return deepResults.filter(notNullOrUndefined); +}; diff --git a/convex/_model/lists/table.ts b/convex/_model/lists/table.ts index c98f4884..1f9edecf 100644 --- a/convex/_model/lists/table.ts +++ b/convex/_model/lists/table.ts @@ -3,14 +3,15 @@ import { defineTable } from 'convex/server'; import { v } from 'convex/values'; import { getStaticEnumConvexValidator } from '../common/_helpers/getStaticEnumConvexValidator'; -import { FlamesOfWarV4 } from '../gameSystems'; +import { FlamesOfWarV4 } from './gameSystems'; const gameSystem = getStaticEnumConvexValidator(GameSystem); export const editableFields = { - gameSystem: gameSystem, - data: FlamesOfWarV4.listData, - ownerUserId: v.id('users'), + data: FlamesOfWarV4.listData, // TODO: Union with other types + gameSystem, + storageId: v.optional(v.id('_storage')), // TODO: NOt optional after migration + tournamentRegistrationId: v.id('tournamentRegistrations'), }; /** @@ -19,10 +20,15 @@ export const editableFields = { export const computedFields = { modifiedAt: v.optional(v.number()), locked: v.optional(v.boolean()), + // approvalStatus: v.union(v.literal('approved'), v.literal('rejected'), v.null()), + primary: v.optional(v.boolean()), + + ownerUserId: v.optional(v.id('users')), // Deprecated, remove with migration }; export default defineTable({ ...editableFields, ...computedFields, }) - .index('by_owner_user_id', ['ownerUserId']); + .index('by_game_system', ['gameSystem']) + .index('by_tournament_registration', ['tournamentRegistrationId']); diff --git a/convex/_model/matchResults/queries/getMatchResults.ts b/convex/_model/matchResults/queries/getMatchResults.ts index bc43fce5..76f1e5fe 100644 --- a/convex/_model/matchResults/queries/getMatchResults.ts +++ b/convex/_model/matchResults/queries/getMatchResults.ts @@ -6,6 +6,11 @@ import { deepenMatchResult, DeepMatchResult } from '../_helpers/deepenMatchResul export const getMatchResultsArgs = v.object({ paginationOpts: paginationOptsValidator, + filter: v.optional(v.object({ + tournamentId: v.optional(v.id('tournaments')), + tournamentPairingId: v.optional(v.id('tournaments')), + tournamentCompetitorId: v.optional(v.id('tournaments')), + })), }); export const getMatchResults = async ( diff --git a/convex/_model/tournamentCompetitors/_helpers/checkUsersAreTeammates.ts b/convex/_model/tournamentCompetitors/_helpers/checkUsersAreTeammates.ts new file mode 100644 index 00000000..1652a48a --- /dev/null +++ b/convex/_model/tournamentCompetitors/_helpers/checkUsersAreTeammates.ts @@ -0,0 +1,24 @@ +import { Id } from '../../../_generated/dataModel'; +import { QueryCtx } from '../../../_generated/server'; + +export const checkUsersAreTeammates = async ( + ctx: QueryCtx, + user0Id: Id<'users'> | null, + user1Id: Id<'users'> | null, +): Promise => { + if (!user0Id || !user1Id) { + return false; + } + const player0Records = await ctx.db.query('tournamentRegistrations') + .withIndex('by_user', (q) => q.eq('userId', user0Id)) + .collect(); + const player1Records = await ctx.db.query('tournamentRegistrations') + .withIndex('by_user', (q) => q.eq('userId', user1Id)) + .collect(); + + const player0CompetitorIds = new Set(player0Records.map((r) => r.tournamentCompetitorId)); + + return player1Records.some((r) => + player0CompetitorIds.has(r.tournamentCompetitorId), + ); +}; diff --git a/convex/_model/tournamentRegistrations/_helpers/deepenTournamentRegistration.ts b/convex/_model/tournamentRegistrations/_helpers/deepenTournamentRegistration.ts index 51e6f51f..1769a799 100644 --- a/convex/_model/tournamentRegistrations/_helpers/deepenTournamentRegistration.ts +++ b/convex/_model/tournamentRegistrations/_helpers/deepenTournamentRegistration.ts @@ -3,7 +3,9 @@ import { ConvexError } from 'convex/values'; import { Doc } from '../../../_generated/dataModel'; import { QueryCtx } from '../../../_generated/server'; +import { getDocStrict } from '../../common/_helpers/getDocStrict'; import { getErrorMessage } from '../../common/errors'; +import { getListsByTournamentRegistration } from '../../lists'; import { checkUserIsTournamentOrganizer } from '../../tournamentOrganizers'; import { getUser } from '../../users'; import { getAvailableActions } from './getAvailableActions'; @@ -20,10 +22,8 @@ export const deepenTournamentRegistration = async ( if (!user) { throw new ConvexError(getErrorMessage('USER_NOT_FOUND')); } - const tournament = await ctx.db.get(doc.tournamentId); - if (!tournament) { - throw new ConvexError(getErrorMessage('TOURNAMENT_NOT_FOUND')); - } + + const tournament = await getDocStrict(ctx, doc.tournamentId); const availableActions = await getAvailableActions(ctx, doc); @@ -32,6 +32,7 @@ export const deepenTournamentRegistration = async ( const factionsVisible = isOrganizer || tournament.factionsRevealed; // TODO: Use lists if they are present. getDetails() + const lists = await getListsByTournamentRegistration(ctx, { tournamentRegistrationId: doc._id }); const alignments = Array.from(new Set(alignmentsVisible && details?.alignment ? [details.alignment] : [])); const factions = Array.from(new Set(factionsVisible && details?.faction ? [details.faction] : [])); @@ -48,6 +49,7 @@ export const deepenTournamentRegistration = async ( displayName: user.displayName, alignments, factions, + lists, }; }; diff --git a/convex/_model/tournaments/actions/exportFowV4TournamentMatchData.ts b/convex/_model/tournaments/actions/exportFowV4TournamentMatchData.ts index 30df2181..16c555a1 100644 --- a/convex/_model/tournaments/actions/exportFowV4TournamentMatchData.ts +++ b/convex/_model/tournaments/actions/exportFowV4TournamentMatchData.ts @@ -42,17 +42,17 @@ export const exportFowV4TournamentMatchData = async ( type GeneralKey = keyof typeof matchResult; type DetailsKey = keyof typeof matchResult.details; const playerUser = i === 0 ? matchResult.player0User : matchResult.player1User; - const playerList = i === 0 ? matchResult.player0List : matchResult.player1List; + // const playerList = i === 0 ? matchResult.player0List : matchResult.player1List; const playerTeam = tournamentCompetitors.find((c) => c.registrations.find((r) => r.user?._id === playerUser?._id)); return { ...acc, [`player_${letter}_team`]: playerTeam?.teamName ?? '', [`player_${letter}_user_id`]: playerUser?._id ?? '', [`player_${letter}_name`]: playerUser?.displayName ?? matchResult[`player${i}Placeholder` as GeneralKey], - [`player_${letter}_force_diagram`]: playerList?.data.meta.forceDiagram ?? '', - [`player_${letter}_faction`]: playerList?.data.meta.faction ?? '', - [`player_${letter}_formation_0`]: playerList?.data.formations[0]?.sourceId ?? '', - [`player_${letter}_formation_1`]: playerList?.data.formations[1]?.sourceId ?? '', + // [`player_${letter}_force_diagram`]: playerList?.data.meta.forceDiagram ?? '', + // [`player_${letter}_faction`]: playerList?.data.meta.faction ?? '', + // [`player_${letter}_formation_0`]: playerList?.data.formations[0]?.sourceId ?? '', + // [`player_${letter}_formation_1`]: playerList?.data.formations[1]?.sourceId ?? '', [`player_${letter}_battle_plan`]: details[`player${i}BattlePlan` as DetailsKey], [`player_${letter}_units_lost`]: details[`player${i}UnitsLost` as DetailsKey], [`player_${letter}_score`]: matchResult[`player${i}Score` as DetailsKey], diff --git a/convex/_model/tournaments/table.ts b/convex/_model/tournaments/table.ts index 19cb31f6..7f372e1a 100644 --- a/convex/_model/tournaments/table.ts +++ b/convex/_model/tournaments/table.ts @@ -60,6 +60,7 @@ export const editableFields = { alignmentsRevealed: v.optional(v.boolean()), factionsRevealed: v.optional(v.boolean()), + listsRevealed: v.optional(v.boolean()), // Format pairingConfig: tournamentPairingConfig, diff --git a/convex/lists.ts b/convex/lists.ts index 1068c1e5..79a233ff 100644 --- a/convex/lists.ts +++ b/convex/lists.ts @@ -1,7 +1,17 @@ -import { mutation } from './_generated/server'; +import { mutation, query } from './_generated/server'; import * as model from './_model/lists'; -export const importListData = mutation({ - args: model.importListDataArgs, - handler: model.importListData, +export const createList = mutation({ + args: model.createListArgs, + handler: model.createList, +}); + +export const getList = query({ + args: model.getListArgs, + handler: model.getList, +}); + +export const getListsByTournamentRegistration = query({ + args: model.getListsByTournamentRegistrationArgs, + handler: model.getListsByTournamentRegistration, }); diff --git a/src/api.ts b/src/api.ts index 41724490..74550cad 100644 --- a/src/api.ts +++ b/src/api.ts @@ -32,6 +32,11 @@ export { validateTournamentPairing, } from '../convex/_model/tournamentPairings'; +// Lists +export { + type DeepList as List, +} from '../convex/_model/lists'; + // Match Result Comments export { type DeepMatchResultComment as MatchResultComment, diff --git a/src/components/ListCreateDialog/ListCreateDialog.hooks.tsx b/src/components/ListCreateDialog/ListCreateDialog.hooks.tsx new file mode 100644 index 00000000..ad11f952 --- /dev/null +++ b/src/components/ListCreateDialog/ListCreateDialog.hooks.tsx @@ -0,0 +1,4 @@ +import { TournamentRegistrationId } from '~/api'; +import { useModal } from '~/modals'; + +export const useListCreateDialog = () => useModal<{ tournamentRegistrationId: TournamentRegistrationId }>('list-create'); diff --git a/src/components/ListCreateDialog/ListCreateDialog.module.scss b/src/components/ListCreateDialog/ListCreateDialog.module.scss new file mode 100644 index 00000000..b2567c76 --- /dev/null +++ b/src/components/ListCreateDialog/ListCreateDialog.module.scss @@ -0,0 +1,5 @@ +@use "/src/style/flex"; + +.ListCreateDialog { + @include flex.column; +} diff --git a/src/components/ListCreateDialog/ListCreateDialog.tsx b/src/components/ListCreateDialog/ListCreateDialog.tsx new file mode 100644 index 00000000..3093bb8b --- /dev/null +++ b/src/components/ListCreateDialog/ListCreateDialog.tsx @@ -0,0 +1,129 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { useState } from 'react'; +import { GameSystem } from '@ianpaschal/combat-command-game-systems/common'; +import clsx from 'clsx'; +import { FileText, Link } from 'lucide-react'; + +import { StorageId, TournamentRegistrationId } from '~/api'; +import { Button } from '~/components/generic/Button'; +import { + ControlledDialog, + DialogActions, + DialogHeader, +} from '~/components/generic/Dialog'; +import { FormField } from '~/components/generic/Form'; +import { InputSelect } from '~/components/generic/InputSelect'; +import { ScrollArea } from '~/components/generic/ScrollArea'; +import { Separator } from '~/components/generic/Separator'; +import { + Tabs, + TabsContent, + TabsList, +} from '~/components/generic/Tabs'; +import { useUploadFile } from '~/services/files'; +import { useCreateList } from '~/services/lists'; +import { useListCreateDialog } from './ListCreateDialog.hooks'; + +import styles from './ListCreateDialog.module.scss'; + +export interface ListCreateDialogProps { + className?: string; + gameSystem: GameSystem; +} + +export const ListCreateDialog = ({ + className, + gameSystem, +}: ListCreateDialogProps): JSX.Element => { + const { id, close, data } = useListCreateDialog(); + const tournamentRegistrationId = data?.tournamentRegistrationId; + + const tabs = [ + { value: 'pdf', label: 'PDF', icon: }, + { value: 'forces_official', label: 'Link', icon: }, + ]; + const [tab, setTab] = useState('pdf'); + + // TODO: Maybe someday turn these into form fields? + const [storageId, setStorageId] = useState(null); + const [fileType, setFileType] = useState(null); + const [url, setUrl] = useState(''); + + const { mutation: uploadFile } = useUploadFile({ + onSuccess: setStorageId, + }); + + const { mutation: createList, loading: isCreateListLoading } = useCreateList(); + + const actionsDisabled = isCreateListLoading; + + const handleTabChange = (value: string) => { + // if dirty, show warning dialog + setTab(value); + }; + + const handleUpload = async (file: Blob): Promise => { + await uploadFile(file); + }; + + const handleImport = async (identifier: string): Promise => { + // await importList({ + // gameSystem, + // url, + // }); + }; + + const handleSubmit = (data: object): void => { + // createList({ + // data, + // gameSystem, + // rawStorageId: storageId, + // tournamentRegistrationId, + // }); + }; + + return ( + + + + + + + + File upload + + + JSON import + + + {gameSystem === GameSystem.FlamesOfWarV4 && ( +
+
+ Extracted data +
+ {fileType === 'pdf' && ( +
PDF Preview
+ )} + {fileType === 'json' && ( +
JSON Preview
+ )} +
+ )} +
+ + +