diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..39bf3efc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: CI + +on: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '24' + cache: 'npm' + + - name: Install dependencies + run: npm ci + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Unit tests + run: npm test + + - name: Deploy Convex preview + run: | + npx convex deploy \ + --preview-create "pr-${{ github.event.pull_request.number }}-${{ github.run_attempt }}" \ + --cmd-url-env-var-name VITE_CONVEX_URL \ + --cmd 'echo "VITE_CONVEX_URL=$VITE_CONVEX_URL" >> "$GITHUB_ENV"' + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} + + - name: Configure preview deployment environment + run: | + PREVIEW="pr-${{ github.event.pull_request.number }}-${{ github.run_attempt }}" + npx convex env set --preview-name "$PREVIEW" -- JWT_PRIVATE_KEY "$JWT_PRIVATE_KEY" + npx convex env set --preview-name "$PREVIEW" -- JWKS "$JWKS" + npx convex env set --preview-name "$PREVIEW" -- AUTH_RESEND_KEY "$AUTH_RESEND_KEY" + npx convex env set --preview-name "$PREVIEW" -- SITE_URL "http://localhost:5173" + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} + JWT_PRIVATE_KEY: ${{ secrets.CONVEX_JWT_PRIVATE_KEY }} + JWKS: ${{ secrets.CONVEX_JWKS }} + AUTH_RESEND_KEY: ${{ secrets.CONVEX_AUTH_RESEND_KEY }} + + - name: Install Playwright browsers + run: npx playwright install firefox --with-deps + + - name: E2E tests + run: npm run test:e2e + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} + CONVEX_PREVIEW_NAME: pr-${{ github.event.pull_request.number }}-${{ github.run_attempt }} + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 575b20b5..6cbd4973 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ pnpm-debug.log* lerna-debug.log* coverage +playwright-report +test-results +test/.auth node_modules dist dist-ssr diff --git a/convex/_fixtures/createMockTournament.ts b/convex/_fixtures/createMockTournament.ts index 7cdcd9f0..96a4befd 100644 --- a/convex/_fixtures/createMockTournament.ts +++ b/convex/_fixtures/createMockTournament.ts @@ -2,10 +2,12 @@ import { CurrencyCode, GameSystem, getGameSystem, - tournamentPairingConfig, } from '@ianpaschal/combat-command-game-systems/common'; import { Doc } from '../_generated/dataModel'; +import { + defaultValues as tournamentPairingConfigDefaultValues, +} from '../_model/common/tournamentPairingConfig'; import { RankingFactor } from '../_model/common/types'; const DAY_LENGTH_MS = 172800000; @@ -46,7 +48,10 @@ export const createMockTournament = ( currency: CurrencyCode.EUR, }, roundCount: 5, - pairingConfig: tournamentPairingConfig.defaultValues, + pairingConfig: { + ...tournamentPairingConfigDefaultValues, + tableCount: Math.ceil((overrides?.maxCompetitors ?? 48) / 2), + }, gameSystemConfig: gameSystemConfig.defaultValues, startsAt: Date.now() + (DAY_LENGTH_MS * 3), endsAt: Date.now() + (DAY_LENGTH_MS * 5), diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 319b1146..0a721b5b 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -11,6 +11,10 @@ import type * as _fixtures_createMockTournament from "../_fixtures/createMockTournament.js"; import type * as _fixtures_createMockTournamentCompetitor from "../_fixtures/createMockTournamentCompetitor.js"; import type * as _fixtures_fowV4_createMockFowV4MatchResultData from "../_fixtures/fowV4/createMockFowV4MatchResultData.js"; +import type * as _model__test__helpers_testUsers from "../_model/_test/_helpers/testUsers.js"; +import type * as _model__test_actions_populateUsers from "../_model/_test/actions/populateUsers.js"; +import type * as _model__test_index from "../_model/_test/index.js"; +import type * as _model__test_mutations_cleanUp from "../_model/_test/mutations/cleanUp.js"; import type * as _model_common_VisibilityLevel from "../_model/common/VisibilityLevel.js"; import type * as _model_common__helpers_buildFilteredQuery from "../_model/common/_helpers/buildFilteredQuery.js"; import type * as _model_common__helpers_checkAuth from "../_model/common/_helpers/checkAuth.js"; @@ -192,6 +196,7 @@ import type * as _model_tournamentRegistrations__helpers_deepenTournamentRegistr import type * as _model_tournamentRegistrations__helpers_getAvailableActions from "../_model/tournamentRegistrations/_helpers/getAvailableActions.js"; import type * as _model_tournamentRegistrations__helpers_getCreateSuccessMessage from "../_model/tournamentRegistrations/_helpers/getCreateSuccessMessage.js"; import type * as _model_tournamentRegistrations__helpers_getDeleteSuccessMessage from "../_model/tournamentRegistrations/_helpers/getDeleteSuccessMessage.js"; +import type * as _model_tournamentRegistrations__helpers_sortTournamentCompetitorsByName from "../_model/tournamentRegistrations/_helpers/sortTournamentCompetitorsByName.js"; import type * as _model_tournamentRegistrations_index from "../_model/tournamentRegistrations/index.js"; import type * as _model_tournamentRegistrations_mutations_createTournamentRegistration from "../_model/tournamentRegistrations/mutations/createTournamentRegistration.js"; import type * as _model_tournamentRegistrations_mutations_deleteTournamentRegistration from "../_model/tournamentRegistrations/mutations/deleteTournamentRegistration.js"; @@ -233,6 +238,7 @@ import type * as _model_tournaments__helpers_deepenTournament from "../_model/to import type * as _model_tournaments__helpers_extractSearchTokens from "../_model/tournaments/_helpers/extractSearchTokens.js"; import type * as _model_tournaments__helpers_getAvailableActions from "../_model/tournaments/_helpers/getAvailableActions.js"; import type * as _model_tournaments__helpers_getDisplayName from "../_model/tournaments/_helpers/getDisplayName.js"; +import type * as _model_tournaments__helpers_getLastVisibleRound from "../_model/tournaments/_helpers/getLastVisibleRound.js"; import type * as _model_tournaments__helpers_getTournamentDeep from "../_model/tournaments/_helpers/getTournamentDeep.js"; import type * as _model_tournaments__helpers_getTournamentNextRound from "../_model/tournaments/_helpers/getTournamentNextRound.js"; import type * as _model_tournaments__helpers_getTournamentPlayerUserIds from "../_model/tournaments/_helpers/getTournamentPlayerUserIds.js"; @@ -249,6 +255,7 @@ import type * as _model_tournaments_mutations_startTournamentRound from "../_mod import type * as _model_tournaments_mutations_toggleTournamentAlignmentsRevealed from "../_model/tournaments/mutations/toggleTournamentAlignmentsRevealed.js"; import type * as _model_tournaments_mutations_toggleTournamentListsRevealed from "../_model/tournaments/mutations/toggleTournamentListsRevealed.js"; import type * as _model_tournaments_mutations_updateTournament from "../_model/tournaments/mutations/updateTournament.js"; +import type * as _model_tournaments_mutations_updateTournamentPairingConfig from "../_model/tournaments/mutations/updateTournamentPairingConfig.js"; import type * as _model_tournaments_queries_getTournament from "../_model/tournaments/queries/getTournament.js"; import type * as _model_tournaments_queries_getTournamentByTournamentPairing from "../_model/tournaments/queries/getTournamentByTournamentPairing.js"; import type * as _model_tournaments_queries_getTournamentOpenRound from "../_model/tournaments/queries/getTournamentOpenRound.js"; @@ -288,11 +295,11 @@ import type * as _model_users_queries_getUsers from "../_model/users/queries/get import type * as _model_users_queries_internal_getUserByClaimToken from "../_model/users/queries/internal/getUserByClaimToken.js"; import type * as _model_users_queries_internal_getUserByEmail from "../_model/users/queries/internal/getUserByEmail.js"; import type * as _model_users_queries_internal_getUserById from "../_model/users/queries/internal/getUserById.js"; +import type * as _model_users_queries_internal_getUserByUsername from "../_model/users/queries/internal/getUserByUsername.js"; import type * as _model_users_table from "../_model/users/table.js"; -import type * as _model_utils__helpers_testUsers from "../_model/utils/_helpers/testUsers.js"; -import type * as _model_utils_actions_populateUsers from "../_model/utils/actions/populateUsers.js"; import type * as _model_utils_createTestTournament from "../_model/utils/createTestTournament.js"; import type * as _model_utils_createTestTournamentMatchResults from "../_model/utils/createTestTournamentMatchResults.js"; +import type * as _model_utils_createTestTournamentPairings from "../_model/utils/createTestTournamentPairings.js"; import type * as _model_utils_deleteTestTournament from "../_model/utils/deleteTestTournament.js"; import type * as _model_utils_getTournamentOrganizerList from "../_model/utils/getTournamentOrganizerList.js"; import type * as _model_utils_getTournamentRegistrationsCsv from "../_model/utils/getTournamentRegistrationsCsv.js"; @@ -321,6 +328,7 @@ import type * as matchResults from "../matchResults.js"; import type * as migrations from "../migrations.js"; import type * as photos from "../photos.js"; import type * as scheduledTasks from "../scheduledTasks.js"; +import type * as test from "../test.js"; import type * as tournamentCompetitors from "../tournamentCompetitors.js"; import type * as tournamentPairings from "../tournamentPairings.js"; import type * as tournamentRegistrations from "../tournamentRegistrations.js"; @@ -349,6 +357,10 @@ declare const fullApi: ApiFromModules<{ "_fixtures/createMockTournament": typeof _fixtures_createMockTournament; "_fixtures/createMockTournamentCompetitor": typeof _fixtures_createMockTournamentCompetitor; "_fixtures/fowV4/createMockFowV4MatchResultData": typeof _fixtures_fowV4_createMockFowV4MatchResultData; + "_model/_test/_helpers/testUsers": typeof _model__test__helpers_testUsers; + "_model/_test/actions/populateUsers": typeof _model__test_actions_populateUsers; + "_model/_test/index": typeof _model__test_index; + "_model/_test/mutations/cleanUp": typeof _model__test_mutations_cleanUp; "_model/common/VisibilityLevel": typeof _model_common_VisibilityLevel; "_model/common/_helpers/buildFilteredQuery": typeof _model_common__helpers_buildFilteredQuery; "_model/common/_helpers/checkAuth": typeof _model_common__helpers_checkAuth; @@ -530,6 +542,7 @@ declare const fullApi: ApiFromModules<{ "_model/tournamentRegistrations/_helpers/getAvailableActions": typeof _model_tournamentRegistrations__helpers_getAvailableActions; "_model/tournamentRegistrations/_helpers/getCreateSuccessMessage": typeof _model_tournamentRegistrations__helpers_getCreateSuccessMessage; "_model/tournamentRegistrations/_helpers/getDeleteSuccessMessage": typeof _model_tournamentRegistrations__helpers_getDeleteSuccessMessage; + "_model/tournamentRegistrations/_helpers/sortTournamentCompetitorsByName": typeof _model_tournamentRegistrations__helpers_sortTournamentCompetitorsByName; "_model/tournamentRegistrations/index": typeof _model_tournamentRegistrations_index; "_model/tournamentRegistrations/mutations/createTournamentRegistration": typeof _model_tournamentRegistrations_mutations_createTournamentRegistration; "_model/tournamentRegistrations/mutations/deleteTournamentRegistration": typeof _model_tournamentRegistrations_mutations_deleteTournamentRegistration; @@ -571,6 +584,7 @@ declare const fullApi: ApiFromModules<{ "_model/tournaments/_helpers/extractSearchTokens": typeof _model_tournaments__helpers_extractSearchTokens; "_model/tournaments/_helpers/getAvailableActions": typeof _model_tournaments__helpers_getAvailableActions; "_model/tournaments/_helpers/getDisplayName": typeof _model_tournaments__helpers_getDisplayName; + "_model/tournaments/_helpers/getLastVisibleRound": typeof _model_tournaments__helpers_getLastVisibleRound; "_model/tournaments/_helpers/getTournamentDeep": typeof _model_tournaments__helpers_getTournamentDeep; "_model/tournaments/_helpers/getTournamentNextRound": typeof _model_tournaments__helpers_getTournamentNextRound; "_model/tournaments/_helpers/getTournamentPlayerUserIds": typeof _model_tournaments__helpers_getTournamentPlayerUserIds; @@ -587,6 +601,7 @@ declare const fullApi: ApiFromModules<{ "_model/tournaments/mutations/toggleTournamentAlignmentsRevealed": typeof _model_tournaments_mutations_toggleTournamentAlignmentsRevealed; "_model/tournaments/mutations/toggleTournamentListsRevealed": typeof _model_tournaments_mutations_toggleTournamentListsRevealed; "_model/tournaments/mutations/updateTournament": typeof _model_tournaments_mutations_updateTournament; + "_model/tournaments/mutations/updateTournamentPairingConfig": typeof _model_tournaments_mutations_updateTournamentPairingConfig; "_model/tournaments/queries/getTournament": typeof _model_tournaments_queries_getTournament; "_model/tournaments/queries/getTournamentByTournamentPairing": typeof _model_tournaments_queries_getTournamentByTournamentPairing; "_model/tournaments/queries/getTournamentOpenRound": typeof _model_tournaments_queries_getTournamentOpenRound; @@ -626,11 +641,11 @@ declare const fullApi: ApiFromModules<{ "_model/users/queries/internal/getUserByClaimToken": typeof _model_users_queries_internal_getUserByClaimToken; "_model/users/queries/internal/getUserByEmail": typeof _model_users_queries_internal_getUserByEmail; "_model/users/queries/internal/getUserById": typeof _model_users_queries_internal_getUserById; + "_model/users/queries/internal/getUserByUsername": typeof _model_users_queries_internal_getUserByUsername; "_model/users/table": typeof _model_users_table; - "_model/utils/_helpers/testUsers": typeof _model_utils__helpers_testUsers; - "_model/utils/actions/populateUsers": typeof _model_utils_actions_populateUsers; "_model/utils/createTestTournament": typeof _model_utils_createTestTournament; "_model/utils/createTestTournamentMatchResults": typeof _model_utils_createTestTournamentMatchResults; + "_model/utils/createTestTournamentPairings": typeof _model_utils_createTestTournamentPairings; "_model/utils/deleteTestTournament": typeof _model_utils_deleteTestTournament; "_model/utils/getTournamentOrganizerList": typeof _model_utils_getTournamentOrganizerList; "_model/utils/getTournamentRegistrationsCsv": typeof _model_utils_getTournamentRegistrationsCsv; @@ -659,6 +674,7 @@ declare const fullApi: ApiFromModules<{ migrations: typeof migrations; photos: typeof photos; scheduledTasks: typeof scheduledTasks; + test: typeof test; tournamentCompetitors: typeof tournamentCompetitors; tournamentPairings: typeof tournamentPairings; tournamentRegistrations: typeof tournamentRegistrations; diff --git a/convex/_model/utils/_helpers/testUsers.ts b/convex/_model/_test/_helpers/testUsers.ts similarity index 100% rename from convex/_model/utils/_helpers/testUsers.ts rename to convex/_model/_test/_helpers/testUsers.ts diff --git a/convex/_model/utils/actions/populateUsers.ts b/convex/_model/_test/actions/populateUsers.ts similarity index 100% rename from convex/_model/utils/actions/populateUsers.ts rename to convex/_model/_test/actions/populateUsers.ts diff --git a/convex/_model/_test/index.ts b/convex/_model/_test/index.ts new file mode 100644 index 00000000..b79fe703 --- /dev/null +++ b/convex/_model/_test/index.ts @@ -0,0 +1,2 @@ +export * from './actions/populateUsers'; +export * from './mutations/cleanUp'; diff --git a/convex/_model/_test/mutations/cleanUp.ts b/convex/_model/_test/mutations/cleanUp.ts new file mode 100644 index 00000000..656a0f28 --- /dev/null +++ b/convex/_model/_test/mutations/cleanUp.ts @@ -0,0 +1,24 @@ +import type { TableNamesInDataModel } from 'convex/server'; +import { Infer, v } from 'convex/values'; + +import type { DataModel } from '../../../_generated/dataModel'; +import type { MutationCtx } from '../../../_generated/server'; +import schema from '../../../schema'; + +type TableName = TableNamesInDataModel; + +export const cleanUpArgs = v.object({ + preservedTables: v.array(v.string()), +}); + +export const cleanUp = async ( + ctx: MutationCtx, + args: Infer, +): Promise => { + const preserved = new Set(args.preservedTables); + const tables = (Object.keys(schema.tables) as TableName[]).filter((t) => !preserved.has(t)); + for (const table of tables) { + const docs = await ctx.db.query(table).collect(); + await Promise.all(docs.map((doc) => ctx.db.delete(doc._id))); + } +}; diff --git a/convex/_model/common/errors.ts b/convex/_model/common/errors.ts index e3d2e9b7..252f9c00 100644 --- a/convex/_model/common/errors.ts +++ b/convex/_model/common/errors.ts @@ -110,11 +110,9 @@ export const errors = { CANNOT_ADD_PAIRINGS_TO_ARCHIVED_TOURNAMENT: 'Cannot add pairings to an archived tournament.', CANNOT_ADD_PAIRINGS_TO_DRAFT_TOURNAMENT: 'Cannot add pairings to a draft tournament.', CANNOT_ADD_PAIRINGS_TO_IN_PROGRESS_ROUND: 'Cannot add pairings while round is already in-progress.', - CANNOT_ADD_PAIRINGS_TO_PUBLISHED_TOURNAMENT: 'Cannot add pairings to a tournament that hasn\'t started yet.', CANNOT_DELETE_PAIRINGS_FROM_ARCHIVED_TOURNAMENT: 'Cannot delete pairings from an archived tournament.', CANNOT_DELETE_PAIRINGS_FROM_DRAFT_TOURNAMENT: 'Cannot delete pairings from a draft tournament.', CANNOT_DELETE_PAIRINGS_FROM_IN_PROGRESS_ROUND: 'Cannot delete pairings while round is in-progress.', - CANNOT_DELETE_PAIRINGS_FROM_PUBLISHED_TOURNAMENT: 'Cannot delete pairings from a tournament that hasn\'t started yet.', CANNOT_ADD_TOO_MANY_PAIRINGS: 'Cannot add more pairings than the tournament is set-up for.', TOURNAMENT_ALREADY_HAS_PAIRINGS_FOR_ROUND: 'Tournament already has pairings for this round.', diff --git a/convex/_model/common/tournamentPairingConfig.ts b/convex/_model/common/tournamentPairingConfig.ts index 20730f1d..d1ee1451 100644 --- a/convex/_model/common/tournamentPairingConfig.ts +++ b/convex/_model/common/tournamentPairingConfig.ts @@ -1,4 +1,36 @@ -import { tournamentPairingConfig as config } from '@ianpaschal/combat-command-game-systems/common'; +import { TournamentPairingPolicy } from '@ianpaschal/combat-command-game-systems/common'; +import { createEnumSchema } from '@ianpaschal/combat-command-game-systems/utils'; import { zodToConvex } from 'convex-helpers/server/zod'; +import { z } from 'zod'; -export const tournamentPairingConfig = zodToConvex(config.schema); +import { Id } from '../../_generated/dataModel'; + +export const schema = z.object({ + orderBy: z.union([z.literal('ranking'), z.literal('random')]), + policies: z.object({ + sameAlignment: createEnumSchema(TournamentPairingPolicy), + repeat: createEnumSchema(TournamentPairingPolicy), + }), + tableCount: z.optional(z.number().min(1)), // TODO: REMOVE POST MIGRATION +}); + +export const generationSchema = schema.extend({ + include: z.array(z.string()).transform((val): Id<'tournamentCompetitors'>[] => val as Id<'tournamentCompetitors'>[]), +}); + +export type TournamentPairingConfig = z.infer; + +export type TournamentPairingPolicies = Partial; + +export const defaultValues = { + orderBy: 'ranking', + policies: { + sameAlignment: TournamentPairingPolicy.Allow, + repeat: TournamentPairingPolicy.Block, + }, + tableCount: 1, +} satisfies TournamentPairingConfig; + +export const tournamentPairingConfig = zodToConvex(schema); + +export const tournamentPairingInput = zodToConvex(generationSchema); diff --git a/convex/_model/tournamentCompetitors/mutations/createTournamentCompetitor.ts b/convex/_model/tournamentCompetitors/mutations/createTournamentCompetitor.ts index fcb7ff4c..eea5e72e 100644 --- a/convex/_model/tournamentCompetitors/mutations/createTournamentCompetitor.ts +++ b/convex/_model/tournamentCompetitors/mutations/createTournamentCompetitor.ts @@ -36,7 +36,7 @@ export const createTournamentCompetitor = async ( throw new ConvexError(getErrorMessage('CANNOT_MODIFY_ARCHIVED_TOURNAMENT')); } const tournamentCompetitors = await ctx.db.query('tournamentCompetitors') - .withIndex('by_tournament_id', (q) => q.eq('tournamentId', args.tournamentId)) + .withIndex('by_tournament', (q) => q.eq('tournamentId', args.tournamentId)) .collect(); const existingTeamNames = tournamentCompetitors.map((item) => item.teamName); if (args.teamName && existingTeamNames.includes(args.teamName)) { diff --git a/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts b/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts index 2a44b051..66b1caac 100644 --- a/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts +++ b/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts @@ -16,10 +16,14 @@ export const getTournamentCompetitorsByTournament = async ( ctx: QueryCtx, args: Infer, ): Promise => { - const tournamentCompetitors = await ctx.db.query('tournamentCompetitors') - .withIndex('by_tournament_id', (q) => q.eq('tournamentId', args.tournamentId)) + const results = await ctx.db.query('tournamentCompetitors') + .withIndex('by_tournament', (q) => q.eq('tournamentId', args.tournamentId)) .collect(); - return (await Promise.all(tournamentCompetitors.map(async (item) => ( + const deepened = await Promise.all(results.map(async (item) => ( await deepenTournamentCompetitor(ctx, item, args.rankingRound) - )))).sort(sortTournamentCompetitorsByName); + ))); + if (args.rankingRound !== undefined) { + return deepened.sort((a, b) => a.rank - b.rank); + } + return deepened.sort(sortTournamentCompetitorsByName); }; diff --git a/convex/_model/tournamentCompetitors/table.ts b/convex/_model/tournamentCompetitors/table.ts index 6b015cd4..3a212142 100644 --- a/convex/_model/tournamentCompetitors/table.ts +++ b/convex/_model/tournamentCompetitors/table.ts @@ -22,4 +22,4 @@ export default defineTable({ ...editableFields, ...computedFields, }) - .index('by_tournament_id', ['tournamentId']); + .index('by_tournament', ['tournamentId']); diff --git a/convex/_model/tournamentPairings/_helpers/assignTables.test.ts b/convex/_model/tournamentPairings/_helpers/assignTables.test.ts index 9afb9e33..e52b5208 100644 --- a/convex/_model/tournamentPairings/_helpers/assignTables.test.ts +++ b/convex/_model/tournamentPairings/_helpers/assignTables.test.ts @@ -13,6 +13,11 @@ import { assignTables } from './assignTables'; const createMockTournament = (competitorCount: number): TournamentDeep => ({ competitorCount, + pairingConfig: { + tableCount: Math.ceil(competitorCount / 2), + orderBy: 'ranking', + policies: { repeat: 'block', sameAlignment: 'allow' }, + }, }) as TournamentDeep; const createMockData = ( diff --git a/convex/_model/tournamentPairings/_helpers/assignTables.ts b/convex/_model/tournamentPairings/_helpers/assignTables.ts index 5fd2628f..566947ce 100644 --- a/convex/_model/tournamentPairings/_helpers/assignTables.ts +++ b/convex/_model/tournamentPairings/_helpers/assignTables.ts @@ -20,7 +20,7 @@ export const assignTables = ( ); playedTablesMap.set(null, []); - const tableCount = (data.tournament?.competitorCount ?? 2) / 2; // TODO: Use actual table count + const tableCount = data.tournament.pairingConfig.tableCount!; // TODO remove assertion post MIGRATION const draftPairings = pairings.map((p) => ({ ...p, diff --git a/convex/_model/tournamentPairings/actions/generateDraftTournamentPairings.ts b/convex/_model/tournamentPairings/actions/generateDraftTournamentPairings.ts index ef1eca59..1dd0dd56 100644 --- a/convex/_model/tournamentPairings/actions/generateDraftTournamentPairings.ts +++ b/convex/_model/tournamentPairings/actions/generateDraftTournamentPairings.ts @@ -1,6 +1,7 @@ import { Infer, v } from 'convex/values'; import { api } from '../../../_generated/api'; +import { Id } from '../../../_generated/dataModel'; import { ActionCtx } from '../../../_generated/server'; import { tournamentPairingConfig } from '../../common/tournamentPairingConfig'; import { DeepTournamentCompetitor } from '../../tournamentCompetitors'; @@ -17,11 +18,14 @@ export const generateDraftTournamentPairingsArgs = v.object({ tournamentId: v.id('tournaments'), round: v.number(), config: tournamentPairingConfig, + include: v.array(v.id('tournamentCompetitors')), }); +export type GenerateDraftTournamentPairingsArgs = Infer; + export const generateDraftTournamentPairings = async ( ctx: ActionCtx, - args: Infer, + args: GenerateDraftTournamentPairingsArgs, ): Promise => { const tournamentCompetitors = await ctx.runQuery( @@ -30,7 +34,9 @@ export const generateDraftTournamentPairings = async ( }, ); - const activeCompetitors = tournamentCompetitors.filter(({ active }) => active); + const include = new Set>(args.include); + + const activeCompetitors = tournamentCompetitors.filter((c) => include.has(c._id)); const orderedCompetitors: DeepTournamentCompetitor[] = []; if (args.config.orderBy === 'ranking') { diff --git a/convex/_model/tournamentPairings/index.ts b/convex/_model/tournamentPairings/index.ts index c48dddca..8234915e 100644 --- a/convex/_model/tournamentPairings/index.ts +++ b/convex/_model/tournamentPairings/index.ts @@ -8,7 +8,9 @@ export { deepenTournamentPairing, type TournamentPairingDeep, } from './_helpers/deepenTournamentPairing'; -export { generateDraftPairings } from './_helpers/generateDraftPairings'; +export { + generateDraftPairings, +} from './_helpers/generateDraftPairings'; export { TournamentPairingActionKey } from './_helpers/getAvailableActions'; export { getTournamentPairingDeep } from './_helpers/getTournamentPairingDeep'; export { getTournamentPairingShallow } from './_helpers/getTournamentPairingShallow'; @@ -24,6 +26,7 @@ export type { // Actions export { generateDraftTournamentPairings, + type GenerateDraftTournamentPairingsArgs, generateDraftTournamentPairingsArgs, } from './actions/generateDraftTournamentPairings'; export { diff --git a/convex/_model/tournamentPairings/mutations/createTournamentPairings.ts b/convex/_model/tournamentPairings/mutations/createTournamentPairings.ts index edab209d..014473b4 100644 --- a/convex/_model/tournamentPairings/mutations/createTournamentPairings.ts +++ b/convex/_model/tournamentPairings/mutations/createTournamentPairings.ts @@ -35,9 +35,6 @@ export const createTournamentPairings = async ( if (tournament.status === 'draft') { throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRINGS_TO_DRAFT_TOURNAMENT')); } - if (tournament.status === 'published') { - throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRINGS_TO_PUBLISHED_TOURNAMENT')); - } if (tournament.status === 'archived') { throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRINGS_TO_ARCHIVED_TOURNAMENT')); } @@ -77,9 +74,6 @@ export const createTournamentPairings = async ( if (!competitor) { throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRING_FOR_MISSING_COMPETITOR')); } - if (!competitor.active) { - throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRING_FOR_INACTIVE_COMPETITOR')); - } if (pairedCompetitorIds.has(id)) { throw new ConvexError(getErrorMessage('CANNOT_ADD_PAIRING_FOR_ALREADY_PAIRED_COMPETITOR')); } diff --git a/convex/_model/tournamentPairings/mutations/deleteTournamentPairings.ts b/convex/_model/tournamentPairings/mutations/deleteTournamentPairings.ts index 4bfee889..109937ee 100644 --- a/convex/_model/tournamentPairings/mutations/deleteTournamentPairings.ts +++ b/convex/_model/tournamentPairings/mutations/deleteTournamentPairings.ts @@ -30,11 +30,6 @@ export const deleteTournamentPairings = async ( throw new ConvexError(getErrorMessage('CANNOT_DELETE_PAIRINGS_FROM_DRAFT_TOURNAMENT')); } - // TODO: Technically not really needed... - if (tournament.status === 'published') { - throw new ConvexError(getErrorMessage('CANNOT_DELETE_PAIRINGS_FROM_PUBLISHED_TOURNAMENT')); - } - // TODO: Replace with generic 'archived' error: if (tournament.status === 'archived') { throw new ConvexError(getErrorMessage('CANNOT_DELETE_PAIRINGS_FROM_ARCHIVED_TOURNAMENT')); diff --git a/convex/_model/tournamentRegistrations/_helpers/deepenTournamentRegistration.ts b/convex/_model/tournamentRegistrations/_helpers/deepenTournamentRegistration.ts index c2e3e1d5..e60aa56a 100644 --- a/convex/_model/tournamentRegistrations/_helpers/deepenTournamentRegistration.ts +++ b/convex/_model/tournamentRegistrations/_helpers/deepenTournamentRegistration.ts @@ -7,6 +7,7 @@ import { getDocStrict } from '../../common/_helpers/getDocStrict'; import { getErrorMessage } from '../../common/errors'; import { getListsByTournamentRegistration } from '../../lists'; import { checkUserIsTournamentOrganizer } from '../../tournamentOrganizers'; +import { getTournamentResultsByUser } from '../../tournamentResults'; import { getUser } from '../../users'; import { getAvailableActions } from './getAvailableActions'; @@ -14,6 +15,7 @@ import { getAvailableActions } from './getAvailableActions'; export const deepenTournamentRegistration = async ( ctx: QueryCtx, doc: Doc<'tournamentRegistrations'>, + round?: number, ) => { const userId = await getAuthUserId(ctx); const { details, ...restDoc } = doc; @@ -25,6 +27,11 @@ export const deepenTournamentRegistration = async ( const tournament = await getDocStrict(ctx, doc.tournamentId); + const results = await getTournamentResultsByUser(ctx, { + userId: doc.userId, + tournamentId: doc.tournamentId, + round: round ?? tournament?.lastRound ?? 0, + }); const availableActions = await getAvailableActions(ctx, doc); const isOrganizer = await checkUserIsTournamentOrganizer(ctx, tournament._id, userId); @@ -43,6 +50,7 @@ export const deepenTournamentRegistration = async ( return { ...restDoc, + ...results, availableActions, details: visibleDetails, user, diff --git a/convex/_model/tournamentRegistrations/_helpers/sortTournamentCompetitorsByName.ts b/convex/_model/tournamentRegistrations/_helpers/sortTournamentCompetitorsByName.ts new file mode 100644 index 00000000..97777b20 --- /dev/null +++ b/convex/_model/tournamentRegistrations/_helpers/sortTournamentCompetitorsByName.ts @@ -0,0 +1,6 @@ +import { DeepTournamentRegistration } from './deepenTournamentRegistration'; + +export const sortTournamentRegistrationsByName = ( + a: DeepTournamentRegistration, + b: DeepTournamentRegistration, +): number => a.displayName.localeCompare(b.displayName); diff --git a/convex/_model/tournamentRegistrations/queries/getTournamentRegistrationByTournamentUser.ts b/convex/_model/tournamentRegistrations/queries/getTournamentRegistrationByTournamentUser.ts index 22d2a7ef..b32f16bf 100644 --- a/convex/_model/tournamentRegistrations/queries/getTournamentRegistrationByTournamentUser.ts +++ b/convex/_model/tournamentRegistrations/queries/getTournamentRegistrationByTournamentUser.ts @@ -19,5 +19,9 @@ export const getTournamentRegistrationByTournamentUser = async ( if (!doc) { return null; } + const tournament = await ctx.db.get(doc.tournamentId); + if (!tournament) { + return null; + } return await deepenTournamentRegistration(ctx, doc); }; diff --git a/convex/_model/tournamentRegistrations/queries/getTournamentRegistrationsByTournament.ts b/convex/_model/tournamentRegistrations/queries/getTournamentRegistrationsByTournament.ts index fe14488c..e4702ce8 100644 --- a/convex/_model/tournamentRegistrations/queries/getTournamentRegistrationsByTournament.ts +++ b/convex/_model/tournamentRegistrations/queries/getTournamentRegistrationsByTournament.ts @@ -5,19 +5,21 @@ import { deepenTournamentRegistration, DeepTournamentRegistration, } from '../_helpers/deepenTournamentRegistration'; +import { sortTournamentRegistrationsByName } from '../_helpers/sortTournamentCompetitorsByName'; export const getTournamentRegistrationsByTournamentArgs = v.object({ tournamentId: v.id('tournaments'), + rankingRound: v.optional(v.number()), }); export const getTournamentRegistrationsByTournament = async ( ctx: QueryCtx, args: Infer, ): Promise => { - const tournamentRegistrations = await ctx.db.query('tournamentRegistrations') + const results = await ctx.db.query('tournamentRegistrations') .withIndex('by_tournament', (q) => q.eq('tournamentId', args.tournamentId)) .collect(); - return await Promise.all( - tournamentRegistrations.map(async (registration) => await deepenTournamentRegistration(ctx, registration)), - ); + return (await Promise.all(results.map(async (item) => ( + await deepenTournamentRegistration(ctx, item, args.rankingRound) + )))).sort(sortTournamentRegistrationsByName); }; diff --git a/convex/_model/tournamentResults/_helpers/aggregateTournamentData.ts b/convex/_model/tournamentResults/_helpers/aggregateTournamentData.ts index 7f5b21f6..00d5254b 100644 --- a/convex/_model/tournamentResults/_helpers/aggregateTournamentData.ts +++ b/convex/_model/tournamentResults/_helpers/aggregateTournamentData.ts @@ -28,7 +28,7 @@ export const aggregateTournamentData = async ( .withIndex('by_tournament', (q) => q.eq('tournamentId', tournament._id)) .collect(); const tournamentCompetitors = await ctx.db.query('tournamentCompetitors') - .withIndex('by_tournament_id', (q) => q.eq('tournamentId', tournament._id)) + .withIndex('by_tournament', (q) => q.eq('tournamentId', tournament._id)) .collect(); const tournamentPairings = await ctx.db.query('tournamentPairings') .withIndex('by_tournament_id', (q) => q.eq('tournamentId', tournament._id)) diff --git a/convex/_model/tournamentResults/mutations/refreshTournamentResult.ts b/convex/_model/tournamentResults/mutations/refreshTournamentResult.ts index 2e96e9a8..cb5275ff 100644 --- a/convex/_model/tournamentResults/mutations/refreshTournamentResult.ts +++ b/convex/_model/tournamentResults/mutations/refreshTournamentResult.ts @@ -29,7 +29,7 @@ export const refreshTournamentResult = async ( throw new ConvexError(getErrorMessage('TOURNAMENT_NOT_FOUND')); } const tournamentCompetitors = await ctx.db.query('tournamentCompetitors') - .withIndex('by_tournament_id', (q) => q.eq('tournamentId', args.tournamentId)) + .withIndex('by_tournament', (q) => q.eq('tournamentId', args.tournamentId)) .collect(); const tournamentRegistrations = await ctx.db.query('tournamentRegistrations') .withIndex('by_tournament', (q) => q.eq('tournamentId', args.tournamentId)) diff --git a/convex/_model/tournaments/_helpers/deepenTournament.ts b/convex/_model/tournaments/_helpers/deepenTournament.ts index 8f6e9ca6..af2d669b 100644 --- a/convex/_model/tournaments/_helpers/deepenTournament.ts +++ b/convex/_model/tournaments/_helpers/deepenTournament.ts @@ -9,6 +9,7 @@ import { } from '../../tournamentOrganizers'; import { getAvailableActions } from './getAvailableActions'; import { getDisplayName } from './getDisplayName'; +import { getLastVisibleRound } from './getLastVisibleRound'; import { getTournamentNextRound } from './getTournamentNextRound'; /* eslint-disable @typescript-eslint/explicit-function-return-type */ @@ -24,43 +25,45 @@ import { getTournamentNextRound } from './getTournamentNextRound'; */ export const deepenTournament = async ( ctx: QueryCtx, - tournament: Doc<'tournaments'>, + doc: Doc<'tournaments'>, ) => { const userId = await getAuthUserId(ctx); - const logoUrl = await getStorageUrl(ctx, tournament.logoStorageId); - const bannerUrl = await getStorageUrl(ctx, tournament.bannerStorageId); - const availableActions = await getAvailableActions(ctx, tournament); + const logoUrl = await getStorageUrl(ctx, doc.logoStorageId); + const bannerUrl = await getStorageUrl(ctx, doc.bannerStorageId); + const availableActions = await getAvailableActions(ctx, doc); const tournamentOrganizers = await getTournamentOrganizersByTournament(ctx, { - tournamentId: tournament._id, + tournamentId: doc._id, }); - const isOrganizer = await checkUserIsTournamentOrganizer(ctx, tournament._id, userId); + const lastVisibleRound = await getLastVisibleRound(ctx, doc); + const isOrganizer = await checkUserIsTournamentOrganizer(ctx, doc._id, userId); const tournamentCompetitors = await ctx.db.query('tournamentCompetitors') - .withIndex('by_tournament_id', (q) => q.eq('tournamentId', tournament._id)) + .withIndex('by_tournament', (q) => q.eq('tournamentId', doc._id)) .collect(); const tournamentRegistrations = await ctx.db.query('tournamentRegistrations') - .withIndex('by_tournament', (q) => q.eq('tournamentId', tournament._id)) + .withIndex('by_tournament', (q) => q.eq('tournamentId', doc._id)) .collect(); const playerUserIds = tournamentRegistrations.map((r) => r.userId); const activePlayerUserIds = tournamentRegistrations.filter((r) => r.active).map((p) => p.userId); return { - ...tournament, + ...doc, activePlayerCount: activePlayerUserIds.length, activePlayerUserIds, - alignmentsVisible: isOrganizer || tournament.alignmentsRevealed, + alignmentsVisible: isOrganizer || doc.alignmentsRevealed, availableActions, bannerUrl, competitorCount: tournamentCompetitors.length, - displayName: getDisplayName(tournament), - factionsVisible: isOrganizer || tournament.factionsRevealed, + displayName: getDisplayName(doc), + factionsVisible: isOrganizer || doc.factionsRevealed, + lastRound: lastVisibleRound, logoUrl, - maxPlayers : tournament.maxCompetitors * tournament.competitorSize, - nextRound: getTournamentNextRound(tournament), + maxPlayers : doc.maxCompetitors * doc.competitorSize, + nextRound: getTournamentNextRound(doc), organizers: tournamentOrganizers, playerCount: playerUserIds.length, playerUserIds, - useTeams: tournament.competitorSize > 1, + useTeams: doc.competitorSize > 1, }; }; diff --git a/convex/_model/tournaments/_helpers/getAvailableActions.ts b/convex/_model/tournaments/_helpers/getAvailableActions.ts index fc2dc300..a854ff8f 100644 --- a/convex/_model/tournaments/_helpers/getAvailableActions.ts +++ b/convex/_model/tournaments/_helpers/getAvailableActions.ts @@ -36,11 +36,6 @@ export enum TournamentActionKey { ToggleListsRevealed = 'toggleListsRevealed', - /** Set a published Tournament's status to 'active'. */ - Start = 'start', - - // TODO: UndoStart - /** Create TournamentPairings for a given round. */ ConfigureRound = 'configureRound', @@ -135,15 +130,11 @@ export const getAvailableActions = async ( actions.push(TournamentActionKey.ToggleListsRevealed); } - if (isOrganizer && doc.status === 'published') { // TODO: Check for at least 2 competitors - actions.push(TournamentActionKey.Start); - } - - if (isOrganizer && doc.status === 'active' && !hasCurrentRound && hasNextRound) { + if (isOrganizer && doc.status !== 'draft' && !hasCurrentRound && hasNextRound) { actions.push(TournamentActionKey.ConfigureRound); } - if (isOrganizer && doc.status === 'active' && !hasCurrentRound && hasNextRound && nextRoundPairingCount > 0) { + if (isOrganizer && doc.status !== 'draft' && !hasCurrentRound && hasNextRound && nextRoundPairingCount > 0) { actions.push(TournamentActionKey.StartRound); } diff --git a/convex/_model/tournaments/_helpers/getLastVisibleRound.test.ts b/convex/_model/tournaments/_helpers/getLastVisibleRound.test.ts new file mode 100644 index 00000000..902cbbd2 --- /dev/null +++ b/convex/_model/tournaments/_helpers/getLastVisibleRound.test.ts @@ -0,0 +1,143 @@ +import { GameSystem } from '@ianpaschal/combat-command-game-systems/common'; +import { convexTest } from 'convex-test'; +import { + describe, + expect, + it, +} from 'vitest'; + +import { createMockTournament } from '../../../_fixtures/createMockTournament'; +import schema from '../../../schema'; +import { getLastVisibleRound } from './getLastVisibleRound'; + +describe('getLastVisibleRound', () => { + it('returns last round when not final round.', async () => { + const t = convexTest(schema); + + const result = await t.run(async (ctx) => { + const tournamentId = await ctx.db.insert('tournaments', createMockTournament(GameSystem.FlamesOfWarV4, { + lastRound: 1, + roundCount: 3, + status: 'active', + })); + const doc = await ctx.db.get(tournamentId); + return await getLastVisibleRound(ctx, doc!); + }); + + expect(result).toBe(1); + }); + + it('returns last round after the final round, when user is a TO.', async () => { + const t = convexTest(schema); + + const { userId, tournamentId } = await t.run(async (ctx) => { + const userId = await ctx.db.insert('users', { + email: 'to@test.com', + username: 'to_test', + locationVisibility: 0, + nameVisibility: 0, + }); + const tournamentId = await ctx.db.insert('tournaments', createMockTournament(GameSystem.FlamesOfWarV4, { + lastRound: 2, + roundCount: 3, + status: 'active', + })); + await ctx.db.insert('tournamentOrganizers', { tournamentId, userId }); + return { userId, tournamentId }; + }); + + const result = await t.withIdentity({ subject: `${userId}|session` }).run(async (ctx) => { + const doc = await ctx.db.get(tournamentId); + return await getLastVisibleRound(ctx, doc!); + }); + + expect(result).toBe(2); + }); + + it('returns round before last after the final round, when user is not a TO.', async () => { + const t = convexTest(schema); + + const { userId, tournamentId } = await t.run(async (ctx) => { + const userId = await ctx.db.insert('users', { + email: 'user@test.com', + username: 'user_test', + locationVisibility: 0, + nameVisibility: 0, + }); + const tournamentId = await ctx.db.insert('tournaments', createMockTournament(GameSystem.FlamesOfWarV4, { + lastRound: 2, + roundCount: 3, + status: 'active', + })); + return { userId, tournamentId }; + }); + + const result = await t.withIdentity({ subject: `${userId}|session` }).run(async (ctx) => { + const doc = await ctx.db.get(tournamentId); + return await getLastVisibleRound(ctx, doc!); + }); + + expect(result).toBe(1); + }); + + it('returns round before last after the final round, when user is not authenticated.', async () => { + const t = convexTest(schema); + + const result = await t.run(async (ctx) => { + const tournamentId = await ctx.db.insert('tournaments', createMockTournament(GameSystem.FlamesOfWarV4, { + lastRound: 2, + roundCount: 3, + status: 'active', + })); + const doc = await ctx.db.get(tournamentId); + return await getLastVisibleRound(ctx, doc!); + }); + + expect(result).toBe(1); + }); + + it('does not return negative values on first round.', async () => { + const t = convexTest(schema); + + const result = await t.run(async (ctx) => { + const tournamentId = await ctx.db.insert('tournaments', createMockTournament(GameSystem.FlamesOfWarV4, { + lastRound: 0, + roundCount: 1, + status: 'active', + })); + const doc = await ctx.db.get(tournamentId); + return await getLastVisibleRound(ctx, doc!); + }); + + expect(result).toBe(0); + }); + + it('returns undefined when lastRound is not set.', async () => { + const t = convexTest(schema); + + await t.run(async (ctx) => { + const tournamentId = await ctx.db.insert('tournaments', createMockTournament(GameSystem.FlamesOfWarV4, { + roundCount: 3, + status: 'active', + })); + const doc = await ctx.db.get(tournamentId); + expect(await getLastVisibleRound(ctx, doc!)).toBeUndefined(); + }); + }); + + it('returns last round when tournament is archived.', async () => { + const t = convexTest(schema); + + const result = await t.run(async (ctx) => { + const tournamentId = await ctx.db.insert('tournaments', createMockTournament(GameSystem.FlamesOfWarV4, { + lastRound: 2, + roundCount: 3, + status: 'archived', + })); + const doc = await ctx.db.get(tournamentId); + return await getLastVisibleRound(ctx, doc!); + }); + + expect(result).toBe(2); + }); +}); diff --git a/convex/_model/tournaments/_helpers/getLastVisibleRound.ts b/convex/_model/tournaments/_helpers/getLastVisibleRound.ts new file mode 100644 index 00000000..643bab1c --- /dev/null +++ b/convex/_model/tournaments/_helpers/getLastVisibleRound.ts @@ -0,0 +1,30 @@ +import { getAuthUserId } from '@convex-dev/auth/server'; + +import { Doc } from '../../../_generated/dataModel'; +import { QueryCtx } from '../../../_generated/server'; +import { checkUserIsTournamentOrganizer } from '../../tournamentOrganizers'; + +/** + * Determines the last tournament round visible to the current user. + * Non-organizers cannot view final round rankings until the tournament is archived. + * @returns The last round which should be visible to the current user + */ +export const getLastVisibleRound = async ( + ctx: QueryCtx, + doc: Doc<'tournaments'>, +): Promise => { + const userId = await getAuthUserId(ctx); + const isOrganizer = await checkUserIsTournamentOrganizer(ctx, doc._id, userId); + + // Always show true last round to organizers or if the tournament has ended: + if (isOrganizer || doc.status === 'archived') { + return doc.lastRound; + } + + // Show penultimate round to players if last round has been completed: + if (doc.lastRound !== undefined && doc.lastRound + 1 === doc.roundCount) { + return Math.max(doc.lastRound - 1, 0); + } + + return doc.lastRound; +}; diff --git a/convex/_model/tournaments/index.ts b/convex/_model/tournaments/index.ts index f5ed1b01..3f54696c 100644 --- a/convex/_model/tournaments/index.ts +++ b/convex/_model/tournaments/index.ts @@ -62,6 +62,10 @@ export { updateTournament, updateTournamentArgs, } from './mutations/updateTournament'; +export { + updateTournamentPairingConfig, + updateTournamentPairingConfigArgs, +} from './mutations/updateTournamentPairingConfig'; // Queries export { diff --git a/convex/_model/tournaments/mutations/deleteTournament.ts b/convex/_model/tournaments/mutations/deleteTournament.ts index b4a7c5e3..ccbba7ae 100644 --- a/convex/_model/tournaments/mutations/deleteTournament.ts +++ b/convex/_model/tournaments/mutations/deleteTournament.ts @@ -43,7 +43,7 @@ export const deleteTournament = async ( // Cascade to Tournament Competitors: const tournamentCompetitors = await ctx.db.query('tournamentCompetitors') - .withIndex('by_tournament_id', (q) => q.eq('tournamentId', args.id)) + .withIndex('by_tournament', (q) => q.eq('tournamentId', args.id)) .collect(); for (const record of tournamentCompetitors) { await ctx.db.delete(record._id); diff --git a/convex/_model/tournaments/mutations/startTournamentRound.ts b/convex/_model/tournaments/mutations/startTournamentRound.ts index b118370c..fe38a2a9 100644 --- a/convex/_model/tournaments/mutations/startTournamentRound.ts +++ b/convex/_model/tournaments/mutations/startTournamentRound.ts @@ -34,9 +34,6 @@ export const startTournamentRound = async ( if (tournament.status === 'draft') { throw new ConvexError(getErrorMessage('CANNOT_OPEN_ROUND_ON_DRAFT_TOURNAMENT')); } - if (tournament.status === 'published') { - throw new ConvexError(getErrorMessage('CANNOT_OPEN_ROUND_ON_PUBLISHED_TOURNAMENT')); - } if (tournament.status === 'archived') { throw new ConvexError(getErrorMessage('CANNOT_OPEN_ROUND_ON_ARCHIVED_TOURNAMENT')); } @@ -53,8 +50,9 @@ export const startTournamentRound = async ( round: nextRound, }); - // Open the round: + // Open the round (and activate the tournament if it's still in published state): await ctx.db.patch(args.id, { currentRound: nextRound, + status: 'active', }); }; diff --git a/convex/_model/tournaments/mutations/updateTournamentPairingConfig.ts b/convex/_model/tournaments/mutations/updateTournamentPairingConfig.ts new file mode 100644 index 00000000..d2ea0c6b --- /dev/null +++ b/convex/_model/tournaments/mutations/updateTournamentPairingConfig.ts @@ -0,0 +1,23 @@ +import { Infer, v } from 'convex/values'; + +import { MutationCtx } from '../../../_generated/server'; +import { tournamentPairingConfig } from '../../common/tournamentPairingConfig'; +import { checkTournamentAuth } from '../_helpers/checkTournamentAuth'; +import { getTournamentShallow } from '../_helpers/getTournamentShallow'; + +export const updateTournamentPairingConfigArgs = v.object({ + tournamentId: v.id('tournaments'), + pairingConfig: tournamentPairingConfig, +}); + +export const updateTournamentPairingConfig = async ( + ctx: MutationCtx, + args: Infer, +): Promise => { + const tournament = await getTournamentShallow(ctx, args.tournamentId); + checkTournamentAuth(ctx, tournament); + await ctx.db.patch(args.tournamentId, { + pairingConfig: args.pairingConfig, + modifiedAt: Date.now(), + }); +}; diff --git a/convex/_model/tournaments/queries/getTournamentOpenRound.ts b/convex/_model/tournaments/queries/getTournamentOpenRound.ts index c37f4dae..250ac45e 100644 --- a/convex/_model/tournaments/queries/getTournamentOpenRound.ts +++ b/convex/_model/tournaments/queries/getTournamentOpenRound.ts @@ -1,7 +1,6 @@ import { Infer, v } from 'convex/values'; import { QueryCtx } from '../../../_generated/server'; -import { getTournamentShallow } from '../_helpers/getTournamentShallow'; export const getTournamentOpenRoundArgs = v.object({ id: v.id('tournaments'), @@ -23,7 +22,10 @@ export const getTournamentOpenRound = async ( ctx: QueryCtx, args: Infer, ) => { - const tournament = await getTournamentShallow(ctx, args.id); + const tournament = await ctx.db.get(args.id); + if (!tournament) { + return null; + } if (tournament.status !== 'active' || tournament.currentRound === undefined) { return null; diff --git a/convex/_model/users/actions/internal/setUserDefaultAvatar.ts b/convex/_model/users/actions/internal/setUserDefaultAvatar.ts index c1fee2d9..de8301a7 100644 --- a/convex/_model/users/actions/internal/setUserDefaultAvatar.ts +++ b/convex/_model/users/actions/internal/setUserDefaultAvatar.ts @@ -23,7 +23,7 @@ export const setUserDefaultAvatar = async ( } // Fetch avatar - const avatarUrl = `https://api.dicebear.com/7.x/bottts-neutral/svg?seed=${Math.random()}&scale=75`; + const avatarUrl = `https://api.dicebear.com/7.x/bottts-neutral/svg?seed=${encodeURIComponent(user.username)}&scale=75`; const avatarResponse = await fetch(avatarUrl); if (!avatarResponse.ok) { throw new Error('Failed to fetch avatar'); diff --git a/convex/_model/users/actions/inviteUser.ts b/convex/_model/users/actions/inviteUser.ts index 90fc104d..088e8380 100644 --- a/convex/_model/users/actions/inviteUser.ts +++ b/convex/_model/users/actions/inviteUser.ts @@ -14,6 +14,7 @@ import { checkAuth } from '../../common/_helpers/checkAuth'; import { getErrorMessage } from '../../common/errors'; import { VisibilityLevel } from '../../common/VisibilityLevel'; import { createClaimToken } from '../_helpers/createClaimToken'; +import { generateUsername } from '../_helpers/generateUsername'; import { hashClaimToken } from '../_helpers/hashClaimToken'; export const inviteUserArgs = v.object({ @@ -55,12 +56,17 @@ export const inviteUser = async ( }); user = existingUser; } else { + let username = generateUsername(); + while (await ctx.runQuery(internal.users.getUserByUsername, { username })) { + username = generateUsername(); + } const { user: createdUser } = await createAccount(ctx, { provider: 'password', account: { id: email }, profile: { ...restArgs, email, + username, locationVisibility: VisibilityLevel.Hidden, nameVisibility: VisibilityLevel.Tournaments, claimTokenHash: await hashClaimToken(claimToken), diff --git a/convex/_model/users/index.ts b/convex/_model/users/index.ts index dda7ac3c..3c55e067 100644 --- a/convex/_model/users/index.ts +++ b/convex/_model/users/index.ts @@ -78,3 +78,7 @@ export { getUserById, getUserByIdArgs, } from './queries/internal/getUserById'; +export { + getUserByUsername, + getUserByUsernameArgs, +} from './queries/internal/getUserByUsername'; diff --git a/convex/_model/users/queries/internal/getUserByUsername.ts b/convex/_model/users/queries/internal/getUserByUsername.ts new file mode 100644 index 00000000..448e74f0 --- /dev/null +++ b/convex/_model/users/queries/internal/getUserByUsername.ts @@ -0,0 +1,15 @@ +import { Infer, v } from 'convex/values'; + +import { Doc } from '../../../../_generated/dataModel'; +import { QueryCtx } from '../../../../_generated/server'; + +export const getUserByUsernameArgs = v.object({ + username: v.string(), +}); + +export const getUserByUsername = async ( + ctx: QueryCtx, + args: Infer, +): Promise | null> => await ctx.db.query('users') + .withIndex('by_username', (q) => q.eq('username', args.username)) + .first(); diff --git a/convex/_model/users/table.ts b/convex/_model/users/table.ts index e7787922..b4a4bbf3 100644 --- a/convex/_model/users/table.ts +++ b/convex/_model/users/table.ts @@ -8,7 +8,7 @@ export const editableFields = { givenName: v.optional(v.string()), locationVisibility: v.number(), nameVisibility: v.number(), - username: v.optional(v.string()), + username: v.string(), }; export const computedFields = { diff --git a/convex/_model/utils/createTestTournament.ts b/convex/_model/utils/createTestTournament.ts index 7d78d4b4..5b0149ec 100644 --- a/convex/_model/utils/createTestTournament.ts +++ b/convex/_model/utils/createTestTournament.ts @@ -2,6 +2,7 @@ import { GameSystem } from '@ianpaschal/combat-command-game-systems/common'; import { Infer, v } from 'convex/values'; import { createMockTournament } from '../../_fixtures/createMockTournament'; +import { Id } from '../../_generated/dataModel'; import { MutationCtx } from '../../_generated/server'; import { getStaticEnumConvexValidator } from '../common/_helpers/getStaticEnumConvexValidator'; import { tournamentStatus } from '../common/tournamentStatus'; @@ -37,8 +38,8 @@ export const createTestTournament = async ( gameSystem, competitorCount, }: Infer, -): Promise => { - const maxCompetitors = competitorCount ?? useTeams ? 12 : 24; +): Promise> => { + const maxCompetitors = competitorCount ?? (useTeams ? 12 : 24); const competitorSize = useTeams ? 3 : 1; // 1. Gather users @@ -106,4 +107,6 @@ export const createTestTournament = async ( } } } + + return tournamentId; }; diff --git a/convex/_model/utils/createTestTournamentPairings.ts b/convex/_model/utils/createTestTournamentPairings.ts new file mode 100644 index 00000000..2e8555fd --- /dev/null +++ b/convex/_model/utils/createTestTournamentPairings.ts @@ -0,0 +1,38 @@ +import { Infer, v } from 'convex/values'; + +import { Id } from '../../_generated/dataModel'; +import { MutationCtx } from '../../_generated/server'; +import { defaultValues } from '../common/tournamentPairingConfig'; +import { getTournamentCompetitorsByTournament } from '../tournamentCompetitors'; +import { generateDraftPairings } from '../tournamentPairings/_helpers/generateDraftPairings'; + +export const createTestTournamentPairingsArgs = v.object({ + tournamentId: v.id('tournaments'), + round: v.number(), +}); + +export const createTestTournamentPairings = async ( + ctx: MutationCtx, + { tournamentId, round }: Infer, +): Promise[]> => { + const competitors = await getTournamentCompetitorsByTournament(ctx, { tournamentId }); + const activeCompetitors = competitors.filter((c) => c.active); + + const pairs = generateDraftPairings(activeCompetitors, defaultValues.policies); + + const pairingIds: Id<'tournamentPairings'>[] = []; + let tableNumber = 1; + + for (const [competitor0, competitor1] of pairs) { + const id = await ctx.db.insert('tournamentPairings', { + tournamentId, + round, + tournamentCompetitor0Id: competitor0._id, + tournamentCompetitor1Id: competitor1?._id ?? null, + table: competitor1 ? tableNumber++ : null, + }); + pairingIds.push(id); + } + + return pairingIds; +}; diff --git a/convex/_model/utils/deleteTestTournament.ts b/convex/_model/utils/deleteTestTournament.ts index c503d384..cf81a9f9 100644 --- a/convex/_model/utils/deleteTestTournament.ts +++ b/convex/_model/utils/deleteTestTournament.ts @@ -16,7 +16,7 @@ export const deleteTestTournament = async ( // 2. Delete competitors const competitors = await ctx.db.query('tournamentCompetitors') - .withIndex('by_tournament_id', (q) => q.eq('tournamentId', id)) + .withIndex('by_tournament', (q) => q.eq('tournamentId', id)) .collect(); competitors.forEach(async ({ _id }) => { await ctx.db.delete(_id); diff --git a/convex/_model/utils/index.ts b/convex/_model/utils/index.ts index 9899c6b5..7a2f1992 100644 --- a/convex/_model/utils/index.ts +++ b/convex/_model/utils/index.ts @@ -1,6 +1,3 @@ -export { - populateUsers, -} from './actions/populateUsers'; export { createTestTournament, createTestTournamentArgs, @@ -9,6 +6,10 @@ export { createTestTournamentMatchResults, createTestTournamentMatchResultsArgs, } from './createTestTournamentMatchResults'; +export { + createTestTournamentPairings, + createTestTournamentPairingsArgs, +} from './createTestTournamentPairings'; export { deleteTestTournament, deleteTestTournamentArgs, diff --git a/convex/migrations.ts b/convex/migrations.ts index 4e1e8d09..49fbd94a 100644 --- a/convex/migrations.ts +++ b/convex/migrations.ts @@ -80,7 +80,7 @@ export const moveSoloCompetitorScoreAdjustments = migrations.define({ // Find all competitors for this tournament that have adjustments to migrate: const competitors = await ctx.db.query('tournamentCompetitors') - .withIndex('by_tournament_id', (q) => q.eq('tournamentId', doc._id)) + .withIndex('by_tournament', (q) => q.eq('tournamentId', doc._id)) .collect(); let anyMoved = false; @@ -135,6 +135,21 @@ export const backfillListSubmittedAt = migrations.define({ }, }); +export const addTableCountToPairingConfig = migrations.define({ + table: 'tournaments', + migrateOne: async (ctx, doc) => { + if (doc.pairingConfig.tableCount != null) { + return; + } + await ctx.db.patch(doc._id, { + pairingConfig: { + ...doc.pairingConfig, + tableCount: Math.ceil(doc.maxCompetitors / 2), + }, + }); + }, +}); + export const convertPlayedAt = migrations.define({ table: 'matchResults', migrateOne: async (ctx, doc) => { diff --git a/convex/test.ts b/convex/test.ts new file mode 100644 index 00000000..e7f79c97 --- /dev/null +++ b/convex/test.ts @@ -0,0 +1,11 @@ +import { internalAction, internalMutation } from './_generated/server'; +import * as model from './_model/_test'; + +export const cleanUp = internalMutation({ + args: model.cleanUpArgs, + handler: model.cleanUp, +}); + +export const populateUsers = internalAction({ + handler: model.populateUsers, +}); diff --git a/convex/tournaments.ts b/convex/tournaments.ts index f9496111..0228306f 100644 --- a/convex/tournaments.ts +++ b/convex/tournaments.ts @@ -35,6 +35,11 @@ export const updateTournament = mutationWithTrigger({ handler: model.updateTournament, }); +export const updateTournamentPairingConfig = mutation({ + args: model.updateTournamentPairingConfigArgs, + handler: model.updateTournamentPairingConfig, +}); + export const deleteTournament = mutation({ args: model.deleteTournamentArgs, handler: model.deleteTournament, diff --git a/convex/users.ts b/convex/users.ts index d7d6fbd8..b58ea8a0 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -35,6 +35,11 @@ export const getUserById = internalQuery({ handler: model.getUserById, }); +export const getUserByUsername = internalQuery({ + args: model.getUserByUsernameArgs, + handler: model.getUserByUsername, +}); + export const updateUserEmail = internalMutation({ args: model.updateUserEmailArgs, handler: model.updateUserEmail, diff --git a/convex/utils.ts b/convex/utils.ts index 66f7101c..b0f23e7b 100644 --- a/convex/utils.ts +++ b/convex/utils.ts @@ -1,5 +1,4 @@ import { - internalAction, internalMutation, internalQuery, mutation, @@ -16,6 +15,11 @@ export const createTestTournamentMatchResults = mutation({ handler: model.createTestTournamentMatchResults, }); +export const createTestTournamentPairings = mutation({ + args: model.createTestTournamentPairingsArgs, + handler: model.createTestTournamentPairings, +}); + export const deleteTestTournament = mutation({ args: model.deleteTestTournamentArgs, handler: model.deleteTestTournament, @@ -31,10 +35,6 @@ export const revealTournamentPlayerNames = internalMutation({ handler: model.revealTournamentPlayerNames, }); -export const populateUsers = internalAction({ - handler: model.populateUsers, -}); - export const refreshSearchIndex = internalMutation({ args: model.refreshSearchIndexArgs, handler: model.refreshSearchIndex, diff --git a/package-lock.json b/package-lock.json index 9bb7271f..852a266b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,6 +72,7 @@ "@edge-runtime/vm": "^5.0.0", "@faker-js/faker": "^9.6.0", "@ianpaschal/eslint-config": "^1.0.1", + "@playwright/test": "^1.60.0", "@stylistic/stylelint-config": "^2.0.0", "@stylistic/stylelint-plugin": "^3.1.0", "@testing-library/jest-dom": "^6.9.1", @@ -2983,6 +2984,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/colors": { "version": "3.0.0", "license": "MIT" @@ -10169,6 +10186,53 @@ "node": ">=6" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "dev": true, diff --git a/package.json b/package.json index f4b57d93..b3e77ea1 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "lint:scss": "stylelint '**/*.scss'", "prepare": "husky", "test": "vitest --run --coverage", + "test:e2e": "playwright test", + "test:e2e:slow": "SLOW_MO=500 playwright test --headed", "gen:component": "node scripts/generateComponent.js", "release:prepare": "node scripts/prepareRelease.js", "release:finish": "node scripts/finishRelease.js" @@ -84,6 +86,7 @@ "@edge-runtime/vm": "^5.0.0", "@faker-js/faker": "^9.6.0", "@ianpaschal/eslint-config": "^1.0.1", + "@playwright/test": "^1.60.0", "@stylistic/stylelint-config": "^2.0.0", "@stylistic/stylelint-plugin": "^3.1.0", "@testing-library/jest-dom": "^6.9.1", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..f128d6a7 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,48 @@ +import { defineConfig, devices } from '@playwright/test'; + +const LOCAL_CONVEX_DEPLOYMENT = 'local:local-ian_paschal-combat_command-1'; +const LOCAL_CONVEX_URL = 'http://127.0.0.1:3210'; + +if (!process.env.CI) { + process.env.CONVEX_DEPLOYMENT = LOCAL_CONVEX_DEPLOYMENT; + process.env.VITE_CONVEX_URL = LOCAL_CONVEX_URL; +} + +export default defineConfig({ + testDir: './test', + globalSetup: './test/globalSetup.ts', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: process.env.CI ? 'html' : 'list', + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + }, + webServer: [ + + // In CI the backend is a Convex preview deployment already running in the cloud — + // only start the local dev server when running tests locally. + ...(!process.env.CI ? [{ + command: 'npx convex dev --tail-logs disable > /dev/null 2>&1', + url: LOCAL_CONVEX_URL, + reuseExistingServer: false, + timeout: 60000, + }] : []), + { + command: 'npm run dev:frontend > /dev/null 2>&1', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + }, + ], + projects: [ + { + name: 'firefox', + use: { + ...devices['Desktop Firefox'], + launchOptions: { slowMo: process.env.SLOW_MO ? parseInt(process.env.SLOW_MO) : 0 }, + }, + }, + ], +}); diff --git a/src/api.ts b/src/api.ts index c7b3e924..1c220fb2 100644 --- a/src/api.ts +++ b/src/api.ts @@ -11,6 +11,12 @@ export { export { type Faction, } from '../convex/_model/common/faction'; +export { + type TournamentPairingConfig, + defaultValues as tournamentPairingConfigDefaultValues, + schema as tournamentPairingConfigSchema, + type TournamentPairingPolicies, +} from '../convex/_model/common/tournamentPairingConfig'; export type { RankingFactor, TournamentStatus, @@ -86,6 +92,7 @@ export { // Tournament Pairings export { type DraftTournamentPairing, + type GenerateDraftTournamentPairingsArgs, type ShallowTournamentPairing, type TournamentPairingDeep as TournamentPairing, TournamentPairingActionKey, diff --git a/src/components/ContextMenu/ContextMenu.tsx b/src/components/ContextMenu/ContextMenu.tsx index 4b587572..5a943d84 100644 --- a/src/components/ContextMenu/ContextMenu.tsx +++ b/src/components/ContextMenu/ContextMenu.tsx @@ -10,6 +10,7 @@ import { Ellipsis } from 'lucide-react'; import { DeviceSize, useDeviceSize } from '~/hooks/useDeviceSize'; export interface ContextMenuProps { + 'aria-label'?: string; className?: string; actions: MenuItem[], size?: ElementSize; @@ -17,6 +18,7 @@ export interface ContextMenuProps { } export const ContextMenu = ({ + 'aria-label': ariaLabel, className, actions, size = 'normal', @@ -27,6 +29,7 @@ export const ContextMenu = ({ return (