From 579071ff5dec5f250cc4231de225d582287ae530 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 23 Jun 2025 21:30:00 +0200 Subject: [PATCH 01/21] Update update-project-status.yml --- .github/workflows/update-project-status.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/update-project-status.yml b/.github/workflows/update-project-status.yml index 664bb89a..79c7492b 100644 --- a/.github/workflows/update-project-status.yml +++ b/.github/workflows/update-project-status.yml @@ -4,7 +4,7 @@ on: pull_request: types: [opened, closed] branches: - - development + - develop - master jobs: From 5360b139e8631bdffc7f7d1432a0b66de3596fab Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 23 Jun 2025 21:34:40 +0200 Subject: [PATCH 02/21] Update updateProjectStatus.js --- scripts/updateProjectStatus.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/updateProjectStatus.js b/scripts/updateProjectStatus.js index 077fab9b..705bd12d 100644 --- a/scripts/updateProjectStatus.js +++ b/scripts/updateProjectStatus.js @@ -59,9 +59,13 @@ const repoData = await graphql(` title fields(first: 20) { nodes { - id - name + ... on ProjectV2Field { + id + name + } ... on ProjectV2SingleSelectField { + id + name options { id name From 8f29c8deea5515e71564aa864b70f9fb3bfd89f3 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 23 Jun 2025 21:35:16 +0200 Subject: [PATCH 03/21] fix: Toast text does not wrap (#87) --- src/components/ToastProvider/ToastProvider.module.scss | 2 ++ src/services/auth/useRequestPasswordReset.ts | 2 +- src/services/auth/useResetPassword.ts | 2 +- src/services/auth/useSignIn.ts | 2 +- src/services/auth/useSignOut.ts | 2 +- src/services/auth/useSignUp.ts | 2 +- 6 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/components/ToastProvider/ToastProvider.module.scss b/src/components/ToastProvider/ToastProvider.module.scss index 651e6aa3..515e06a8 100644 --- a/src/components/ToastProvider/ToastProvider.module.scss +++ b/src/components/ToastProvider/ToastProvider.module.scss @@ -100,6 +100,8 @@ .Content { @include flex.column($gap: 0.25rem); + + min-width: 0; } .Title { diff --git a/src/services/auth/useRequestPasswordReset.ts b/src/services/auth/useRequestPasswordReset.ts index 002895f5..9ee98e19 100644 --- a/src/services/auth/useRequestPasswordReset.ts +++ b/src/services/auth/useRequestPasswordReset.ts @@ -22,7 +22,7 @@ export const useRequestPasswordReset = () => { flow: 'reset', }).catch((error) => { console.error(error); - toast.error(error.message); + toast.error('Error', { description: error.message }); }).finally(() => { setLoading(false); toast.success('Code sent!'); diff --git a/src/services/auth/useResetPassword.ts b/src/services/auth/useResetPassword.ts index e3e73745..82554ed0 100644 --- a/src/services/auth/useResetPassword.ts +++ b/src/services/auth/useResetPassword.ts @@ -49,7 +49,7 @@ export const useResetPassword = () => { }).catch((error) => { setLoading(false); console.error(error); - toast.error(error.message); + toast.error('Error', { description: error.message }); }); }, loading, diff --git a/src/services/auth/useSignIn.ts b/src/services/auth/useSignIn.ts index 626929ea..becc3372 100644 --- a/src/services/auth/useSignIn.ts +++ b/src/services/auth/useSignIn.ts @@ -48,7 +48,7 @@ export const useSignIn = () => { }).catch((error) => { setLoading(false); console.error(error); - toast.error(error.message); + toast.error('Error', { description: error.message }); }); }, loading, diff --git a/src/services/auth/useSignOut.ts b/src/services/auth/useSignOut.ts index 9615a623..f2142e93 100644 --- a/src/services/auth/useSignOut.ts +++ b/src/services/auth/useSignOut.ts @@ -40,7 +40,7 @@ export const useSignOut = () => { await signOut().catch((error) => { setLoading(false); console.error(error); - toast.error(error.message); + toast.error('Error', { description: error.message }); }); }, loading, diff --git a/src/services/auth/useSignUp.ts b/src/services/auth/useSignUp.ts index 8fe1f94f..86050795 100644 --- a/src/services/auth/useSignUp.ts +++ b/src/services/auth/useSignUp.ts @@ -58,7 +58,7 @@ export const useSignUp = () => { }).catch((error) => { setLoading(false); console.error(error); - toast.error(error.message); + toast.error('Error', { description: error.message }); }); }, loading, From 758b72a9a8ff2bcfbaab13bd58a134c1d302e229 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 23 Jun 2025 21:44:28 +0200 Subject: [PATCH 04/21] fix: Sanitize sign in/sign up inputs (#91) #86 --- src/components/generic/Form/Form.tsx | 4 +++- .../ForgotPasswordForm.schema.ts | 2 +- .../ResetPasswordForm.schema.ts | 8 +++---- .../SignInForm/SignInForm.schema.ts | 4 ++-- .../components/SignInForm/SignInForm.tsx | 2 +- .../SignUpForm/SignUpForm.schema.ts | 21 ++++++++++++------- 6 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/components/generic/Form/Form.tsx b/src/components/generic/Form/Form.tsx index 24b8f53e..f2f09739 100644 --- a/src/components/generic/Form/Form.tsx +++ b/src/components/generic/Form/Form.tsx @@ -20,6 +20,7 @@ export interface FormProps { children: ReactNode; onSubmit: SubmitHandler; className?: string; + useBlocker?: boolean; } export const Form = ({ @@ -27,12 +28,13 @@ export const Form = ({ form, children, className, + useBlocker: block = true, onSubmit, }: FormProps) => { const { isDirty } = form.formState; const blockNavigation = useRef(true); const navigation = useNavigation(); - const blocker = useBlocker(() => isDirty && blockNavigation.current); + const blocker = useBlocker(() => block && isDirty && blockNavigation.current); const handleSubmit = async (e: BaseSyntheticEvent): Promise => { e.stopPropagation(); blockNavigation.current = false; diff --git a/src/pages/AuthPage/components/ForgotPasswordForm/ForgotPasswordForm.schema.ts b/src/pages/AuthPage/components/ForgotPasswordForm/ForgotPasswordForm.schema.ts index dae5b2cf..011f06b6 100644 --- a/src/pages/AuthPage/components/ForgotPasswordForm/ForgotPasswordForm.schema.ts +++ b/src/pages/AuthPage/components/ForgotPasswordForm/ForgotPasswordForm.schema.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; export const forgotPasswordFormSchema = z.object({ - email: z.string().min(1, 'Please enter your email.'), + email: z.string().email().transform((val) => val.trim().toLowerCase()), }); export type ForgotPasswordFormData = z.infer; diff --git a/src/pages/AuthPage/components/ResetPasswordForm/ResetPasswordForm.schema.ts b/src/pages/AuthPage/components/ResetPasswordForm/ResetPasswordForm.schema.ts index 0c35e8f3..241b1765 100644 --- a/src/pages/AuthPage/components/ResetPasswordForm/ResetPasswordForm.schema.ts +++ b/src/pages/AuthPage/components/ResetPasswordForm/ResetPasswordForm.schema.ts @@ -1,10 +1,10 @@ import { z } from 'zod'; export const resetPasswordFormSchema = z.object({ - code: z.string().min(1).max(8), - email: z.string().email(), - newPassword: z.string().min(8, 'Password must be at least 8 characters.'), - newPasswordRepeat: z.string(), + code: z.string().min(1).max(8).transform((val) => val.trim()), + email: z.string().email().transform((val) => val.trim().toLowerCase()), + newPassword: z.string().min(8, 'Password must be at least 8 characters.').transform((val) => val.trim()), + newPasswordRepeat: z.string().transform((val) => val.trim()), }).superRefine((values, ctx) => { if (values.newPassword !== values.newPasswordRepeat) { ctx.addIssue({ diff --git a/src/pages/AuthPage/components/SignInForm/SignInForm.schema.ts b/src/pages/AuthPage/components/SignInForm/SignInForm.schema.ts index cd6143f4..a80f008c 100644 --- a/src/pages/AuthPage/components/SignInForm/SignInForm.schema.ts +++ b/src/pages/AuthPage/components/SignInForm/SignInForm.schema.ts @@ -1,8 +1,8 @@ import { z } from 'zod'; export const signInFormSchema = z.object({ - email: z.string().min(1, 'Please enter your email.'), - password: z.string().min(1, 'Please enter your password.'), + email: z.string().min(1, 'Please enter your email.').transform((val) => val.trim().toLowerCase()), + password: z.string().min(1, 'Please enter your password.').transform((val) => val.trim()), }); export type SignInFormData = z.infer; diff --git a/src/pages/AuthPage/components/SignInForm/SignInForm.tsx b/src/pages/AuthPage/components/SignInForm/SignInForm.tsx index beb46698..bb8bfc91 100644 --- a/src/pages/AuthPage/components/SignInForm/SignInForm.tsx +++ b/src/pages/AuthPage/components/SignInForm/SignInForm.tsx @@ -30,7 +30,7 @@ export const SignInForm = (): JSX.Element => { }; return ( -
+

Welcome Back

Sign in to your account

diff --git a/src/pages/AuthPage/components/SignUpForm/SignUpForm.schema.ts b/src/pages/AuthPage/components/SignUpForm/SignUpForm.schema.ts index e457024a..8c1fb5c2 100644 --- a/src/pages/AuthPage/components/SignUpForm/SignUpForm.schema.ts +++ b/src/pages/AuthPage/components/SignUpForm/SignUpForm.schema.ts @@ -1,12 +1,19 @@ import { z } from 'zod'; export const signUpFormSchema = z.object({ - email: z.string().email('Please enter a valid email.'), - password: z.string().min(8, 'Password must be at least 8 characters.'), - passwordRepeat: z.string(), - username: z.string().min(3, 'Must be at least 3 characters.').max(24, 'Cannot be longer than 24 characters.'), - givenName: z.string().min(2, 'Must be at least 2 characters.').max(64, 'Cannot be longer than 64 characters.'), - familyName: z.string().min(2, 'Must be at least 2 characters.').max(64, 'Cannot be longer than 64 characters.'), + email: z.string().email('Please enter a valid email.').transform((val) => val.trim().toLowerCase()), + password: z.string().min(8, 'Password must be at least 8 characters.').transform((val) => val.trim()), + passwordRepeat: z.string().transform((val) => val.trim()), + username: z.string() + .min(3, 'Must be at least 3 characters.') + .max(24, 'Cannot be longer than 24 characters.') + .regex( + /^(?!.*[._]{2})(?![._])[a-zA-Z0-9._]+(? val.trim().toLowerCase()), + givenName: z.string().min(2, 'Must be at least 2 characters.').max(64, 'Cannot be longer than 64 characters.').transform((val) => val.trim()), + familyName: z.string().min(2, 'Must be at least 2 characters.').max(64, 'Cannot be longer than 64 characters.').transform((val) => val.trim()), }).superRefine((values, ctx) => { if (values.password !== values.passwordRepeat) { ctx.addIssue({ @@ -19,7 +26,7 @@ export const signUpFormSchema = z.object({ export type SignUpFormData = z.infer; -export const defaultValues ={ +export const defaultValues = { email: '', password: '', givenName: '', From 38a7ab9ecd5de8df3d3eda09fad819640cba2a48 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 23 Jun 2025 21:44:49 +0200 Subject: [PATCH 05/21] feat: #32 Auto generate avatars & refactor users (#90) --- convex/_generated/api.d.ts | 48 ++++++++++--------- .../_helpers/deepenMatchResultComment.ts | 9 ++-- .../_helpers/deepenMatchResultLike.ts | 9 ++-- .../_helpers/deepenMatchResult.ts | 10 ++-- .../_helpers/deepenTournamentCompetitor.ts | 33 +++++-------- convex/_model/users/_helpers/checkUserAuth.ts | 28 +++++++++++ .../checkUserTournamentRelationship.ts | 30 ++++++++++++ .../_model/users/_helpers/getShallowUser.ts | 28 +++++++++++ .../users/_helpers/redactUser.ts} | 46 +++++++++++++----- .../users/actions/setUserDefaultAvatar.ts | 48 +++++++++++++++++++ .../index.ts => _model/users/fields.ts} | 19 ++------ convex/_model/users/index.ts | 42 ++++++++++++++++ convex/_model/users/mutations/updateUser.ts | 34 +++++++++++++ .../users/mutations/updateUserAvatarNoAuth.ts | 21 ++++++++ convex/_model/users/queries/getCurrentUser.ts | 23 +++++++++ convex/_model/users/queries/getUser.ts | 29 +++++++++++ convex/_model/users/queries/getUsers.ts | 33 +++++++++++++ convex/auth.ts | 10 ++++ convex/schema.ts | 4 +- convex/users.ts | 37 ++++++++++++++ convex/users/checkUsernameExists.ts | 13 ----- convex/users/fetchCurrentUser.ts | 22 --------- convex/users/fetchUser.ts | 22 --------- convex/users/fetchUserList.ts | 21 -------- convex/users/updateAvatar.ts | 37 -------------- convex/users/updateUser.ts | 22 --------- .../utils/checkUserTournamentRelationship.ts | 30 ------------ convex/users/utils/getAvatarUrl.ts | 13 ----- convex/users/utils/getLimitedUser.ts | 21 -------- src/api.ts | 12 ++--- src/components/AuthProvider/AuthProvider.tsx | 2 +- .../FowV4MatchResultDetails.hooks.ts | 2 +- .../components/CommonFields.hooks.ts | 6 +-- .../components/SelectPlayerDialog.tsx | 6 +-- .../IdentityBadge/IdentityBadge.hooks.tsx | 15 ++---- .../UserProfileForm/UserProfileForm.tsx | 8 ++-- src/services/avatar/useUploadAvatar.ts | 6 +-- src/services/users.ts | 9 ++-- 38 files changed, 490 insertions(+), 318 deletions(-) create mode 100644 convex/_model/users/_helpers/checkUserAuth.ts create mode 100644 convex/_model/users/_helpers/checkUserTournamentRelationship.ts create mode 100644 convex/_model/users/_helpers/getShallowUser.ts rename convex/{users/utils/redactUserInfo.ts => _model/users/_helpers/redactUser.ts} (52%) create mode 100644 convex/_model/users/actions/setUserDefaultAvatar.ts rename convex/{users/index.ts => _model/users/fields.ts} (54%) create mode 100644 convex/_model/users/index.ts create mode 100644 convex/_model/users/mutations/updateUser.ts create mode 100644 convex/_model/users/mutations/updateUserAvatarNoAuth.ts create mode 100644 convex/_model/users/queries/getCurrentUser.ts create mode 100644 convex/_model/users/queries/getUser.ts create mode 100644 convex/_model/users/queries/getUsers.ts create mode 100644 convex/users.ts delete mode 100644 convex/users/checkUsernameExists.ts delete mode 100644 convex/users/fetchCurrentUser.ts delete mode 100644 convex/users/fetchUser.ts delete mode 100644 convex/users/fetchUserList.ts delete mode 100644 convex/users/updateAvatar.ts delete mode 100644 convex/users/updateUser.ts delete mode 100644 convex/users/utils/checkUserTournamentRelationship.ts delete mode 100644 convex/users/utils/getAvatarUrl.ts delete mode 100644 convex/users/utils/getLimitedUser.ts diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 42cb2132..11df2d20 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -125,6 +125,18 @@ import type * as _model_tournaments_queries_getTournamentOpenRound from "../_mod import type * as _model_tournaments_queries_getTournamentRankings from "../_model/tournaments/queries/getTournamentRankings.js"; import type * as _model_tournaments_queries_getTournaments from "../_model/tournaments/queries/getTournaments.js"; import type * as _model_tournaments_queries_getTournamentsByStatus from "../_model/tournaments/queries/getTournamentsByStatus.js"; +import type * as _model_users__helpers_checkUserAuth from "../_model/users/_helpers/checkUserAuth.js"; +import type * as _model_users__helpers_checkUserTournamentRelationship from "../_model/users/_helpers/checkUserTournamentRelationship.js"; +import type * as _model_users__helpers_getShallowUser from "../_model/users/_helpers/getShallowUser.js"; +import type * as _model_users__helpers_redactUser from "../_model/users/_helpers/redactUser.js"; +import type * as _model_users_actions_setUserDefaultAvatar from "../_model/users/actions/setUserDefaultAvatar.js"; +import type * as _model_users_fields from "../_model/users/fields.js"; +import type * as _model_users_index from "../_model/users/index.js"; +import type * as _model_users_mutations_updateUser from "../_model/users/mutations/updateUser.js"; +import type * as _model_users_mutations_updateUserAvatarNoAuth from "../_model/users/mutations/updateUserAvatarNoAuth.js"; +import type * as _model_users_queries_getCurrentUser from "../_model/users/queries/getCurrentUser.js"; +import type * as _model_users_queries_getUser from "../_model/users/queries/getUser.js"; +import type * as _model_users_queries_getUsers from "../_model/users/queries/getUsers.js"; import type * as _model_utils__helpers_mockData from "../_model/utils/_helpers/mockData.js"; import type * as _model_utils__helpers_testUsers from "../_model/utils/_helpers/testUsers.js"; import type * as _model_utils_createTestTournament from "../_model/utils/createTestTournament.js"; @@ -171,17 +183,7 @@ import type * as tournamentCompetitors from "../tournamentCompetitors.js"; import type * as tournamentPairings from "../tournamentPairings.js"; import type * as tournamentTimers from "../tournamentTimers.js"; import type * as tournaments from "../tournaments.js"; -import type * as users_checkUsernameExists from "../users/checkUsernameExists.js"; -import type * as users_fetchCurrentUser from "../users/fetchCurrentUser.js"; -import type * as users_fetchUser from "../users/fetchUser.js"; -import type * as users_fetchUserList from "../users/fetchUserList.js"; -import type * as users_index from "../users/index.js"; -import type * as users_updateAvatar from "../users/updateAvatar.js"; -import type * as users_updateUser from "../users/updateUser.js"; -import type * as users_utils_checkUserTournamentRelationship from "../users/utils/checkUserTournamentRelationship.js"; -import type * as users_utils_getAvatarUrl from "../users/utils/getAvatarUrl.js"; -import type * as users_utils_getLimitedUser from "../users/utils/getLimitedUser.js"; -import type * as users_utils_redactUserInfo from "../users/utils/redactUserInfo.js"; +import type * as users from "../users.js"; import type * as utils from "../utils.js"; /** @@ -305,6 +307,18 @@ declare const fullApi: ApiFromModules<{ "_model/tournaments/queries/getTournamentRankings": typeof _model_tournaments_queries_getTournamentRankings; "_model/tournaments/queries/getTournaments": typeof _model_tournaments_queries_getTournaments; "_model/tournaments/queries/getTournamentsByStatus": typeof _model_tournaments_queries_getTournamentsByStatus; + "_model/users/_helpers/checkUserAuth": typeof _model_users__helpers_checkUserAuth; + "_model/users/_helpers/checkUserTournamentRelationship": typeof _model_users__helpers_checkUserTournamentRelationship; + "_model/users/_helpers/getShallowUser": typeof _model_users__helpers_getShallowUser; + "_model/users/_helpers/redactUser": typeof _model_users__helpers_redactUser; + "_model/users/actions/setUserDefaultAvatar": typeof _model_users_actions_setUserDefaultAvatar; + "_model/users/fields": typeof _model_users_fields; + "_model/users/index": typeof _model_users_index; + "_model/users/mutations/updateUser": typeof _model_users_mutations_updateUser; + "_model/users/mutations/updateUserAvatarNoAuth": typeof _model_users_mutations_updateUserAvatarNoAuth; + "_model/users/queries/getCurrentUser": typeof _model_users_queries_getCurrentUser; + "_model/users/queries/getUser": typeof _model_users_queries_getUser; + "_model/users/queries/getUsers": typeof _model_users_queries_getUsers; "_model/utils/_helpers/mockData": typeof _model_utils__helpers_mockData; "_model/utils/_helpers/testUsers": typeof _model_utils__helpers_testUsers; "_model/utils/createTestTournament": typeof _model_utils_createTestTournament; @@ -351,17 +365,7 @@ declare const fullApi: ApiFromModules<{ tournamentPairings: typeof tournamentPairings; tournamentTimers: typeof tournamentTimers; tournaments: typeof tournaments; - "users/checkUsernameExists": typeof users_checkUsernameExists; - "users/fetchCurrentUser": typeof users_fetchCurrentUser; - "users/fetchUser": typeof users_fetchUser; - "users/fetchUserList": typeof users_fetchUserList; - "users/index": typeof users_index; - "users/updateAvatar": typeof users_updateAvatar; - "users/updateUser": typeof users_updateUser; - "users/utils/checkUserTournamentRelationship": typeof users_utils_checkUserTournamentRelationship; - "users/utils/getAvatarUrl": typeof users_utils_getAvatarUrl; - "users/utils/getLimitedUser": typeof users_utils_getLimitedUser; - "users/utils/redactUserInfo": typeof users_utils_redactUserInfo; + users: typeof users; utils: typeof utils; }>; export declare const api: FilterApi< diff --git a/convex/_model/matchResultComments/_helpers/deepenMatchResultComment.ts b/convex/_model/matchResultComments/_helpers/deepenMatchResultComment.ts index 3dd7fd4b..2ebb515c 100644 --- a/convex/_model/matchResultComments/_helpers/deepenMatchResultComment.ts +++ b/convex/_model/matchResultComments/_helpers/deepenMatchResultComment.ts @@ -1,6 +1,9 @@ +import { ConvexError } from 'convex/values'; + import { Doc } from '../../../_generated/dataModel'; import { QueryCtx } from '../../../_generated/server'; -import { getLimitedUser } from '../../../users/utils/getLimitedUser'; +import { getErrorMessage } from '../../../common/errors'; +import { getUser } from '../../users/queries/getUser'; /* eslint-disable @typescript-eslint/explicit-function-return-type */ /** @@ -17,9 +20,9 @@ export const deepenMatchResultComment = async ( ctx: QueryCtx, matchResultComment: Doc<'matchResultComments'>, ) => { - const user = await getLimitedUser(ctx, matchResultComment.userId); + const user = await getUser(ctx, { id: matchResultComment.userId }); if (!user) { - throw Error('Could not find user!'); + throw new ConvexError(getErrorMessage('USER_NOT_FOUND')); } return { ...matchResultComment, diff --git a/convex/_model/matchResultLikes/_helpers/deepenMatchResultLike.ts b/convex/_model/matchResultLikes/_helpers/deepenMatchResultLike.ts index 84f55799..8123f73c 100644 --- a/convex/_model/matchResultLikes/_helpers/deepenMatchResultLike.ts +++ b/convex/_model/matchResultLikes/_helpers/deepenMatchResultLike.ts @@ -1,6 +1,9 @@ +import { ConvexError } from 'convex/values'; + import { Doc } from '../../../_generated/dataModel'; import { QueryCtx } from '../../../_generated/server'; -import { getLimitedUser } from '../../../users/utils/getLimitedUser'; +import { getErrorMessage } from '../../../common/errors'; +import { getUser } from '../../users/queries/getUser'; /* eslint-disable @typescript-eslint/explicit-function-return-type */ /** @@ -17,9 +20,9 @@ export const deepenMatchResultLike = async ( ctx: QueryCtx, matchResultLike: Doc<'matchResultLikes'>, ) => { - const user = await getLimitedUser(ctx, matchResultLike.userId); + const user = await getUser(ctx, { id: matchResultLike.userId }); if (!user) { - throw Error('Could not find user!'); + throw new ConvexError(getErrorMessage('USER_NOT_FOUND')); } return { ...matchResultLike, diff --git a/convex/_model/matchResults/_helpers/deepenMatchResult.ts b/convex/_model/matchResults/_helpers/deepenMatchResult.ts index 4a7f3346..1ef3a542 100644 --- a/convex/_model/matchResults/_helpers/deepenMatchResult.ts +++ b/convex/_model/matchResults/_helpers/deepenMatchResult.ts @@ -1,7 +1,7 @@ import { Doc } from '../../../_generated/dataModel'; import { QueryCtx } from '../../../_generated/server'; -import { getLimitedUser } from '../../../users/utils/getLimitedUser'; import { getMission } from '../../fowV4/getMission'; +import { getUser } from '../../users/queries/getUser'; /* eslint-disable @typescript-eslint/explicit-function-return-type */ /** @@ -18,8 +18,12 @@ export const deepenMatchResult = async ( ctx: QueryCtx, matchResult: Doc<'matchResults'>, ) => { - const player0User = await getLimitedUser(ctx, matchResult?.player0UserId); - const player1User = await getLimitedUser(ctx, matchResult?.player1UserId); + const player0User = matchResult?.player0UserId ? await getUser(ctx, { + id: matchResult.player0UserId, + }) : null; + const player1User = matchResult?.player1UserId ? await getUser(ctx, { + id: matchResult.player1UserId, + }) : null; const mission = getMission(matchResult.details.missionId); const comments = await ctx.db.query('matchResultComments') .withIndex('by_match_result_id',((q) => q.eq('matchResultId', matchResult._id))) diff --git a/convex/_model/tournamentCompetitors/_helpers/deepenTournamentCompetitor.ts b/convex/_model/tournamentCompetitors/_helpers/deepenTournamentCompetitor.ts index 5cc340b0..1f3778ad 100644 --- a/convex/_model/tournamentCompetitors/_helpers/deepenTournamentCompetitor.ts +++ b/convex/_model/tournamentCompetitors/_helpers/deepenTournamentCompetitor.ts @@ -1,7 +1,7 @@ import { Doc } from '../../../_generated/dataModel'; import { QueryCtx } from '../../../_generated/server'; -import { redactUserInfo } from '../../../users/utils/redactUserInfo'; -import { getStorageUrl } from '../../common/_helpers/getStorageUrl'; +import { LimitedUser } from '../../users/_helpers/redactUser'; +import { getUser } from '../../users/queries/getUser'; /* eslint-disable @typescript-eslint/explicit-function-return-type */ /** @@ -18,27 +18,18 @@ export const deepenTournamentCompetitor = async ( ctx: QueryCtx, tournamentCompetitor: Doc<'tournamentCompetitors'>, ) => { - const players = await Promise.all(tournamentCompetitor.players.map(async ({ active, userId }) => { - // TODO: Replace with proper user helper - const user = await ctx.db.get(userId); - if (!user) { - return { - active, - user: null, - }; - } - const avatarUrl = await getStorageUrl(ctx, user.avatarStorageId); - return { - active, - user: { - ...await redactUserInfo(ctx, user), - avatarUrl, - }, - }; - })); + const players = await Promise.all(tournamentCompetitor.players.map(async ({ active, userId }) => ({ + active, + user: await getUser(ctx, { id: userId }), + }))); + + function playerHasUser(player: { active: boolean; user: LimitedUser | null }): player is { active: boolean; user: LimitedUser } { + return player.user !== null; + } + return { ...tournamentCompetitor, - players: players.filter((player) => !!player.user), + players: players.filter(playerHasUser), }; }; diff --git a/convex/_model/users/_helpers/checkUserAuth.ts b/convex/_model/users/_helpers/checkUserAuth.ts new file mode 100644 index 00000000..3f9992bc --- /dev/null +++ b/convex/_model/users/_helpers/checkUserAuth.ts @@ -0,0 +1,28 @@ +import { getAuthUserId } from '@convex-dev/auth/server'; +import { ConvexError } from 'convex/values'; + +import { Doc } from '../../../_generated/dataModel'; +import { QueryCtx } from '../../../_generated/server'; +import { getErrorMessage } from '../../../common/errors'; + +/** + * Checks if a user has permission to perform actions on a user. + * Throws an error if: + * - The user is not authenticated; + * - The user is attempting to edit another user; + * + * @param ctx - Convex query context + * @param user - Raw user document + */ +export const checkUserAuth = async ( + ctx: QueryCtx, + user: Doc<'users'>, // TODO: Make union with TournamentDeep +): Promise => { + const userId = await getAuthUserId(ctx); + if (!userId) { + throw new ConvexError(getErrorMessage('USER_NOT_AUTHENTICATED')); + } + if (userId !== user._id) { + throw new ConvexError(getErrorMessage('USER_DOES_NOT_HAVE_PERMISSION')); + } +}; diff --git a/convex/_model/users/_helpers/checkUserTournamentRelationship.ts b/convex/_model/users/_helpers/checkUserTournamentRelationship.ts new file mode 100644 index 00000000..716a4d78 --- /dev/null +++ b/convex/_model/users/_helpers/checkUserTournamentRelationship.ts @@ -0,0 +1,30 @@ +import { Id } from '../../../_generated/dataModel'; +import { QueryCtx } from '../../../_generated/server'; +import { getTournamentUserIds } from '../../../_model/tournaments'; + +export const checkUserTournamentRelationship = async ( + ctx: QueryCtx, + userIdA?: Id<'users'> | null, + userIdB?: Id<'users'> | null, +): Promise => { + if (!userIdA || !userIdB) { + return false; + } + + const tournaments = await ctx.db.query('tournaments').collect(); + + // Check each tournament for a relationship, return true if one is found + return tournaments.some(async ({ _id, organizerUserIds }) => { + + const playerUserIds = await getTournamentUserIds(ctx, _id); + + // Merge all organizer IDs and player IDs into one set + const allTournamentUserIds = new Set([ + ...organizerUserIds, + ...playerUserIds, + ]); + + // If the set contains both user IDs, they were at the same tournament + return allTournamentUserIds.has(userIdA) && allTournamentUserIds.has(userIdB); + }); +}; diff --git a/convex/_model/users/_helpers/getShallowUser.ts b/convex/_model/users/_helpers/getShallowUser.ts new file mode 100644 index 00000000..b2c5c354 --- /dev/null +++ b/convex/_model/users/_helpers/getShallowUser.ts @@ -0,0 +1,28 @@ +import { ConvexError } from 'convex/values'; + +import { Doc, Id } from '../../../_generated/dataModel'; +import { QueryCtx } from '../../../_generated/server'; +import { getErrorMessage } from '../../../common/errors'; + +/** + * Gets a user from the database without joining any additional data (shallow). + * + * @remarks + * This method should be used when you KNOW a user exists. + * This is almost always the case. + * If the document does not exist in the database, this function will throw an error. + * + * @param ctx - Convex query context + * @param id - ID of the user + * @returns A raw user document + */ +export const getShallowUser = async ( + ctx: QueryCtx, + id: Id<'users'>, +): Promise> => { + const user = await ctx.db.get(id); + if (!user) { + throw new ConvexError(getErrorMessage('USER_NOT_FOUND')); + } + return user; +}; diff --git a/convex/users/utils/redactUserInfo.ts b/convex/_model/users/_helpers/redactUser.ts similarity index 52% rename from convex/users/utils/redactUserInfo.ts rename to convex/_model/users/_helpers/redactUser.ts index cdfd77cf..d3e001ef 100644 --- a/convex/users/utils/redactUserInfo.ts +++ b/convex/_model/users/_helpers/redactUser.ts @@ -1,33 +1,55 @@ import { getAuthUserId } from '@convex-dev/auth/server'; -import { Doc } from '../../_generated/dataModel'; -import { QueryCtx } from '../../_generated/server'; +import { Doc } from '../../../_generated/dataModel'; +import { QueryCtx } from '../../../_generated/server'; +import { getStorageUrl } from '../../common/_helpers/getStorageUrl'; import { checkUserTournamentRelationship } from './checkUserTournamentRelationship'; -export const redactUserInfo = async ( +/** + * User with some personal information hidden based on their preferences. + */ +export type LimitedUser = Omit, 'givenName' | 'familyName' | 'countryCode'> & { + givenName?: string; + familyName?: string; + countryCode?: string; + avatarUrl?: string; +}; + +/** + * Removes a users's real name or location based on their preferences, also adds avatarUrl. + * + * @remarks + * This is essentially the user equivalent to the deepen[Resource]() pattern. + * + * @param ctx - Convex query context + * @param tournament - Raw user document + * @returns A limited user + */ +export const redactUser = async ( ctx: QueryCtx, user: Doc<'users'>, -): Promise> => { - const queryingUserId = await getAuthUserId(ctx); +): Promise => { + const userId = await getAuthUserId(ctx); // eslint-disable-next-line @typescript-eslint/no-unused-vars const { givenName, familyName, countryCode, ...restFields } = user; + const avatarUrl = await getStorageUrl(ctx, user.avatarStorageId); - const limitedUser: Doc<'users'> = restFields; + const limitedUser: LimitedUser = { + ...restFields, + avatarUrl, + }; // If user is querying own profile, simply return it - if (queryingUserId === user._id) { - return user; + if (userId === user._id) { + return { ...user, avatarUrl }; } // If user is querying someone they are in a friendship or club with const hasFriendRelationship = false; // If user is querying someone they are in a tournament with - const hasTournamentRelationship = (queryingUserId && limitedUser?._id) ? await checkUserTournamentRelationship(ctx, { - queryingUserId, - evaluatingUserId: user._id, - }) : false; + const hasTournamentRelationship = await checkUserTournamentRelationship(ctx, userId, user._id); // Add name information if allowed if ( diff --git a/convex/_model/users/actions/setUserDefaultAvatar.ts b/convex/_model/users/actions/setUserDefaultAvatar.ts new file mode 100644 index 00000000..e0de9131 --- /dev/null +++ b/convex/_model/users/actions/setUserDefaultAvatar.ts @@ -0,0 +1,48 @@ +'use node'; + +import { Infer, v } from 'convex/values'; + +import { api, internal } from '../../../_generated/api'; +import { ActionCtx } from '../../../_generated/server'; + +export const setUserDefaultAvatarArgs = v.object({ + userId: v.id('users'), +}); + +export const setUserDefaultAvatar = async ( + ctx: ActionCtx, + args: Infer, +): Promise => { + const user = await ctx.runQuery(api.users.getUser, { + id: args.userId, + }); + if (!user || !!user.avatarStorageId) { + return; + } + + // Fetch avatar + const avatarUrl = `https://api.dicebear.com/7.x/bottts-neutral/svg?seed=${args.userId}&scale=75`; + const avatarResponse = await fetch(avatarUrl); + if (!avatarResponse.ok) { + throw new Error('Failed to fetch avatar'); + } + const svg = await avatarResponse.arrayBuffer(); + + // Upload avatar + const uploadUrl = await ctx.storage.generateUploadUrl(); + const uploadResponse = await fetch(uploadUrl, { + method: 'POST', + headers: { 'Content-Type': 'image/svg+xml' }, + body: svg, + }); + if (!uploadResponse.ok) { + throw new Error('Failed to upload avatar to storage'); + } + const { storageId: avatarStorageId } = await uploadResponse.json(); + + // Update the user profile document with the new avatar + await ctx.runMutation(internal.users.updateUserAvatarNoAuth, { + userId: args.userId, + avatarStorageId, + }); +}; diff --git a/convex/users/index.ts b/convex/_model/users/fields.ts similarity index 54% rename from convex/users/index.ts rename to convex/_model/users/fields.ts index e92caa44..62ff0d31 100644 --- a/convex/users/index.ts +++ b/convex/_model/users/fields.ts @@ -1,9 +1,8 @@ -import { defineTable } from 'convex/server'; import { v } from 'convex/values'; -import { userDataVisibilityLevel } from '../common/userDataVisibilityLevel'; +import { userDataVisibilityLevel } from '../../common/userDataVisibilityLevel'; -export const fields = { +export const editableFields = { avatarStorageId: v.optional(v.id('_storage')), countryCode: v.optional(v.string()), familyName: v.optional(v.string()), @@ -13,20 +12,8 @@ export const fields = { username: v.optional(v.string()), }; -export const table = defineTable({ - ...fields, +export const computedFields = { email: v.string(), emailVerificationTime: v.optional(v.number()), modifiedAt: v.optional(v.number()), -}).index( - 'by_country_code', ['countryCode'], -).index( - 'by_name', ['givenName', 'familyName'], -).index( - 'by_username', ['username'], -); - -export { - fields as userFields, - table as users, }; diff --git a/convex/_model/users/index.ts b/convex/_model/users/index.ts new file mode 100644 index 00000000..4bac0ba1 --- /dev/null +++ b/convex/_model/users/index.ts @@ -0,0 +1,42 @@ +import { defineTable } from 'convex/server'; + +import { Id } from '../../_generated/dataModel'; +import { computedFields, editableFields } from './fields'; + +export const usersTable = defineTable({ + ...editableFields, + ...computedFields, +}) + .index('by_country_code', ['countryCode']) + .index('by_name', ['givenName', 'familyName']) + .index('by_username', ['username']); + +export type UserId = Id<'users'>; + +export { + type LimitedUser, + redactUser, +} from './_helpers/redactUser'; +export { + setUserDefaultAvatar, + setUserDefaultAvatarArgs, +} from './actions/setUserDefaultAvatar'; +export { + updateUser, + updateUserArgs, +} from './mutations/updateUser'; +export { + updateUserAvatarNoAuth, + updateUserAvatarNoAuthArgs, +} from './mutations/updateUserAvatarNoAuth'; +export { + getCurrentUser, +} from './queries/getCurrentUser'; +export { + getUser, + getUserArgs, +} from './queries/getUser'; +export { + getUsers, + getUsersArgs, +} from './queries/getUsers'; diff --git a/convex/_model/users/mutations/updateUser.ts b/convex/_model/users/mutations/updateUser.ts new file mode 100644 index 00000000..3f02fc42 --- /dev/null +++ b/convex/_model/users/mutations/updateUser.ts @@ -0,0 +1,34 @@ +import { Infer, v } from 'convex/values'; + +import { MutationCtx } from '../../../_generated/server'; +import { checkUserAuth } from '../_helpers/checkUserAuth'; +import { getShallowUser } from '../_helpers/getShallowUser'; +import { editableFields } from '../fields'; + +export const updateUserArgs = v.object({ + id: v.id('users'), + ...editableFields, +}); + +/** + * Updates a user. + * + * @param ctx - Convex query context + * @param args - User data + */ +export const updateUser = async ( + ctx: MutationCtx, + args: Infer, +): Promise => { + const { id, ...updated } = args; + const user = await getShallowUser(ctx, id); + + // --- CHECK AUTH ---- + checkUserAuth(ctx, user); + + // ---- PRIMARY ACTIONS ---- + await ctx.db.patch(id, { + ...updated, + modifiedAt: Date.now(), + }); +}; diff --git a/convex/_model/users/mutations/updateUserAvatarNoAuth.ts b/convex/_model/users/mutations/updateUserAvatarNoAuth.ts new file mode 100644 index 00000000..1d033306 --- /dev/null +++ b/convex/_model/users/mutations/updateUserAvatarNoAuth.ts @@ -0,0 +1,21 @@ +import { Infer, v } from 'convex/values'; + +import { MutationCtx } from '../../../_generated/server'; + +export const updateUserAvatarNoAuthArgs = v.object({ + userId: v.id('users'), + avatarStorageId: v.id('_storage'), +}); + +export const updateUserAvatarNoAuth = async ( + ctx: MutationCtx, + args: Infer, +): Promise => { + const user = await ctx.db.get(args.userId); + if (!user) { + throw 'Could not find a user by that ID.'; + } + await ctx.db.patch(args.userId, { + avatarStorageId: args.avatarStorageId, + }); +}; diff --git a/convex/_model/users/queries/getCurrentUser.ts b/convex/_model/users/queries/getCurrentUser.ts new file mode 100644 index 00000000..5e6b3543 --- /dev/null +++ b/convex/_model/users/queries/getCurrentUser.ts @@ -0,0 +1,23 @@ +import { getAuthUserId } from '@convex-dev/auth/server'; + +import { QueryCtx } from '../../../_generated/server'; +import { LimitedUser } from '../_helpers/redactUser'; +import { getUser } from './getUser'; + +/** + * Gets the querying user. + * + * @param ctx - Convex query context + * @param args - Convex query args + * @param args.id - ID of the user + * @returns - A limited user if found, otherwise null + */ +export const getCurrentUser = async ( + ctx: QueryCtx, +): Promise => { + const userId = await getAuthUserId(ctx); + if (!userId) { + return null; + } + return await getUser(ctx, { id: userId }); +}; diff --git a/convex/_model/users/queries/getUser.ts b/convex/_model/users/queries/getUser.ts new file mode 100644 index 00000000..b28aaed2 --- /dev/null +++ b/convex/_model/users/queries/getUser.ts @@ -0,0 +1,29 @@ +import { Infer, v } from 'convex/values'; + +import { QueryCtx } from '../../../_generated/server'; +import { LimitedUser, redactUser } from '../_helpers/redactUser'; + +export const getUserArgs = v.object({ + id: v.id('users'), +}); + +/** + * Gets a user by ID. + * + * @param ctx - Convex query context + * @param args - Convex query args + * @param args.id - ID of the user + * @returns - A limited user if found, otherwise null + */ +export const getUser = async ( + ctx: QueryCtx, + args: Infer, +): Promise => { + const user = await ctx.db.get(args.id); + if (!user) { + return null; + } + + // --- CHECK AUTH ---- + return await redactUser(ctx, user); +}; diff --git a/convex/_model/users/queries/getUsers.ts b/convex/_model/users/queries/getUsers.ts new file mode 100644 index 00000000..141762ca --- /dev/null +++ b/convex/_model/users/queries/getUsers.ts @@ -0,0 +1,33 @@ +import { Infer, v } from 'convex/values'; + +import { QueryCtx } from '../../../_generated/server'; +import { filterWithSearchTerm } from '../../common/_helpers/filterWithSearchTerm'; +import { LimitedUser, redactUser } from '../_helpers/redactUser'; + +export const getUsersArgs = v.object({ + search: v.optional(v.string()), +}); + +/** + * Gets an array of limited users. + * + * @param ctx - Convex query context + * @param args - Convex query args + * @param args.search - Search text + * @returns An array of limited users + */ +export const getUsers = async ( + ctx: QueryCtx, + args: Infer, +): Promise => { + const users = await ctx.db.query('users').collect(); + const limitedUsers = await Promise.all(users.map(async (user) => await redactUser(ctx, user))); + if (args.search) { + return filterWithSearchTerm(limitedUsers, args.search, [ + 'familyName', + 'givenName', + 'username', + ]); + } + return limitedUsers; +}; diff --git a/convex/auth.ts b/convex/auth.ts index bfb71f16..1a3ede2a 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -1,6 +1,8 @@ import { Password } from '@convex-dev/auth/providers/Password'; import { convexAuth } from '@convex-dev/auth/server'; +import { api } from './_generated/api'; +import { MutationCtx } from './_generated/server'; import { ResendOtpPasswordReset } from './auth/ResendOtpPasswordReset'; import { UserDataVisibilityLevel } from './common/userDataVisibilityLevel'; @@ -34,4 +36,12 @@ export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({ // verify: ResendOtpVerification, }), ], + callbacks: { + afterUserCreatedOrUpdated: async (ctx: MutationCtx, { userId }) => { + // Use scheduler so that we can trigger an action rather than a mutation: + await ctx.scheduler.runAfter(0, api.users.setUserDefaultAvatar, { + userId, + }); + }, + }, }); diff --git a/convex/schema.ts b/convex/schema.ts index 771d569f..d5a8102a 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -8,9 +8,9 @@ import { tournamentCompetitorsTable } from './_model/tournamentCompetitors'; import { tournamentPairingsTable } from './_model/tournamentPairings'; import { tournamentsTable } from './_model/tournaments'; import { tournamentTimersTable } from './_model/tournamentTimers'; +import { usersTable } from './_model/users'; import { friendships } from './friendships'; import { photos } from './photos'; -import { users } from './users'; export default defineSchema({ ...authTables, @@ -23,5 +23,5 @@ export default defineSchema({ tournamentPairings: tournamentPairingsTable, tournaments: tournamentsTable, tournamentTimers: tournamentTimersTable, - users, + users: usersTable, }); diff --git a/convex/users.ts b/convex/users.ts new file mode 100644 index 00000000..3bc00c47 --- /dev/null +++ b/convex/users.ts @@ -0,0 +1,37 @@ +import { + action, + internalMutation, + mutation, + query, +} from './_generated/server'; +import * as model from './_model/users'; + +// INTERNAL MUTATIONS +export const updateUserAvatarNoAuth = internalMutation({ + args: model.updateUserAvatarNoAuthArgs, + handler: model.updateUserAvatarNoAuth, +}); + +export const setUserDefaultAvatar = action({ + args: model.setUserDefaultAvatarArgs, + handler: model.setUserDefaultAvatar, +}); + +export const getCurrentUser = query({ + handler: model.getCurrentUser, +}); + +export const getUser = query({ + args: model.getUserArgs, + handler: model.getUser, +}); + +export const getUsers = query({ + args: model.getUsersArgs, + handler: model.getUsers, +}); + +export const updateUser = mutation({ + args: model.updateUserArgs, + handler: model.updateUser, +}); diff --git a/convex/users/checkUsernameExists.ts b/convex/users/checkUsernameExists.ts deleted file mode 100644 index aa3f5377..00000000 --- a/convex/users/checkUsernameExists.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { v } from 'convex/values'; - -import { query } from '../_generated/server'; - -export const checkUsernameExists = query({ - args: { - username: v.string(), - }, - handler: async (ctx, args) => { - const user = await ctx.db.query('users').withIndex('by_username', (q) => q.eq('username', args.username)).first(); - return Boolean(user); - }, -}); diff --git a/convex/users/fetchCurrentUser.ts b/convex/users/fetchCurrentUser.ts deleted file mode 100644 index e1deef99..00000000 --- a/convex/users/fetchCurrentUser.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { getAuthUserId } from '@convex-dev/auth/server'; - -import { getAvatarUrl } from './utils/getAvatarUrl'; -import { query } from '../_generated/server'; - -export const fetchCurrentUser = query({ - handler: async (ctx) => { - const userId = await getAuthUserId(ctx); - if (!userId) { - return null; - } - const user = await ctx.db.get(userId); - if (!user) { - return null; - } - const avatarUrl = await getAvatarUrl(ctx, user.avatarStorageId); - return { - ...user, - avatarUrl, - }; - }, -}); diff --git a/convex/users/fetchUser.ts b/convex/users/fetchUser.ts deleted file mode 100644 index d68b9ac8..00000000 --- a/convex/users/fetchUser.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { v } from 'convex/values'; - -import { getAvatarUrl } from './utils/getAvatarUrl'; -import { getLimitedUser } from './utils/getLimitedUser'; -import { query } from '../_generated/server'; - -export const fetchUser = query({ - args: { - id: v.id('users'), - }, - handler: async (ctx, { id }) => { - const user = await getLimitedUser(ctx, id); - if (!user) { - return null; - } - const avatarUrl = await getAvatarUrl(ctx, user.avatarStorageId); - return { - ...user, - avatarUrl, - }; - }, -}); diff --git a/convex/users/fetchUserList.ts b/convex/users/fetchUserList.ts deleted file mode 100644 index 199bc926..00000000 --- a/convex/users/fetchUserList.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { v } from 'convex/values'; - -import { getAvatarUrl } from './utils/getAvatarUrl'; -import { redactUserInfo } from './utils/redactUserInfo'; -import { query } from '../_generated/server'; -import { filterWithSearchTerm } from '../_model/common/_helpers/filterWithSearchTerm'; - -export const fetchUserList = query({ - args: { - // TODO: Add tournamentId, tournamentCompetitorId, clubId - search: v.optional(v.string()), - }, - handler: async (ctx, args) => { - const users = await ctx.db.query('users').collect(); - const results = await Promise.all(users.map(async (u) => ({ - ...await redactUserInfo(ctx, u), - avatarUrl: await getAvatarUrl(ctx, u.avatarStorageId), - }))); - return filterWithSearchTerm(results, args.search ?? '', ['familyName', 'givenName', 'username']); - }, -}); diff --git a/convex/users/updateAvatar.ts b/convex/users/updateAvatar.ts deleted file mode 100644 index bea82dfe..00000000 --- a/convex/users/updateAvatar.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { getAuthUserId } from '@convex-dev/auth/server'; -import { v } from 'convex/values'; - -import { mutation } from '../_generated/server'; - -export const updateAvatar = mutation({ - args: { - userId: v.id('users'), - avatarStorageId: v.id('_storage'), - }, - handler: async (ctx, { userId, avatarStorageId }) => { - const queryingUserId = await getAuthUserId(ctx); - - if (userId !== queryingUserId) { - throw 'Cannot modify another user\'s avatar.'; - } - - const user = await ctx.db.get(userId); - - if (!user) { - throw 'Could not find a user by that ID.'; - } - - const existingAvatarStorageId = user?.avatarStorageId; - - // Add the new storage ID to the user - await ctx.db.patch(userId, { - avatarStorageId, - modifiedAt: Date.now(), - }); - - // Delete the old file to clean-up - if (existingAvatarStorageId) { - await ctx.storage.delete(existingAvatarStorageId); - } - }, -}); diff --git a/convex/users/updateUser.ts b/convex/users/updateUser.ts deleted file mode 100644 index fee6b725..00000000 --- a/convex/users/updateUser.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { getAuthUserId } from '@convex-dev/auth/server'; -import { v } from 'convex/values'; - -import { mutation } from '../_generated/server'; -import { fields } from '.'; - -export const updateUser = mutation({ - args: { - ...fields, - id: v.id('users'), - }, - handler: async (ctx, { id, ...args }) => { - const queryingUserId = await getAuthUserId(ctx); - if (id !== queryingUserId) { - throw 'Cannot modify another user\'s profile!'; - } - await ctx.db.patch(id, { - ...args, - modifiedAt: Date.now(), - }); - }, -}); diff --git a/convex/users/utils/checkUserTournamentRelationship.ts b/convex/users/utils/checkUserTournamentRelationship.ts deleted file mode 100644 index d8cf2f8f..00000000 --- a/convex/users/utils/checkUserTournamentRelationship.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Id } from '../../_generated/dataModel'; -import { QueryCtx } from '../../_generated/server'; -import { getTournamentUserIds } from '../../_model/tournaments'; - -type CheckUserTournamentRelationshipArgs = { - queryingUserId: Id<'users'>; - evaluatingUserId: Id<'users'>; -}; - -export const checkUserTournamentRelationship = async ( - ctx: QueryCtx, - args: CheckUserTournamentRelationshipArgs, -): Promise => { - const tournaments = await ctx.db.query('tournaments').collect(); - - // Check each tournament for a relationship, return true if one is found - return tournaments.some(async (tournament) => { - - const competitorUserIds = await getTournamentUserIds(ctx, tournament._id); - - // Merge all organizer IDs and player IDs into one set - const allTournamentUserIds = new Set([ - ...tournament.organizerUserIds, - ...competitorUserIds, - ]); - - // If the set contains both user IDs, they were at the same tournament - return allTournamentUserIds.has(args.evaluatingUserId) && allTournamentUserIds.has(args.queryingUserId); - }); -}; diff --git a/convex/users/utils/getAvatarUrl.ts b/convex/users/utils/getAvatarUrl.ts deleted file mode 100644 index a9e0d9df..00000000 --- a/convex/users/utils/getAvatarUrl.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Id } from '../../_generated/dataModel'; -import { QueryCtx } from '../../_generated/server'; - -export const getAvatarUrl = async (ctx: QueryCtx, storageId?: Id<'_storage'>): Promise => { - if (!storageId) { - return undefined; - } - const avatarUrl = await ctx.storage.getUrl(storageId); - if (avatarUrl) { - return avatarUrl; - } - return undefined; -}; diff --git a/convex/users/utils/getLimitedUser.ts b/convex/users/utils/getLimitedUser.ts deleted file mode 100644 index 124a1992..00000000 --- a/convex/users/utils/getLimitedUser.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Doc, Id } from '../../_generated/dataModel'; -import { QueryCtx } from '../../_generated/server'; -import { redactUserInfo } from './redactUserInfo'; - -export const getLimitedUser = async ( - ctx: QueryCtx, - id?: Id<'users'>, -): Promise & { avatarUrl?: string } | undefined> => { - if (!id) { - return undefined; - } - const user = await ctx.db.get(id); - if (!user) { - return undefined; - } - const avatarUrl = user.avatarStorageId ? await ctx.storage.getUrl(user.avatarStorageId) : undefined; - return { - ...await redactUserInfo(ctx, user), - avatarUrl: avatarUrl || undefined, - }; -}; diff --git a/src/api.ts b/src/api.ts index b92c82bd..0647faf2 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,5 +1,5 @@ import { api } from '../convex/_generated/api'; -import { Doc, Id } from '../convex/_generated/dataModel'; +import { Id } from '../convex/_generated/dataModel'; // Re-export API for usage in services export { api }; @@ -56,12 +56,10 @@ export { } from '../convex/_model/tournaments'; // Users -export type User = Doc<'users'> & { - avatarUrl?: string; -}; -export type UserId = Id<'users'>; -export type UpdateUserInput = typeof api.users.updateUser.updateUser._args; -export type UpdateUserResponse = typeof api.users.updateUser.updateUser._returnType; +export { + type LimitedUser as User, + type UserId, +} from '../convex/_model/users'; // Static Data & Interfaces (Common) export { diff --git a/src/components/AuthProvider/AuthProvider.tsx b/src/components/AuthProvider/AuthProvider.tsx index 1a274b07..cd86841b 100644 --- a/src/components/AuthProvider/AuthProvider.tsx +++ b/src/components/AuthProvider/AuthProvider.tsx @@ -19,7 +19,7 @@ export interface AuthProviderProps { export const AuthProvider = ({ children, }: AuthProviderProps) => { - const user = useQuery(api.users.fetchCurrentUser.fetchCurrentUser); + const user = useQuery(api.users.getCurrentUser); const [loading, setLoading] = useState(true); const [updatePasswordDialogOpen, setUpdatePasswordDialogOpen] = useState(false); diff --git a/src/components/FowV4MatchResultDetails/FowV4MatchResultDetails.hooks.ts b/src/components/FowV4MatchResultDetails/FowV4MatchResultDetails.hooks.ts index 24de9cde..243b5356 100644 --- a/src/components/FowV4MatchResultDetails/FowV4MatchResultDetails.hooks.ts +++ b/src/components/FowV4MatchResultDetails/FowV4MatchResultDetails.hooks.ts @@ -18,7 +18,7 @@ export const usePlayerName = ( const { user, userId, placeholder } = identity; // TODO: Replace with a service hook - const queryUser = useQuery(api.users.fetchUser.fetchUser, userId ? { + const queryUser = useQuery(api.users.getUser, userId ? { id: userId, } : 'skip'); diff --git a/src/components/FowV4MatchResultForm/components/CommonFields.hooks.ts b/src/components/FowV4MatchResultForm/components/CommonFields.hooks.ts index 27d3d928..79edc529 100644 --- a/src/components/FowV4MatchResultForm/components/CommonFields.hooks.ts +++ b/src/components/FowV4MatchResultForm/components/CommonFields.hooks.ts @@ -1,8 +1,6 @@ import { useFormContext } from 'react-hook-form'; -import { useQuery } from 'convex/react'; import { - api, FowV4MatchOutcomeType, getMission, getMissionPack, @@ -13,7 +11,7 @@ import { getUserDisplayNameString } from '~/utils/common/getUserDisplayNameStrin export const usePlayerDisplayName = ({ userId, placeholder }: { userId?: UserId, placeholder?: string }): string => { const currentUser = useAuth(); - const user = useQuery(api.users.fetchUser.fetchUser, userId ? { id: userId } : 'skip'); + const { data: user } = useGetUser(userId ? { id: userId } : 'skip'); if (!userId && placeholder) { return placeholder; } @@ -43,6 +41,8 @@ export const usePlayerOptions = (): { value: number, label: string }[] => { import { useMemo } from 'react'; +import { useGetUser } from '~/services/users'; + export const useMissionOptions = () => { const { watch } = useFormContext(); const { details, gameSystemConfig } = watch(); diff --git a/src/components/FowV4MatchResultForm/components/SelectPlayerDialog.tsx b/src/components/FowV4MatchResultForm/components/SelectPlayerDialog.tsx index 99c9b1dd..39a89788 100644 --- a/src/components/FowV4MatchResultForm/components/SelectPlayerDialog.tsx +++ b/src/components/FowV4MatchResultForm/components/SelectPlayerDialog.tsx @@ -1,9 +1,8 @@ import { ChangeEvent, useState } from 'react'; import { Close } from '@radix-ui/react-dialog'; -import { useQuery } from 'convex/react'; import { Search } from 'lucide-react'; -import { api, UserId } from '~/api'; +import { UserId } from '~/api'; import { useAuth } from '~/components/AuthProvider'; import { Avatar } from '~/components/generic/Avatar'; import { Button } from '~/components/generic/Button'; @@ -12,6 +11,7 @@ import { InputText } from '~/components/generic/InputText'; import { Label } from '~/components/generic/Label'; import { ScrollArea } from '~/components/generic/ScrollArea'; import { Separator } from '~/components/generic/Separator'; +import { useGetUsers } from '~/services/users'; import { getUserDisplayNameReact } from '~/utils/common/getUserDisplayNameReact'; import styles from './SelectPlayerDialog.module.scss'; @@ -30,7 +30,7 @@ export const SelectPlayerDialog = ({ disabled = false, }: SelectMatchResultPlayerDialogProps): JSX.Element => { const user = useAuth(); - const users = useQuery(api.users.fetchUserList.fetchUserList, {}); + const { data: users } = useGetUsers({}); const selectableUsers = (users || []).filter((u) => u._id !== user?._id && userId !== u._id); const existingUser = (users || []).find((u) => u._id === userId); diff --git a/src/components/IdentityBadge/IdentityBadge.hooks.tsx b/src/components/IdentityBadge/IdentityBadge.hooks.tsx index 44d09420..24ebc8ec 100644 --- a/src/components/IdentityBadge/IdentityBadge.hooks.tsx +++ b/src/components/IdentityBadge/IdentityBadge.hooks.tsx @@ -1,10 +1,11 @@ import { ReactElement } from 'react'; -import { useQuery } from 'convex/react'; import { Ghost, HelpCircle } from 'lucide-react'; -import { api, TournamentCompetitor } from '~/api'; +import { TournamentCompetitor } from '~/api'; import { Avatar } from '~/components/generic/Avatar'; import { FlagCircle } from '~/components/generic/FlagCircle'; +import { useGetTournamentCompetitor } from '~/services/tournamentCompetitors'; +import { useGetUser } from '~/services/users'; import { getCountryName } from '~/utils/common/getCountryName'; import { getUserDisplayNameString } from '~/utils/common/getUserDisplayNameString'; import { Identity } from './IdentityBadge.types'; @@ -40,14 +41,8 @@ const getCompetitorDisplayName = (competitor: TournamentCompetitor): ReactElemen export const useIdentityElements = (identity: Identity, loading?: boolean): ReactElement[] => { const { user, userId, competitor, competitorId, placeholder } = identity; - // TODO: Replace with a service hook - const queryUser = useQuery(api.users.fetchUser.fetchUser, userId ? { - id: userId, - } : 'skip'); - // TODO: Replace with a service hook - const queryCompetitor = useQuery(api.tournamentCompetitors.getTournamentCompetitor, competitorId ? { - id: competitorId, - } : 'skip'); + const { data: queryUser } = useGetUser(userId ? { id: userId } : 'skip'); + const { data: queryCompetitor } = useGetTournamentCompetitor(competitorId ? { id: competitorId } : 'skip'); // Render loading skeleton if explicitly loading or an ID was provided and it is fetching if (loading || (userId && !queryUser) || competitorId && !queryCompetitor) { diff --git a/src/pages/SettingsPage/components/UserProfileForm/UserProfileForm.tsx b/src/pages/SettingsPage/components/UserProfileForm/UserProfileForm.tsx index 22324aa0..1a99a702 100644 --- a/src/pages/SettingsPage/components/UserProfileForm/UserProfileForm.tsx +++ b/src/pages/SettingsPage/components/UserProfileForm/UserProfileForm.tsx @@ -22,7 +22,9 @@ import styles from './UserProfileForm.module.scss'; export const UserProfileForm = (): JSX.Element => { const user = useAuth(); - const { mutation: updateUser } = useUpdateUser(); + const { mutation: updateUser } = useUpdateUser({ + successMessage: 'Profile updated!', + }); const form = useForm({ resolver: zodResolver(userProfileFormSchema), @@ -54,19 +56,17 @@ export const UserProfileForm = (): JSX.Element => { - {/*

About Name Privacy

You can configure if you want your name to be hidden, visible to friends, tournament organizers & participants, or public. A brief explanation of what these levels mean is as follows:

  • Hidden: No one but you can view your name.
  • -
  • Friends: Only you and users you've added as a friend can view your name.
  • + {/*
  • Friends: Only you and users you've added as a friend can view your name.
  • */}
  • Tournaments: Only you, and the participants and organizers of tournaments you attend can view your name.
  • Public: Your name will be publicly visible.

To everyone who can't view your name, your username will be displayed.

Keep in mind, some tournaments (such as the European Team Championship) require players to use their full name. If you register for a tournament which requires this, you will be prompted to increase your setting to 'Tournament' if it is set to 'Hidden' or 'Friends'.

- */} diff --git a/src/services/avatar/useUploadAvatar.ts b/src/services/avatar/useUploadAvatar.ts index a1ba80a4..c9120ade 100644 --- a/src/services/avatar/useUploadAvatar.ts +++ b/src/services/avatar/useUploadAvatar.ts @@ -52,7 +52,7 @@ export const useUploadAvatar = () => { const [isLoading, setLoading] = useState(false); const generateUploadUrl = useMutation(api.generateFileUploadUrl.generateFileUploadUrl); - const updateAvatar = useMutation(api.users.updateAvatar.updateAvatar); + const updateUser = useMutation(api.users.updateUser); return { uploadAvatar: async (userId: UserId, file: File) => { @@ -78,8 +78,8 @@ export const useUploadAvatar = () => { const { storageId } = await uploadResponse.json(); // Get Convex file ID - await updateAvatar({ - userId, + await updateUser({ + id: userId, avatarStorageId: storageId, }); } catch (error) { diff --git a/src/services/users.ts b/src/services/users.ts index 6ba7e82b..5f36c01a 100644 --- a/src/services/users.ts +++ b/src/services/users.ts @@ -2,8 +2,11 @@ import { api } from '~/api'; import { createMutationHook, createQueryHook } from '~/services/utils'; // Basic Queries -export const useGetUser = createQueryHook(api.users.fetchUser.fetchUser); -export const useGetUsers = createQueryHook(api.users.fetchUserList.fetchUserList); +export const useGetUser = createQueryHook(api.users.getUser); +export const useGetUsers = createQueryHook(api.users.getUsers); + +// Special Queries +export const useGetCurrentUser = createQueryHook(api.users.getCurrentUser); // Basic (C_UD) Mutations -export const useUpdateUser = createMutationHook(api.users.updateUser.updateUser); +export const useUpdateUser = createMutationHook(api.users.updateUser); From 85fcad111d2af563143b084f0efa9b435cd3c204 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 23 Jun 2025 22:28:28 +0200 Subject: [PATCH 06/21] feat: Improve default tab --- src/pages/TournamentDetailPage/TournamentDetailPage.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/pages/TournamentDetailPage/TournamentDetailPage.tsx b/src/pages/TournamentDetailPage/TournamentDetailPage.tsx index 26e27b27..d9c26eb2 100644 --- a/src/pages/TournamentDetailPage/TournamentDetailPage.tsx +++ b/src/pages/TournamentDetailPage/TournamentDetailPage.tsx @@ -74,6 +74,9 @@ export const TournamentDetailPage = (): JSX.Element => { } } if (tournament?.status === 'published') { + if (showInfoSidebar) { + return 'roster'; + } return 'info'; } return 'roster'; From 7262497f5a9ca604860bbd82b55c5506393d24ed Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 23 Jun 2025 22:28:49 +0200 Subject: [PATCH 07/21] feat: Improve styling --- .../TournamentInfoBlock.module.scss | 11 ++++++++--- .../TournamentInfoBlock/TournamentInfoBlock.tsx | 4 ++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/components/TournamentInfoBlock/TournamentInfoBlock.module.scss b/src/components/TournamentInfoBlock/TournamentInfoBlock.module.scss index 47eaba39..2a97ac8a 100644 --- a/src/components/TournamentInfoBlock/TournamentInfoBlock.module.scss +++ b/src/components/TournamentInfoBlock/TournamentInfoBlock.module.scss @@ -16,6 +16,7 @@ @include flex.row($yAlign: top, $gap: 0.5rem); @include text.ui; + overflow: hidden; min-width: 0; a { @@ -23,11 +24,15 @@ line-height: inherit; } + &_Content { + min-width: 0; + } + span { - overflow: hidden; + @include text.single-line; + display: block; - text-overflow: ellipsis; - text-wrap: nowrap; + min-width: 0; } svg { diff --git a/src/components/TournamentInfoBlock/TournamentInfoBlock.tsx b/src/components/TournamentInfoBlock/TournamentInfoBlock.tsx index 9c791df5..404b4be2 100644 --- a/src/components/TournamentInfoBlock/TournamentInfoBlock.tsx +++ b/src/components/TournamentInfoBlock/TournamentInfoBlock.tsx @@ -39,14 +39,14 @@ export const TournamentInfoBlock = ({ <>
-
+
{format(tournament.startsAt, 'd MMM yyy, p')} {format(tournament.endsAt, 'd MMM yyyy, p')}
-
+
{tournament.location.name} {tournament.location.placeFormatted}
From 44c9e3bfb310528c846da87ca2b017f01f264052 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 23 Jun 2025 22:29:02 +0200 Subject: [PATCH 08/21] Update mockData.ts --- convex/_model/utils/_helpers/mockData.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/convex/_model/utils/_helpers/mockData.ts b/convex/_model/utils/_helpers/mockData.ts index 880e9c22..2788338d 100644 --- a/convex/_model/utils/_helpers/mockData.ts +++ b/convex/_model/utils/_helpers/mockData.ts @@ -8,14 +8,17 @@ export const mockTournamentData = { // logoStorageId: v.optional(v.union(v.id('_storage'), v.null())), // bannerStorageId: v.optional(v.union(v.id('_storage'), v.null())), location: { - timeZone: '', - name: '', - placeFormatted: '', - coordinates: { - lat: 0, - lon: 0, - }, - countryCode: 'nl', + address: '', + city: 'Ann Arbor', + coordinates: { lat: 42.27859, lon: -83.739716 }, + countryCode: 'us', + district: 'Washtenaw County', + mapboxId: 'dXJuOm1ieHBsYzpKOTFNN0E', + name: 'University of Michigan', + placeFormatted: 'Ann Arbor, Michigan, United States', + postcode: '48109', + region: { code: 'mi', name: 'Michigan' }, + timeZone: 'America/Detroit', }, startsAt: '2025-06-21T09:00:00', endsAt:'2025-06-22T18:00:00', From c83a4e49b7dfcbb12a869d702a2718a316782036 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Wed, 25 Jun 2025 14:02:47 +0200 Subject: [PATCH 09/21] feat: Improve disabled state (#97) #94 --- .../generic/Accordion/AccordionItem.module.scss | 12 ++++++++---- .../generic/Accordion/AccordionItem.tsx | 17 ++++++++++------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/components/generic/Accordion/AccordionItem.module.scss b/src/components/generic/Accordion/AccordionItem.module.scss index 973e6062..10256714 100644 --- a/src/components/generic/Accordion/AccordionItem.module.scss +++ b/src/components/generic/Accordion/AccordionItem.module.scss @@ -13,17 +13,21 @@ overflow: hidden; &_Header { - @include flex.row($gap: 1rem); - @include variants.ghost; - - cursor: pointer; padding: calc(1rem - var(--border-width)); + &[data-enabled="true"] { + @include variants.ghost; + + cursor: pointer; + } + &_Chevron { place-self: center; width: 1.5rem; height: 1.5rem; } + + @include flex.row($gap: 1rem); } &_Content { diff --git a/src/components/generic/Accordion/AccordionItem.tsx b/src/components/generic/Accordion/AccordionItem.tsx index 5b874093..01ea648a 100644 --- a/src/components/generic/Accordion/AccordionItem.tsx +++ b/src/components/generic/Accordion/AccordionItem.tsx @@ -41,15 +41,18 @@ export const AccordionItem = ({
- - - + {!disabled && ( + + + + )} {header}
From 705a8540917777c6d57cce4fec40c17bd4537021 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Wed, 25 Jun 2025 14:03:41 +0200 Subject: [PATCH 10/21] feat: Hide completed pairings from match check-in (#96) #95 --- .../FowV4MatchResultForm/FowV4MatchResultForm.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/FowV4MatchResultForm/FowV4MatchResultForm.tsx b/src/components/FowV4MatchResultForm/FowV4MatchResultForm.tsx index 28fc20ed..8bffffbc 100644 --- a/src/components/FowV4MatchResultForm/FowV4MatchResultForm.tsx +++ b/src/components/FowV4MatchResultForm/FowV4MatchResultForm.tsx @@ -2,7 +2,11 @@ import { SubmitHandler, useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import clsx from 'clsx'; -import { MatchResultId, TournamentPairingId } from '~/api'; +import { + MatchResultId, + TournamentPairingId, + UserId, +} from '~/api'; import { useAuth } from '~/components/AuthProvider'; import { ConfirmationDialog, useConfirmationDialog } from '~/components/ConfirmationDialog'; import { FowV4MatchResultDetails } from '~/components/FowV4MatchResultDetails'; @@ -119,7 +123,7 @@ export const FowV4MatchResultForm = ({ const resultForOptions = [ { value: 'single', label: 'Single Match' }, - ...(tournamentPairings || []).map((pairing) => ({ + ...(tournamentPairings || []).filter((pairing) => pairing.matchResultsProgress.submitted < pairing.matchResultsProgress.required).map((pairing) => ({ value: pairing._id, label: getTournamentPairingDisplayName(pairing), })), @@ -128,6 +132,10 @@ export const FowV4MatchResultForm = ({ const handleChangeResultFor = (value?: SelectValue): void => { if (value) { setTournamentPairingId(value as TournamentPairingId); + if (user && value === 'single') { + form.setValue('player0UserId', user._id); + form.setValue('player1UserId', '' as UserId); + } } }; From 62d1e9599a1d68d44011995b4032a1cff97cb5c6 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Wed, 25 Jun 2025 14:29:33 +0200 Subject: [PATCH 11/21] bug: Preserve internal state (#98) #93 --- .../PairingsGridRow/PairingsGridRow.tsx | 3 +- .../PairingsGridRow/PairingsGridRow.utils.ts | 2 +- .../TournamentPairingsGrid.tsx | 100 +++++++++++++----- .../TournamentPairingsGrid.types.ts | 4 +- .../TournamentPairingsGrid.utils.ts | 32 +++--- .../TournamentPairingsGrid/index.ts | 7 +- .../ConfirmPairingsDialog.tsx | 3 +- .../components/PairingsStep/PairingsStep.tsx | 22 ++-- .../PairingsStep/PairingsStep.utils.ts | 41 +++++-- 9 files changed, 149 insertions(+), 65 deletions(-) diff --git a/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.tsx b/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.tsx index 0f3909c5..59f375bd 100644 --- a/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.tsx +++ b/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.tsx @@ -1,10 +1,11 @@ import { AnimatePresence, motion } from 'framer-motion'; import { CircleCheck, CircleX } from 'lucide-react'; -import { DraftTournamentPairing, TournamentCompetitorRanked } from '~/api'; +import { TournamentCompetitorRanked } from '~/api'; import { Draggable } from '../Draggable'; import { Droppable } from '../Droppable/Droppable'; import { PairableCompetitorCard } from '../PairableCompetitorCard'; +import { DraftTournamentPairing } from '../TournamentPairingsGrid.types'; import { checkDraftPairingIsValid } from './PairingsGridRow.utils'; import styles from './PairingsGridRow.module.scss'; diff --git a/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.utils.ts b/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.utils.ts index cd377314..a13691d4 100644 --- a/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.utils.ts +++ b/src/components/TournamentPairingsGrid/PairingsGridRow/PairingsGridRow.utils.ts @@ -1,4 +1,4 @@ -import { DraftTournamentPairing } from '~/api'; +import { DraftTournamentPairing } from '../TournamentPairingsGrid.types'; /** * Checks if a DraftPairing is valid. diff --git a/src/components/TournamentPairingsGrid/TournamentPairingsGrid.tsx b/src/components/TournamentPairingsGrid/TournamentPairingsGrid.tsx index 92e6588e..6d8b840b 100644 --- a/src/components/TournamentPairingsGrid/TournamentPairingsGrid.tsx +++ b/src/components/TournamentPairingsGrid/TournamentPairingsGrid.tsx @@ -1,5 +1,7 @@ import { + forwardRef, useEffect, + useImperativeHandle, useMemo, useState, } from 'react'; @@ -11,20 +13,22 @@ import { rectIntersection, } from '@dnd-kit/core'; import { restrictToWindowEdges } from '@dnd-kit/modifiers'; +import isEqual from 'fast-deep-equal'; import { AnimatePresence } from 'framer-motion'; -import { DraftTournamentPairing, TournamentCompetitorId } from '~/api'; +import { TournamentCompetitorId, TournamentCompetitorRanked } from '~/api'; import { Label } from '~/components/generic/Label'; -import { - buildGridState, - buildPairingResult, - convertPairingResultToCompetitorList, -} from '~/components/TournamentPairingsGrid/TournamentPairingsGrid.utils'; import { useTournament } from '~/components/TournamentProvider'; import { Draggable } from './Draggable'; import { Droppable } from './Droppable'; import { PairableCompetitorCard } from './PairableCompetitorCard'; import { PairingsGridRow } from './PairingsGridRow'; +import { DraftTournamentPairing, PairingsGridState } from './TournamentPairingsGrid.types'; +import { + buildGridState, + buildPairingResult, + convertPairingResultToCompetitorList, +} from './TournamentPairingsGrid.utils'; import styles from './TournamentPairingsGrid.module.scss'; @@ -45,22 +49,50 @@ const grabAnimationProps = { }; export interface TournamentPairingsGridProps { - value?: DraftTournamentPairing[]; + defaultValue?: DraftTournamentPairing[]; onChange: (value: DraftTournamentPairing[]) => void; } -export const TournamentPairingsGrid = ({ - value, +export interface TournamentPairingsGridHandle { + reset: (pairings: DraftTournamentPairing[]) => void; + isDirty: boolean; +} + +export const TournamentPairingsGrid = forwardRef(({ + defaultValue, onChange, -}: TournamentPairingsGridProps): JSX.Element => { +}: TournamentPairingsGridProps, ref): JSX.Element => { const tournament = useTournament(); const pairingIndexes = Array.from({ length: Math.ceil(tournament.maxCompetitors / 2) }, (_, i) => i); - // Store competitors with their opponentIds so we can check pairing validity - const competitors = useMemo(() => convertPairingResultToCompetitorList(value), [value]); - const state = useMemo(() => buildGridState(value), [value]); + // Store competitors with their opponentIds so we can check pairing validity: + const competitors = useMemo(() => convertPairingResultToCompetitorList(defaultValue), [defaultValue]); + // State: const [activeCompetitorId, setActiveCompetitorId] = useState(null); + const [gridState, setGridState] = useState(null); + + // Set internal state from parent: + useEffect(() => { + if (defaultValue && !gridState) { + setGridState(buildGridState(defaultValue)); + } + }, [defaultValue, gridState]); + + const pairingResult = useMemo(() => buildPairingResult(competitors, gridState), [competitors, gridState]); + const isDirty = !isEqual(defaultValue, pairingResult); + + // Emit change to parent components: + useEffect(() => { + onChange(pairingResult); + }, [pairingResult, onChange]); + + // Allow parent to reset and track dirty state: + useImperativeHandle(ref, () => ({ + reset: (pairings: DraftTournamentPairing[]): void => setGridState(buildGridState(pairings)), + pairingResult, + isDirty, + })); useEffect(() => { document.body.style.cursor = activeCompetitorId ? 'grabbing' : 'default'; @@ -76,28 +108,36 @@ export const TournamentPairingsGrid = ({ }; const handleDragEnd = ({ active, over }: DragEndEvent) => { - if (!over) { + if (!over || !gridState) { return; } setActiveCompetitorId(null); - const updatedInternalState = Object.entries(state).map(([pairingCompetitorId, slotId]) => { - if (pairingCompetitorId === active.id) { - return [pairingCompetitorId, over.id]; + setGridState(Object.entries(gridState).map(([competitorId, slotId]) => { + + // If this ID is the active one, we're dragging it. Set it's slotID to 'over': + if (competitorId === active.id) { + return [competitorId, over.id]; } + + // If this slot is the target, move its competitor to 'unpaired': if (slotId === over.id) { - return [pairingCompetitorId, 'unpaired']; + return [competitorId, 'unpaired']; } - return [pairingCompetitorId, slotId]; + + // Otherwise do nothing: + return [competitorId, slotId]; }).reduce((acc, [pairingCompetitorId, slotId]) => ({ ...acc, [pairingCompetitorId as TournamentCompetitorId]: slotId, - }), {}); - onChange(buildPairingResult(competitors, updatedInternalState)); + }), {})); }; - const fullPairings = (value || []).filter((pairing) => pairing[0] && pairing[1]); - const unpairedCompetitors = (value || []).filter((pairing) => !!pairing[0] && pairing[1] === null).map((pairing) => pairing[0]); + const unpairedCompetitors = competitors.filter((c) => gridState && gridState[c.id] === 'unpaired'); const activeCompetitor = competitors.find((c) => c.id === activeCompetitorId); + const gridStatePivoted = Object.entries(gridState ?? {}).reduce((acc, [competitorId, slotId]) => ({ + ...acc, + [slotId]: competitors.find((c) => c.id === competitorId), + }), {} as Record); return (
- {pairingIndexes.map((i) => ( - - ))} + {pairingIndexes.map((i) => { + const pairing: DraftTournamentPairing = [ + gridStatePivoted[`${i}_0`] ?? null, + gridStatePivoted[`${i}_1`] ?? null, + ]; + return ( + + ); + })}
@@ -148,4 +194,4 @@ export const TournamentPairingsGrid = ({ ); -}; +}); diff --git a/src/components/TournamentPairingsGrid/TournamentPairingsGrid.types.ts b/src/components/TournamentPairingsGrid/TournamentPairingsGrid.types.ts index 3e519e91..daf214a1 100644 --- a/src/components/TournamentPairingsGrid/TournamentPairingsGrid.types.ts +++ b/src/components/TournamentPairingsGrid/TournamentPairingsGrid.types.ts @@ -1,3 +1,5 @@ -import { TournamentCompetitorId } from '~/api'; +import { TournamentCompetitorId, TournamentCompetitorRanked } from '~/api'; export type PairingsGridState = Record; + +export type DraftTournamentPairing = [TournamentCompetitorRanked | null, TournamentCompetitorRanked | null]; diff --git a/src/components/TournamentPairingsGrid/TournamentPairingsGrid.utils.ts b/src/components/TournamentPairingsGrid/TournamentPairingsGrid.utils.ts index 2920f630..4baa4169 100644 --- a/src/components/TournamentPairingsGrid/TournamentPairingsGrid.utils.ts +++ b/src/components/TournamentPairingsGrid/TournamentPairingsGrid.utils.ts @@ -1,8 +1,5 @@ -import { - DraftTournamentPairing, - TournamentCompetitorId, - TournamentCompetitorRanked, -} from '~/api'; +import { TournamentCompetitorId, TournamentCompetitorRanked } from '~/api'; +import { DraftTournamentPairing, PairingsGridState } from './TournamentPairingsGrid.types'; export const convertPairingResultToCompetitorList = (draftPairings?: DraftTournamentPairing[]): TournamentCompetitorRanked[] => { if (!draftPairings) { @@ -25,22 +22,31 @@ export const buildGridState = (draftPairings?: DraftTournamentPairing[]): Record return {}; } return draftPairings.reduce((acc, pairing, i) => { - if (!pairing[1]) { + if (pairing[0] && !pairing[1]) { return { ...acc, [pairing[0].id]: 'unpaired', }; } - return { - ...acc, - [pairing[0].id]: `${i}_0`, - [pairing[1].id]: `${i}_1`, - }; + if (!pairing[0] && pairing[1]) { + return { + ...acc, + [pairing[1].id]: 'unpaired', + }; + } + if (pairing[0] && pairing[1]) { + return { + ...acc, + [pairing[0].id]: `${i}_0`, + [pairing[1].id]: `${i}_1`, + }; + } + return acc; }, {} as Record); }; -export const buildPairingResult = (competitors: TournamentCompetitorRanked[], state: Record): DraftTournamentPairing[] => { - if (!competitors?.length || !Object.keys(state).length) { +export const buildPairingResult = (competitors: TournamentCompetitorRanked[], state: PairingsGridState | null): DraftTournamentPairing[] => { + if (!competitors?.length || !state || !Object.keys(state).length) { return []; } const statefulCompetitors = competitors.map((competitor) => ({ diff --git a/src/components/TournamentPairingsGrid/index.ts b/src/components/TournamentPairingsGrid/index.ts index 954c5aee..0a628319 100644 --- a/src/components/TournamentPairingsGrid/index.ts +++ b/src/components/TournamentPairingsGrid/index.ts @@ -1 +1,6 @@ -export { TournamentPairingsGrid } from './TournamentPairingsGrid'; +export { + TournamentPairingsGrid, + type TournamentPairingsGridHandle, + type TournamentPairingsGridProps, +} from './TournamentPairingsGrid'; +export type { DraftTournamentPairing } from './TournamentPairingsGrid.types'; diff --git a/src/pages/TournamentAdvanceRoundPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.tsx b/src/pages/TournamentAdvanceRoundPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.tsx index 705d68cd..e2b93281 100644 --- a/src/pages/TournamentAdvanceRoundPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.tsx +++ b/src/pages/TournamentAdvanceRoundPage/components/ConfirmPairingsDialog/ConfirmPairingsDialog.tsx @@ -1,6 +1,7 @@ -import { DraftTournamentPairing, UnassignedTournamentPairing } from '~/api'; +import { UnassignedTournamentPairing } from '~/api'; import { ConfirmationDialog } from '~/components/ConfirmationDialog'; import { TournamentPairingRow } from '~/components/TournamentPairingRow'; +import { DraftTournamentPairing } from '~/components/TournamentPairingsGrid'; import { convertDraftPairingsToUnassignedPairings } from '../PairingsStep/PairingsStep.utils'; import styles from './ConfirmPairingsDialog.module.scss'; diff --git a/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.tsx b/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.tsx index a63bbfa5..3e02f25b 100644 --- a/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.tsx +++ b/src/pages/TournamentAdvanceRoundPage/components/PairingsStep/PairingsStep.tsx @@ -2,12 +2,11 @@ import { forwardRef, useEffect, useImperativeHandle, + useRef, useState, } from 'react'; -import isEqual from 'fast-deep-equal'; import { - DraftTournamentPairing, TournamentPairingMethod, tournamentPairingMethodOptions, UnassignedTournamentPairing, @@ -17,7 +16,11 @@ import { Button } from '~/components/generic/Button'; import { InputSelect } from '~/components/generic/InputSelect'; import { Label } from '~/components/generic/Label'; import { Separator } from '~/components/generic/Separator'; -import { TournamentPairingsGrid } from '~/components/TournamentPairingsGrid'; +import { + DraftTournamentPairing, + TournamentPairingsGrid, + TournamentPairingsGridHandle, +} from '~/components/TournamentPairingsGrid'; import { useTournament } from '~/components/TournamentProvider'; import { useGetDraftTournamentPairings } from '~/services/tournamentPairings'; import { ConfirmPairingsDialog, confirmPairingsDialogId } from '../ConfirmPairingsDialog'; @@ -39,7 +42,7 @@ export interface PairingsStepHandle { export const PairingsStep = forwardRef(({ nextRound, onConfirm, -}: PairingsStepProps, ref) => { +}: PairingsStepProps, ref): JSX.Element => { const tournament = useTournament(); // Pairing state @@ -51,14 +54,15 @@ export const PairingsStep = forwardRef(({ round: nextRound, method: pairingMethod, }); - const [manualPairings, setManualPairings] = useState(draftPairingResults); + const [manualPairings, setManualPairings] = useState(); useEffect(() => { if (draftPairingResults) { setManualPairings(draftPairingResults); } }, [draftPairingResults]); - const isDirty = manualPairings && !isEqual(manualPairings, draftPairingResults); + const pairingsGridRef = useRef(null); + const isDirty = pairingsGridRef.current?.isDirty ?? false; const { open: openChangePairingMethodConfirmDialog } = useConfirmationDialog(changePairingMethodConfirmDialogId); const { open: openResetPairingsConfirmDialog } = useConfirmationDialog(resetPairingsConfirmDialogId); @@ -78,7 +82,7 @@ export const PairingsStep = forwardRef(({ if (draftPairingResults) { if (isDirty) { openResetPairingsConfirmDialog({ - onConfirm: () => setManualPairings(draftPairingResults), + onConfirm: () => pairingsGridRef.current?.reset(draftPairingResults), }); } else { setManualPairings(draftPairingResults); @@ -102,12 +106,12 @@ export const PairingsStep = forwardRef(({ value={pairingMethod} disabled={isFirstRound} /> -
- + [ - ...draftTournamentPairings.map((tournamentPairing) => ({ - tournamentCompetitor0Id: tournamentPairing[0].id, - tournamentCompetitor1Id: tournamentPairing[1]?.id ?? null, - playedTables: Array.from(new Set([ - ...tournamentPairing[0].playedTables, - ...(tournamentPairing[1]?.playedTables || []), - ])), - })), -]; +): UnassignedTournamentPairing[] => draftTournamentPairings.filter(([a, b]) => a || b).map(([a, b]) => { + const playedTables = Array.from( + new Set([ + ...(a?.playedTables ?? []), + ...(b?.playedTables ?? []), + ]), + ); + if (a && !b) { + return { + tournamentCompetitor0Id: a.id, + tournamentCompetitor1Id: null, + playedTables, + }; + } + if (!a && b) { + return { + tournamentCompetitor0Id: b.id, + tournamentCompetitor1Id: null, + playedTables, + }; + } + // We've filtered out pairings with no competitors, and handled the one-sided ones above: + return { + tournamentCompetitor0Id: a!.id, + tournamentCompetitor1Id: b!.id, + playedTables, + }; +}); From e2aa511a2e097ce0433ae2231d3c91a60c70d46c Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 30 Jun 2025 16:50:13 +0200 Subject: [PATCH 12/21] feat: #101 Add player count to roster (#103) --- .../TournamentRoster.module.scss | 23 +++++++++++----- .../TournamentRoster/TournamentRoster.tsx | 10 ++++--- .../CompetitorActions.module.scss | 2 +- .../CompetitorActions/CompetitorActions.tsx | 5 +++- .../PlayerCount/PlayerCount.module.scss | 12 +++++++++ .../components/PlayerCount/PlayerCount.tsx | 26 +++++++++++++++++++ .../components/PlayerCount/index.ts | 2 ++ 7 files changed, 69 insertions(+), 11 deletions(-) create mode 100644 src/components/TournamentRoster/components/PlayerCount/PlayerCount.module.scss create mode 100644 src/components/TournamentRoster/components/PlayerCount/PlayerCount.tsx create mode 100644 src/components/TournamentRoster/components/PlayerCount/index.ts diff --git a/src/components/TournamentRoster/TournamentRoster.module.scss b/src/components/TournamentRoster/TournamentRoster.module.scss index fc2038af..548343ef 100644 --- a/src/components/TournamentRoster/TournamentRoster.module.scss +++ b/src/components/TournamentRoster/TournamentRoster.module.scss @@ -8,14 +8,25 @@ .TournamentRoster { &_Header { - @include flex.row; - @include flex.stretchy; + display: grid; + grid-template-areas: "Identity PlayerCount Actions"; + grid-template-columns: 1fr auto auto; + grid-template-rows: 2.5rem; + gap: 1rem; - &_Actions { - @include flex.row; + width: 100%; + } + + &_Identity { + grid-area: Identity; + } + + &_PlayerCount { + grid-area: PlayerCount; + } - margin-left: auto; - } + &_Actions { + grid-area: Actions; } &_Content { diff --git a/src/components/TournamentRoster/TournamentRoster.tsx b/src/components/TournamentRoster/TournamentRoster.tsx index e6156db9..5f2256c3 100644 --- a/src/components/TournamentRoster/TournamentRoster.tsx +++ b/src/components/TournamentRoster/TournamentRoster.tsx @@ -4,6 +4,7 @@ import { TournamentCompetitorEditDialog } from '~/components/TournamentCompetito import { useTournamentCompetitors } from '~/components/TournamentCompetitorsProvider'; import { useTournament } from '~/components/TournamentProvider'; import { CompetitorActions } from '~/components/TournamentRoster/components/CompetitorActions'; +import { PlayerCount } from '~/components/TournamentRoster/components/PlayerCount'; import styles from './TournamentRoster.module.scss'; @@ -14,7 +15,7 @@ export interface TournamentRosterProps { export const TournamentRoster = ({ className, }: TournamentRosterProps): JSX.Element => { - const { useTeams } = useTournament(); + const { useTeams, competitorSize } = useTournament(); const competitors = useTournamentCompetitors(); return ( <> @@ -22,8 +23,11 @@ export const TournamentRoster = ({ {(competitors || []).map((competitor) => (
- - + + {useTeams && ( + + )} +
{competitor.players.filter((player) => player.active).map((player) => ( diff --git a/src/components/TournamentRoster/components/CompetitorActions/CompetitorActions.module.scss b/src/components/TournamentRoster/components/CompetitorActions/CompetitorActions.module.scss index 09e4aabc..4efbf490 100644 --- a/src/components/TournamentRoster/components/CompetitorActions/CompetitorActions.module.scss +++ b/src/components/TournamentRoster/components/CompetitorActions/CompetitorActions.module.scss @@ -9,5 +9,5 @@ .CompetitorActions { @include flex.row; - margin-left: auto; + flex-shrink: 0; } diff --git a/src/components/TournamentRoster/components/CompetitorActions/CompetitorActions.tsx b/src/components/TournamentRoster/components/CompetitorActions/CompetitorActions.tsx index 25137975..2f066843 100644 --- a/src/components/TournamentRoster/components/CompetitorActions/CompetitorActions.tsx +++ b/src/components/TournamentRoster/components/CompetitorActions/CompetitorActions.tsx @@ -1,4 +1,5 @@ import { MouseEvent } from 'react'; +import clsx from 'clsx'; import { Ellipsis, UserPlus } from 'lucide-react'; import { TournamentCompetitor } from '~/api'; @@ -20,10 +21,12 @@ import { import styles from './CompetitorActions.module.scss'; export interface CompetitorActionsProps { + className?: string; competitor: TournamentCompetitor; } export const CompetitorActions = ({ + className, competitor, }: CompetitorActionsProps): JSX.Element => { const user = useAuth(); @@ -94,7 +97,7 @@ export const CompetitorActions = ({ return ( <> -
+
{showCheckInToggle && ( <> diff --git a/src/components/TournamentRoster/components/PlayerCount/PlayerCount.module.scss b/src/components/TournamentRoster/components/PlayerCount/PlayerCount.module.scss new file mode 100644 index 00000000..e6c4b647 --- /dev/null +++ b/src/components/TournamentRoster/components/PlayerCount/PlayerCount.module.scss @@ -0,0 +1,12 @@ +@use "/src/style/flex"; +@use "/src/style/text"; + +.PlayerCount { + @include flex.row($gap: 0.25rem); + @include text.ui($muted: true); + + svg { + width: 1rem; + height: 1rem; + } +} diff --git a/src/components/TournamentRoster/components/PlayerCount/PlayerCount.tsx b/src/components/TournamentRoster/components/PlayerCount/PlayerCount.tsx new file mode 100644 index 00000000..c5258d6c --- /dev/null +++ b/src/components/TournamentRoster/components/PlayerCount/PlayerCount.tsx @@ -0,0 +1,26 @@ +import clsx from 'clsx'; +import { Users } from 'lucide-react'; + +import { TournamentCompetitor } from '~/api'; + +import styles from './PlayerCount.module.scss'; + +export interface PlayerCountProps { + className?: string; + competitor: TournamentCompetitor; + competitorSize: number; +} + +export const PlayerCount = ({ + className, + competitor, + competitorSize, +}: PlayerCountProps): JSX.Element => { + const playerCount = (competitor.players ?? []).filter((p) => p.active).length; + return ( +
+ + {`${playerCount}/${competitorSize}`} +
+ ); +}; diff --git a/src/components/TournamentRoster/components/PlayerCount/index.ts b/src/components/TournamentRoster/components/PlayerCount/index.ts new file mode 100644 index 00000000..6e47b7b8 --- /dev/null +++ b/src/components/TournamentRoster/components/PlayerCount/index.ts @@ -0,0 +1,2 @@ +export type { PlayerCountProps } from './PlayerCount'; +export { PlayerCount } from './PlayerCount'; From 164229baa430599a9790e6b8ff4b9671678abb61 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 30 Jun 2025 21:46:57 +0200 Subject: [PATCH 13/21] feat: Show full player names when tournaments require it --- convex/_generated/api.d.ts | 2 ++ .../_helpers/checkUserTournamentForcedName.ts | 30 +++++++++++++++++++ convex/_model/users/_helpers/redactUser.ts | 7 ++++- 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 convex/_model/users/_helpers/checkUserTournamentForcedName.ts diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 11df2d20..35ab2830 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -126,6 +126,7 @@ import type * as _model_tournaments_queries_getTournamentRankings from "../_mode import type * as _model_tournaments_queries_getTournaments from "../_model/tournaments/queries/getTournaments.js"; import type * as _model_tournaments_queries_getTournamentsByStatus from "../_model/tournaments/queries/getTournamentsByStatus.js"; import type * as _model_users__helpers_checkUserAuth from "../_model/users/_helpers/checkUserAuth.js"; +import type * as _model_users__helpers_checkUserTournamentForcedName from "../_model/users/_helpers/checkUserTournamentForcedName.js"; import type * as _model_users__helpers_checkUserTournamentRelationship from "../_model/users/_helpers/checkUserTournamentRelationship.js"; import type * as _model_users__helpers_getShallowUser from "../_model/users/_helpers/getShallowUser.js"; import type * as _model_users__helpers_redactUser from "../_model/users/_helpers/redactUser.js"; @@ -308,6 +309,7 @@ declare const fullApi: ApiFromModules<{ "_model/tournaments/queries/getTournaments": typeof _model_tournaments_queries_getTournaments; "_model/tournaments/queries/getTournamentsByStatus": typeof _model_tournaments_queries_getTournamentsByStatus; "_model/users/_helpers/checkUserAuth": typeof _model_users__helpers_checkUserAuth; + "_model/users/_helpers/checkUserTournamentForcedName": typeof _model_users__helpers_checkUserTournamentForcedName; "_model/users/_helpers/checkUserTournamentRelationship": typeof _model_users__helpers_checkUserTournamentRelationship; "_model/users/_helpers/getShallowUser": typeof _model_users__helpers_getShallowUser; "_model/users/_helpers/redactUser": typeof _model_users__helpers_redactUser; diff --git a/convex/_model/users/_helpers/checkUserTournamentForcedName.ts b/convex/_model/users/_helpers/checkUserTournamentForcedName.ts new file mode 100644 index 00000000..5a0a62be --- /dev/null +++ b/convex/_model/users/_helpers/checkUserTournamentForcedName.ts @@ -0,0 +1,30 @@ +import { Id } from '../../../_generated/dataModel'; +import { QueryCtx } from '../../../_generated/server'; +import { getTournamentUserIds } from '../../../_model/tournaments'; + +export const checkUserTournamentForcedName = async ( + ctx: QueryCtx, + userIdA?: Id<'users'> | null, + userIdB?: Id<'users'> | null, +): Promise => { + if (!userIdA || !userIdB) { + return false; + } + + const tournaments = await ctx.db.query('tournaments').collect(); + + // Check each tournament for a relationship, return true if one is found + return tournaments.some(async ({ _id, organizerUserIds, requireRealNames }) => { + + const playerUserIds = await getTournamentUserIds(ctx, _id); + + // Merge all organizer IDs and player IDs into one set + const allTournamentUserIds = new Set([ + ...organizerUserIds, + ...playerUserIds, + ]); + + // If the set contains both user IDs, they were at the same tournament + return allTournamentUserIds.has(userIdA) && allTournamentUserIds.has(userIdB) && requireRealNames; + }); +}; diff --git a/convex/_model/users/_helpers/redactUser.ts b/convex/_model/users/_helpers/redactUser.ts index d3e001ef..961820b9 100644 --- a/convex/_model/users/_helpers/redactUser.ts +++ b/convex/_model/users/_helpers/redactUser.ts @@ -3,6 +3,7 @@ import { getAuthUserId } from '@convex-dev/auth/server'; import { Doc } from '../../../_generated/dataModel'; import { QueryCtx } from '../../../_generated/server'; import { getStorageUrl } from '../../common/_helpers/getStorageUrl'; +import { checkUserTournamentForcedName } from './checkUserTournamentForcedName'; import { checkUserTournamentRelationship } from './checkUserTournamentRelationship'; /** @@ -51,11 +52,15 @@ export const redactUser = async ( // If user is querying someone they are in a tournament with const hasTournamentRelationship = await checkUserTournamentRelationship(ctx, userId, user._id); + // If user is querying someone they are in a tournament with which requires real names + const requiredByTournament = await checkUserTournamentForcedName(ctx, userId, user._id); + // Add name information if allowed if ( (user?.nameVisibility === 'public') || (user?.nameVisibility === 'friends' && hasFriendRelationship) || - (user?.nameVisibility === 'tournaments' && (hasFriendRelationship || hasTournamentRelationship)) + (user?.nameVisibility === 'tournaments' && (hasFriendRelationship || hasTournamentRelationship)) || + requiredByTournament ) { limitedUser.givenName = user.givenName; limitedUser.familyName = user.familyName; From 8caccb1b4ac6b1ae7a01ea3538943647133e63f4 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 30 Jun 2025 21:47:21 +0200 Subject: [PATCH 14/21] feat: Add activePlayerCount to deep tournaments --- convex/_model/tournaments/_helpers/deepenTournament.ts | 2 ++ src/components/TournamentInfoBlock/TournamentInfoBlock.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/convex/_model/tournaments/_helpers/deepenTournament.ts b/convex/_model/tournaments/_helpers/deepenTournament.ts index d9ff7f74..e58a7098 100644 --- a/convex/_model/tournaments/_helpers/deepenTournament.ts +++ b/convex/_model/tournaments/_helpers/deepenTournament.ts @@ -37,6 +37,7 @@ export const deepenTournament = async ( // Computed properties (easy to do, but used so frequently, it's nice to include them by default) const playerCount = playerUserIds.length; + const activePlayerCount = activePlayerUserIds.length; const maxPlayers = tournament.maxCompetitors * tournament.competitorSize; const useTeams = tournament.competitorSize > 1; @@ -45,6 +46,7 @@ export const deepenTournament = async ( logoUrl, bannerUrl, competitorCount, + activePlayerCount, playerCount, playerUserIds, activePlayerUserIds, diff --git a/src/components/TournamentInfoBlock/TournamentInfoBlock.tsx b/src/components/TournamentInfoBlock/TournamentInfoBlock.tsx index 404b4be2..f1d34603 100644 --- a/src/components/TournamentInfoBlock/TournamentInfoBlock.tsx +++ b/src/components/TournamentInfoBlock/TournamentInfoBlock.tsx @@ -53,7 +53,7 @@ export const TournamentInfoBlock = ({
- {`${tournament.playerCount} / ${tournament.maxPlayers}`} + {`${tournament.activePlayerCount} / ${tournament.maxPlayers}`} {tournament.useTeams && ( {`(${tournament.competitorCount} / ${tournament.maxCompetitors} teams)`} )} From 6a6d187154f203546c15b6da378c705a41f514ec Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 30 Jun 2025 21:47:42 +0200 Subject: [PATCH 15/21] feat: Sort tournament competitors by name --- .../queries/getTournamentCompetitors.ts | 17 ++++++++++++++++- .../getTournamentCompetitorsByTournament.ts | 17 ++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/convex/_model/tournamentCompetitors/queries/getTournamentCompetitors.ts b/convex/_model/tournamentCompetitors/queries/getTournamentCompetitors.ts index 141db622..479846f8 100644 --- a/convex/_model/tournamentCompetitors/queries/getTournamentCompetitors.ts +++ b/convex/_model/tournamentCompetitors/queries/getTournamentCompetitors.ts @@ -5,7 +5,22 @@ export const getTournamentCompetitors = async ( ctx: QueryCtx, ): Promise => { const tournamentCompetitors = await ctx.db.query('tournamentCompetitors').collect(); - return await Promise.all(tournamentCompetitors.map( + const deepTournamentCompetitors = await Promise.all(tournamentCompetitors.map( async (item) => await deepenTournamentCompetitor(ctx, item), )); + return deepTournamentCompetitors.sort((a, b) => { + const getSortValue = (competitor: DeepTournamentCompetitor): string => { + if (competitor.teamName) { + return competitor.teamName; + } + if (competitor.players[0].user.familyName) { + return competitor.players[0].user.familyName; + } + if (competitor.players[0].user.username) { + return competitor.players[0].user.username; + } + return ''; + }; + return getSortValue(a).localeCompare(getSortValue(b)); + }); }; diff --git a/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts b/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts index c94ace79..fdc7fe50 100644 --- a/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts +++ b/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts @@ -14,7 +14,22 @@ export const getTournamentCompetitorsByTournament = async ( const tournamentCompetitors = await ctx.db.query('tournamentCompetitors') .withIndex('by_tournament_id', (q) => q.eq('tournamentId', args.tournamentId)) .collect(); - return await Promise.all(tournamentCompetitors.map( + const deepTournamentCompetitors = await Promise.all(tournamentCompetitors.map( async (item) => await deepenTournamentCompetitor(ctx, item), )); + return deepTournamentCompetitors.sort((a, b) => { + const getSortValue = (competitor: DeepTournamentCompetitor): string => { + if (competitor.teamName) { + return competitor.teamName; + } + if (competitor.players[0].user.familyName) { + return competitor.players[0].user.familyName; + } + if (competitor.players[0].user.username) { + return competitor.players[0].user.username; + } + return ''; + }; + return getSortValue(a).localeCompare(getSortValue(b)); + }); }; From 89594e9d8fbc0d87d044ee03465e43a21f51e232 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 30 Jun 2025 22:02:45 +0200 Subject: [PATCH 16/21] task: Clean-up .card mixin (#102) #100 --- src/components/AccountMenu/AccountMenu.module.scss | 5 +---- .../AvatarEditable/AvatarEditable.module.scss | 5 +---- .../MatchResultCard/MatchResultCard.module.scss | 3 --- .../MatchResultPlayers/MatchResultPlayers.module.scss | 5 +---- .../ToastProvider/ToastProvider.module.scss | 3 +-- .../TournamentCard/TournamentCard.module.scss | 2 -- src/components/generic/Card/Card.module.scss | 3 --- .../generic/DataTable/DataTable.module.scss | 5 +---- .../generic/InputDateTime/InputDateTime.module.scss | 3 +-- .../generic/InputLocation/InputLocation.module.scss | 3 +-- .../generic/PopoverMenu/PopoverMenu.module.scss | 4 +--- src/pages/TournamentsPage/TournamentsPage.module.scss | 5 +---- src/style/_variants.scss | 11 +++++++++-- 13 files changed, 18 insertions(+), 39 deletions(-) diff --git a/src/components/AccountMenu/AccountMenu.module.scss b/src/components/AccountMenu/AccountMenu.module.scss index ed3c6f2e..dbe7af09 100644 --- a/src/components/AccountMenu/AccountMenu.module.scss +++ b/src/components/AccountMenu/AccountMenu.module.scss @@ -16,14 +16,11 @@ .Content { @include flex.column($gap: 0.25rem); + @include variants.card($elevated: true); @include animate.duration-quick; - @include corners.normal; - @include shadows.elevated; - @include variants.card; margin: 0.25rem 0; padding: 0.25rem; - background-color: var(--card-bg); @include animate.style-pop; // Must list last because it contains nested declarations } diff --git a/src/components/AvatarEditable/AvatarEditable.module.scss b/src/components/AvatarEditable/AvatarEditable.module.scss index 58bf7841..c05ad7ac 100644 --- a/src/components/AvatarEditable/AvatarEditable.module.scss +++ b/src/components/AvatarEditable/AvatarEditable.module.scss @@ -25,14 +25,11 @@ .ActionsMenu { @include flex.column($gap: 0.25rem); @include animate.duration-quick; - @include corners.normal; - @include shadows.elevated; - @include variants.card; + @include variants.card($elevated: true); z-index: 10000; margin: 0.25rem 0; padding: 0.25rem; - background-color: var(--card-bg); @include animate.style-pop; // Must list last because it contains nested declarations } diff --git a/src/components/MatchResultCard/MatchResultCard.module.scss b/src/components/MatchResultCard/MatchResultCard.module.scss index 93b2a24e..44f51d22 100644 --- a/src/components/MatchResultCard/MatchResultCard.module.scss +++ b/src/components/MatchResultCard/MatchResultCard.module.scss @@ -9,10 +9,7 @@ .MatchResultCard { @include flex.column($gap: 0); - @include variants.card; - @include shadows.surface; - @include corners.normal; position: relative; overflow: hidden; diff --git a/src/components/MatchResultPlayers/MatchResultPlayers.module.scss b/src/components/MatchResultPlayers/MatchResultPlayers.module.scss index f2268fae..7e32cacf 100644 --- a/src/components/MatchResultPlayers/MatchResultPlayers.module.scss +++ b/src/components/MatchResultPlayers/MatchResultPlayers.module.scss @@ -109,13 +109,10 @@ } .FactionDetails { - @include variants.card; - @include shadows.elevated; + @include variants.card($elevated: true); @include animate.duration-quick; @include animate.style-pop; @include flex.column($gap: 0); margin: 0.25rem 0; - background-color: var(--card-bg); - border-radius: variables.$corner-radius; } diff --git a/src/components/ToastProvider/ToastProvider.module.scss b/src/components/ToastProvider/ToastProvider.module.scss index 515e06a8..838f09b3 100644 --- a/src/components/ToastProvider/ToastProvider.module.scss +++ b/src/components/ToastProvider/ToastProvider.module.scss @@ -26,8 +26,7 @@ .Root { --icon-size: 1.25rem; - @include corners.wide; - @include variants.card; + @include variants.card($elevated: true); @include text.small; display: flex; diff --git a/src/components/TournamentCard/TournamentCard.module.scss b/src/components/TournamentCard/TournamentCard.module.scss index 154e3174..ef3f2706 100644 --- a/src/components/TournamentCard/TournamentCard.module.scss +++ b/src/components/TournamentCard/TournamentCard.module.scss @@ -12,8 +12,6 @@ --banner-wide-size: 14rem; @include variants.card; - @include shadows.surface; - @include corners.normal; @include text.ui; position: relative; diff --git a/src/components/generic/Card/Card.module.scss b/src/components/generic/Card/Card.module.scss index 06b28ac2..a38e3dd4 100644 --- a/src/components/generic/Card/Card.module.scss +++ b/src/components/generic/Card/Card.module.scss @@ -8,11 +8,8 @@ .Card { @include flex.column($gap: 0); @include variants.card; - @include shadows.surface; - @include corners.normal; @include flex.stretchy; min-height: 0; max-height: 100%; - background-color: var(--card-bg); } diff --git a/src/components/generic/DataTable/DataTable.module.scss b/src/components/generic/DataTable/DataTable.module.scss index 88c21621..bfd4bbf8 100644 --- a/src/components/generic/DataTable/DataTable.module.scss +++ b/src/components/generic/DataTable/DataTable.module.scss @@ -27,14 +27,11 @@ $row-height: 3rem; } .PopoverContent { - @include shadows.elevated; @include animate.duration-quick; - @include variants.card; + @include variants.card($elevated: true); margin: 0.25rem 0; padding: 1rem; - background-color: var(--card-bg); - border-radius: variables.$corner-radius; @include animate.style-pop; // Must list last because it contains nested declarations } diff --git a/src/components/generic/InputDateTime/InputDateTime.module.scss b/src/components/generic/InputDateTime/InputDateTime.module.scss index e8c25663..3d4c364e 100644 --- a/src/components/generic/InputDateTime/InputDateTime.module.scss +++ b/src/components/generic/InputDateTime/InputDateTime.module.scss @@ -20,8 +20,7 @@ } .Content { - @include variants.card; - @include shadows.elevated; + @include variants.card($elevated: true); @include animate.duration-quick; @include animate.style-pop; @include flex.column($gap: 0); diff --git a/src/components/generic/InputLocation/InputLocation.module.scss b/src/components/generic/InputLocation/InputLocation.module.scss index a88ed4cb..ef0e349b 100644 --- a/src/components/generic/InputLocation/InputLocation.module.scss +++ b/src/components/generic/InputLocation/InputLocation.module.scss @@ -6,8 +6,7 @@ @use "/src/style/flex"; .InputDatePopoverContent { - @include variants.card; - @include shadows.elevated; + @include variants.card($elevated: true); @include animate.duration-quick; @include animate.style-pop; diff --git a/src/components/generic/PopoverMenu/PopoverMenu.module.scss b/src/components/generic/PopoverMenu/PopoverMenu.module.scss index 7ecad3cd..862c84d1 100644 --- a/src/components/generic/PopoverMenu/PopoverMenu.module.scss +++ b/src/components/generic/PopoverMenu/PopoverMenu.module.scss @@ -10,9 +10,7 @@ .PopoverContent { @include flex.column($gap: 0.25rem); @include animate.duration-quick; - @include corners.normal; - @include shadows.elevated; - @include variants.card; + @include variants.card($elevated: true); z-index: 1; margin: 0.25rem 0; diff --git a/src/pages/TournamentsPage/TournamentsPage.module.scss b/src/pages/TournamentsPage/TournamentsPage.module.scss index 2c656ae7..64fe4150 100644 --- a/src/pages/TournamentsPage/TournamentsPage.module.scss +++ b/src/pages/TournamentsPage/TournamentsPage.module.scss @@ -11,15 +11,12 @@ } .FilterPopover { - @include variants.card; - @include shadows.elevated; + @include variants.card($elevated: true); @include animate.duration-quick; @include animate.style-pop; margin: 0.25rem 0; padding: 1rem; - background-color: var(--card-bg); - border-radius: variables.$corner-radius; } .List { diff --git a/src/style/_variants.scss b/src/style/_variants.scss index df383775..f840d409 100644 --- a/src/style/_variants.scss +++ b/src/style/_variants.scss @@ -1,5 +1,7 @@ @use "variables"; @use "/src/style/borders"; +@use "/src/style/corners"; +@use "/src/style/shadows"; // TODO: Move to utils @mixin reset { @@ -190,9 +192,14 @@ } } -@mixin card { +@mixin card($elevated: false) { @include borders.normal; + @include corners.normal; + @include shadows.surface; background-color: var(--card-bg); - border-color: var(--border-color-default); + + @if $elevated { + @include shadows.elevated; + } } From 06ecd9d423e404fb4d488b8b60b1fca3e10a024f Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 30 Jun 2025 22:17:21 +0200 Subject: [PATCH 17/21] feat: #99 Improve tournament competitor edit dialog (#104) --- .../TournamentCompetitorEditDialog.tsx | 22 +++++---- .../TournamentCompetitorForm.module.scss | 4 ++ .../TournamentCompetitorForm.schema.ts | 45 ++++++++----------- .../TournamentCompetitorForm.tsx | 40 ++++++++++++++--- .../CompetitorActions/CompetitorActions.tsx | 6 +-- 5 files changed, 73 insertions(+), 44 deletions(-) diff --git a/src/components/TournamentCompetitorEditDialog/TournamentCompetitorEditDialog.tsx b/src/components/TournamentCompetitorEditDialog/TournamentCompetitorEditDialog.tsx index fd36e82a..e7870637 100644 --- a/src/components/TournamentCompetitorEditDialog/TournamentCompetitorEditDialog.tsx +++ b/src/components/TournamentCompetitorEditDialog/TournamentCompetitorEditDialog.tsx @@ -4,6 +4,7 @@ import { DialogActions, DialogHeader, } from '~/components/generic/Dialog'; +import { ScrollArea } from '~/components/generic/ScrollArea'; import { TournamentCompetitorForm, TournamentCompetitorSubmitData } from '~/components/TournamentCompetitorForm'; import { useUpdateTournamentCompetitor } from '~/services/tournamentCompetitors'; import { useTournamentCompetitorEditDialog } from './TournamentCompetitorEditDialog.hooks'; @@ -25,22 +26,27 @@ export const TournamentCompetitorEditDialog = (): JSX.Element => { if (!data) { return; } + + const { players, ...restData } = formData; updateTournamentCompetitor({ id: data?.tournamentCompetitor._id, - ...formData, + ...restData, + players: players.filter((player) => player.userId), }); }; return ( - + + + diff --git a/src/components/TournamentCompetitorForm/TournamentCompetitorForm.module.scss b/src/components/TournamentCompetitorForm/TournamentCompetitorForm.module.scss index c5348d3d..f35e4bff 100644 --- a/src/components/TournamentCompetitorForm/TournamentCompetitorForm.module.scss +++ b/src/components/TournamentCompetitorForm/TournamentCompetitorForm.module.scss @@ -11,6 +11,10 @@ row-gap: 0.5rem; column-gap: 1rem; align-items: center; + + &_AddButton { + grid-column: 1/-1; + } } &_SinglePlayer { diff --git a/src/components/TournamentCompetitorForm/TournamentCompetitorForm.schema.ts b/src/components/TournamentCompetitorForm/TournamentCompetitorForm.schema.ts index 299dc51d..31563df4 100644 --- a/src/components/TournamentCompetitorForm/TournamentCompetitorForm.schema.ts +++ b/src/components/TournamentCompetitorForm/TournamentCompetitorForm.schema.ts @@ -17,21 +17,19 @@ export const createSchema = ( ), }).superRefine((data, ctx) => { const activeCount = data.players.filter((player) => player.active).length; - if (status === 'active') { - if (activeCount < competitorSize) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `At least ${competitorSize} player(s) must be active.`, - path: ['players'], - }); - } - if (activeCount > competitorSize) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Only ${competitorSize} player(s) may be active.`, - path: ['players'], - }); - } + if (activeCount < competitorSize && status === 'active') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `At least ${competitorSize} players must be active.`, + path: ['players'], + }); + } + if (activeCount > competitorSize) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Only ${competitorSize} players may be active.`, + path: ['players'], + }); } if (otherCompetitors.find((c) => c.teamName?.toLowerCase() === data.teamName.trim().toLowerCase())) { ctx.addIssue({ @@ -49,17 +47,10 @@ export const defaultValues: DeepPartial = { players: [], }; -export const getDefaultValues = (competitorSize: number, existingCompetitor?: TournamentCompetitor): DeepPartial => { - const emptyPlayers = Array.from({ length: competitorSize }, () => ({ - active: true, - userId: '', - })); - const existingPlayers = (existingCompetitor?.players || []).map(({ active, user }) => ({ +export const getDefaultValues = (competitorSize: number, existingCompetitor?: TournamentCompetitor): DeepPartial => ({ + teamName: existingCompetitor?.teamName ?? '', + players: (existingCompetitor?.players || []).map(({ active, user }) => ({ active, userId: user._id, - })); - return { - teamName: existingCompetitor?.teamName ?? '', - players: existingPlayers.length ? existingPlayers : emptyPlayers, - }; -}; + })), +}); diff --git a/src/components/TournamentCompetitorForm/TournamentCompetitorForm.tsx b/src/components/TournamentCompetitorForm/TournamentCompetitorForm.tsx index f28cc6b8..5506b273 100644 --- a/src/components/TournamentCompetitorForm/TournamentCompetitorForm.tsx +++ b/src/components/TournamentCompetitorForm/TournamentCompetitorForm.tsx @@ -1,3 +1,4 @@ +import { MouseEvent } from 'react'; import { Fragment } from 'react/jsx-runtime'; import { SubmitHandler, @@ -7,6 +8,7 @@ import { } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import clsx from 'clsx'; +import { Plus } from 'lucide-react'; import { TournamentCompetitor, @@ -14,6 +16,7 @@ import { UserId, } from '~/api'; import { useAuth } from '~/components/AuthProvider'; +import { Button } from '~/components/generic/Button'; import { Form, FormField } from '~/components/generic/Form'; import { InputSelect } from '~/components/generic/InputSelect'; import { InputText } from '~/components/generic/InputText'; @@ -70,7 +73,7 @@ export const TournamentCompetitorForm = ({ defaultValues: getDefaultValues(competitorSize, tournamentCompetitor), mode: 'onSubmit', }); - const { fields } = useFieldArray({ + const { fields, append } = useFieldArray({ control: form.control, name: 'players', rules: { @@ -91,11 +94,25 @@ export const TournamentCompetitorForm = ({ form.setValue(`players.${i}.userId`, userId); } }; + const handleChangePlayerActive = (i: number, active: boolean): void => { form.setValue(`players.${i}.active`, active); }; - const handleSubmit: SubmitHandler = async (formData): Promise => { - onSubmit({ tournamentId, ...formData }); + + const handleAddPlayer = (e: MouseEvent): void => { + e.preventDefault(); + append({ + active: players.filter((p) => p.active).length < competitorSize, + userId: '' as UserId, + }); + }; + + const handleSubmit: SubmitHandler = async ({ players, ...formData }): Promise => { + onSubmit({ + tournamentId, + players: players.filter((p) => p.userId.length), + ...formData, + }); }; const excludedUserIds = [ @@ -105,6 +122,8 @@ export const TournamentCompetitorForm = ({ const isOrganizer = user && organizerUserIds.includes(user._id); + const emptyPlayerSlots = players.filter((player) => !player.userId.length).length; + return ( {useTeams && ( @@ -124,20 +143,29 @@ export const TournamentCompetitorForm = ({ handleChangePlayerActive(i, value)} disabled={loading} /> handleChangeUser(i, value)} excludedUserIds={excludedUserIds} - disabled={loading} + disabled={loading || (!!players[i]?.userId && status !== 'published')} allowPlaceholder={false} /> ))} +
+ +
) : ( <> diff --git a/src/components/TournamentRoster/components/CompetitorActions/CompetitorActions.tsx b/src/components/TournamentRoster/components/CompetitorActions/CompetitorActions.tsx index 2f066843..59ff582f 100644 --- a/src/components/TournamentRoster/components/CompetitorActions/CompetitorActions.tsx +++ b/src/components/TournamentRoster/components/CompetitorActions/CompetitorActions.tsx @@ -79,17 +79,17 @@ export const CompetitorActions = ({ playerUserId: user!._id, tournamentCompetitorId: competitor._id, }), - visible: user && isPlayer && tournament.status !== 'archived', + visible: isPlayer && !['active', 'archived'].includes(tournament.status), }, { label: 'Edit', onClick: () => openEditDialog({ tournamentCompetitor: competitor }), - visible: user && (isOrganizer || (isPlayer && tournament.useTeams)) && tournament.status !== 'archived' && tournament.currentRound === undefined, + visible: (isOrganizer || (isPlayer && tournament.useTeams)) && tournament.status !== 'archived' && tournament.currentRound === undefined, }, { label: 'Delete', onClick: () => openConfirmDeleteDialog(), - visible: user && isOrganizer && tournament.status === 'published', + visible: isOrganizer && tournament.status === 'published', }, ]; From 87452e40e9a4f9cd8d694714c5a80ffff7d46e5b Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 30 Jun 2025 22:46:09 +0200 Subject: [PATCH 18/21] feat: #106 Improve signIn error handling (#107) --- .../components/SignInForm/SignInForm.schema.ts | 2 +- src/services/auth/useSignIn.ts | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/pages/AuthPage/components/SignInForm/SignInForm.schema.ts b/src/pages/AuthPage/components/SignInForm/SignInForm.schema.ts index a80f008c..f43ba2a2 100644 --- a/src/pages/AuthPage/components/SignInForm/SignInForm.schema.ts +++ b/src/pages/AuthPage/components/SignInForm/SignInForm.schema.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; export const signInFormSchema = z.object({ - email: z.string().min(1, 'Please enter your email.').transform((val) => val.trim().toLowerCase()), + email: z.string().email('Please enter your email.').transform((val) => val.trim().toLowerCase()), password: z.string().min(1, 'Please enter your password.').transform((val) => val.trim()), }); diff --git a/src/services/auth/useSignIn.ts b/src/services/auth/useSignIn.ts index becc3372..335ac107 100644 --- a/src/services/auth/useSignIn.ts +++ b/src/services/auth/useSignIn.ts @@ -14,6 +14,19 @@ type SignInInput = { password: string; }; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function mapConvexAuthError(error: any): string { + if (!error?.message) { + return 'An unexpected error occurred. Please try again.'; + } + + if (error.message.includes('InvalidAccountId')) { + return 'Your email or password is incorrect.'; + } + + return 'Sign-in failed. Please check your details and try again.'; +} + export const useSignIn = () => { const { signIn } = useAuthActions(); const navigate = useNavigate(); @@ -47,8 +60,9 @@ export const useSignIn = () => { flow: 'signIn', }).catch((error) => { setLoading(false); + const description = mapConvexAuthError(error); console.error(error); - toast.error('Error', { description: error.message }); + toast.error('Error', { description: description }); }); }, loading, From 5ded5b650656414f2f9356f661b02972898a2666 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 30 Jun 2025 22:47:29 +0200 Subject: [PATCH 19/21] Update convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../queries/getTournamentCompetitorsByTournament.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts b/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts index fdc7fe50..de9087c9 100644 --- a/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts +++ b/convex/_model/tournamentCompetitors/queries/getTournamentCompetitorsByTournament.ts @@ -22,10 +22,10 @@ export const getTournamentCompetitorsByTournament = async ( if (competitor.teamName) { return competitor.teamName; } - if (competitor.players[0].user.familyName) { + if (competitor.players[0]?.user.familyName) { return competitor.players[0].user.familyName; } - if (competitor.players[0].user.username) { + if (competitor.players[0]?.user.username) { return competitor.players[0].user.username; } return ''; From 08df6c7ed66714cfd9194a7c8e89c3eddf70a255 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Mon, 30 Jun 2025 22:51:48 +0200 Subject: [PATCH 20/21] Update convex/_model/users/_helpers/checkUserTournamentForcedName.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../users/_helpers/checkUserTournamentForcedName.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/convex/_model/users/_helpers/checkUserTournamentForcedName.ts b/convex/_model/users/_helpers/checkUserTournamentForcedName.ts index 5a0a62be..423be595 100644 --- a/convex/_model/users/_helpers/checkUserTournamentForcedName.ts +++ b/convex/_model/users/_helpers/checkUserTournamentForcedName.ts @@ -14,8 +14,8 @@ export const checkUserTournamentForcedName = async ( const tournaments = await ctx.db.query('tournaments').collect(); // Check each tournament for a relationship, return true if one is found - return tournaments.some(async ({ _id, organizerUserIds, requireRealNames }) => { - + // Check each tournament for a relationship, return true if one is found + for (const { _id, organizerUserIds, requireRealNames } of tournaments) { const playerUserIds = await getTournamentUserIds(ctx, _id); // Merge all organizer IDs and player IDs into one set @@ -25,6 +25,10 @@ export const checkUserTournamentForcedName = async ( ]); // If the set contains both user IDs, they were at the same tournament - return allTournamentUserIds.has(userIdA) && allTournamentUserIds.has(userIdB) && requireRealNames; - }); + if (allTournamentUserIds.has(userIdA) && allTournamentUserIds.has(userIdB) && requireRealNames) { + return true; + } + } + + return false; }; From 85245b1d3ae9d13040968d5672a697f9417bb7a6 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Tue, 1 Jul 2025 07:32:48 +0200 Subject: [PATCH 21/21] feat: Hide players with 0 matches from rankings --- convex/_model/fowV4/aggregateFowV4TournamentData.ts | 4 ++-- convex/_model/fowV4/flattenFowV4StatMap.ts | 3 ++- convex/_model/users/_helpers/redactUser.ts | 1 - 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/convex/_model/fowV4/aggregateFowV4TournamentData.ts b/convex/_model/fowV4/aggregateFowV4TournamentData.ts index 1af355e7..b2e4c124 100644 --- a/convex/_model/fowV4/aggregateFowV4TournamentData.ts +++ b/convex/_model/fowV4/aggregateFowV4TournamentData.ts @@ -131,11 +131,11 @@ export const aggregateFowV4TournamentData = async ( } return { - players: flattenFowV4StatMap(playerStats).map(({ id, stats }) => ({ + players: flattenFowV4StatMap(playerStats).filter(({ gamesPlayed }) => gamesPlayed).map(({ id, stats }) => ({ id, stats, })), - competitors: flattenFowV4StatMap(competitorStats).map(({ id, stats }) => ({ + competitors: flattenFowV4StatMap(competitorStats).filter(({ gamesPlayed }) => gamesPlayed).map(({ id, stats }) => ({ id, stats, ...competitorMeta[id], diff --git a/convex/_model/fowV4/flattenFowV4StatMap.ts b/convex/_model/fowV4/flattenFowV4StatMap.ts index 818a3ed9..f546dbd6 100644 --- a/convex/_model/fowV4/flattenFowV4StatMap.ts +++ b/convex/_model/fowV4/flattenFowV4StatMap.ts @@ -13,7 +13,7 @@ import { export const flattenFowV4StatMap = ( statMap: Record, -): { id: T; stats: FowV4TournamentFlatExtendedStats }[] => { +): { id: T; stats: FowV4TournamentFlatExtendedStats, gamesPlayed: number }[] => { const statList = Object.entries(statMap) as [T, FowV4TournamentExtendedStats][]; return statList.map(([key, stats]) => { const flatStats = {} as FowV4TournamentFlatExtendedStats; @@ -27,6 +27,7 @@ export const flattenFowV4StatMap = ( return { id: key, stats: flatStats, + gamesPlayed: stats.gamesPlayed ?? 0, }; }); }; diff --git a/convex/_model/users/_helpers/redactUser.ts b/convex/_model/users/_helpers/redactUser.ts index b1b7320e..961820b9 100644 --- a/convex/_model/users/_helpers/redactUser.ts +++ b/convex/_model/users/_helpers/redactUser.ts @@ -52,7 +52,6 @@ export const redactUser = async ( // If user is querying someone they are in a tournament with const hasTournamentRelationship = await checkUserTournamentRelationship(ctx, userId, user._id); - // If user is querying someone they are in a tournament with which requires real names const requiredByTournament = await checkUserTournamentForcedName(ctx, userId, user._id);