From 65c8cfc5ef8bebe859c3f32b41da19bedef3a0dc Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 23 Mar 2025 11:24:56 +0200 Subject: [PATCH] Consistent actions/loaders folder structure --- app/components/UserSearch.tsx | 2 +- app/features/admin/routes/admin.tsx | 4 +- .../routes/{patrons.tsx => patrons.ts} | 0 .../api-private/routes/{seed.tsx => seed.ts} | 0 .../routes/{users.tsx => users.ts} | 0 app/features/art/actions/art.new.server.ts | 112 ++++ app/features/art/art-constants.ts | 1 + app/features/art/loaders/art.new.server.ts | 24 + app/features/art/loaders/art.server.ts | 21 + app/features/art/routes/art.new.tsx | 142 +--- app/features/art/routes/art.tsx | 44 +- .../articles/loaders/a.$slug.server.ts | 12 + app/features/articles/loaders/a.server.ts | 9 + app/features/articles/routes/a.$slug.tsx | 19 +- app/features/articles/routes/a.tsx | 14 +- .../{auth.callback.tsx => auth.callback.ts} | 0 ...th.create-link.tsx => auth.create-link.ts} | 0 ...nate.stop.tsx => auth.impersonate.stop.ts} | 0 ...th.impersonate.tsx => auth.impersonate.ts} | 0 .../routes/{auth.login.tsx => auth.login.ts} | 0 .../{auth.logout.tsx => auth.logout.ts} | 0 .../auth/routes/{auth.tsx => auth.ts} | 0 .../badges/actions/badges.$id.edit.server.ts | 94 +++ .../badges/loaders/badges.$id.server.ts | 15 + app/features/badges/loaders/badges.server.ts | 8 + .../badges/routes/badges.$id.edit.tsx | 99 +-- app/features/badges/routes/badges.$id.tsx | 20 +- app/features/badges/routes/badges.tsx | 12 +- .../loaders/builds.$slug.popular.server.ts | 36 + .../loaders/builds.$slug.stats.server.ts | 38 ++ .../routes/builds.$slug.popular.tsx | 48 +- .../build-stats/routes/builds.$slug.stats.tsx | 51 +- .../calendar.$id.report-winners.server.ts | 57 ++ .../calendar/actions/calendar.$id.server.ts | 54 ++ app/features/calendar/calendar-schemas.ts | 88 +++ .../calendar.$id.report-winners.server.ts | 28 + .../calendar/loaders/calendar.$id.server.ts | 29 + .../calendar/loaders/calendar.server.ts | 105 +++ .../routes/calendar.$id.report-winners.tsx | 152 +---- app/features/calendar/routes/calendar.$id.tsx | 89 +-- app/features/calendar/routes/calendar.new.tsx | 1 + app/features/calendar/routes/calendar.tsx | 124 +--- app/features/front-page/routes/index.tsx | 2 +- .../img-upload/actions/upload.admin.server.ts | 32 + .../img-upload/loaders/upload.admin.server.ts | 17 + .../img-upload/loaders/upload.server.ts | 30 + .../img-upload/routes/upload.admin.tsx | 48 +- app/features/img-upload/routes/upload.tsx | 34 +- .../leaderboards/leaderboards-constants.ts | 3 + .../loaders/leaderboards.server.ts | 103 +++ .../leaderboards/routes/leaderboards.tsx | 119 +--- .../map-list-generator/loaders/maps.server.ts | 29 + .../map-list-generator/routes/maps.tsx | 41 +- .../notifications/routes/notifications.tsx | 14 +- ...uggestions.comment.$tier.$userId.server.ts | 51 ++ .../actions/plus.suggestions.new.server.ts | 67 ++ .../actions/plus.suggestions.server.ts | 91 +++ .../loaders/plus.suggestions.server.ts | 19 + .../plus-suggestions-schemas.ts | 54 ++ .../routes/{plus.index.tsx => plus.index.ts} | 0 ...plus.suggestions.comment.$tier.$userId.tsx | 73 +- .../routes/plus.suggestions.new.tsx | 83 +-- .../routes/plus.suggestions.tsx | 129 +--- .../plus-voting/actions/plus.voting.server.ts | 90 +++ .../loaders/plus.voting.results.server.ts | 96 +++ .../plus-voting/loaders/plus.voting.server.ts | 96 +++ .../plus-voting/plus-voting-schemas.ts | 16 + .../routes/plus.voting.results.tsx | 106 +-- .../plus-voting/routes/plus.voting.tsx | 206 +----- .../actions/q.settings.server.ts | 58 ++ .../loaders/q.settings.server.ts | 13 + .../sendouq-settings/routes/q.settings.tsx | 106 +-- .../loaders/q.streams.server.ts | 7 + .../sendouq-streams/routes/q.streams.tsx | 10 +- .../sendouq/actions/q.looking.server.ts | 366 ++++++++++ .../sendouq/actions/q.match.$id.server.ts | 306 +++++++++ .../sendouq/actions/q.preparing.server.ts | 99 +++ app/features/sendouq/actions/q.server.ts | 113 ++++ .../sendouq/components/MemberAdder.tsx | 2 +- .../sendouq/loaders/q.looking.server.ts | 133 ++++ .../sendouq/loaders/q.match.$id.server.ts | 116 ++++ .../sendouq/loaders/q.preparing.server.ts | 30 + app/features/sendouq/loaders/q.server.ts | 41 ++ app/features/sendouq/loaders/tiers.server.ts | 11 + .../sendouq/routes/{play.tsx => play.ts} | 0 app/features/sendouq/routes/q.looking.tsx | 502 +------------- app/features/sendouq/routes/q.match.$id.tsx | 429 +----------- app/features/sendouq/routes/q.preparing.tsx | 136 +--- app/features/sendouq/routes/q.tsx | 170 +---- app/features/sendouq/routes/tiers.tsx | 14 +- .../{weapon-usage.tsx => weapon-usage.ts} | 0 app/features/settings/routes/settings.tsx | 1 + .../team/routes/t.$customUrl.edit.tsx | 4 +- .../team/routes/t.$customUrl.join.tsx | 4 +- .../team/routes/t.$customUrl.roster.tsx | 2 +- app/features/team/routes/t.$customUrl.tsx | 5 +- app/features/team/routes/t.tsx | 4 +- .../loaders/xsearch.player.$id.server.ts | 25 + .../top-search/loaders/xsearch.server.ts | 62 ++ .../top-search/routes/xsearch.player.$id.tsx | 34 +- app/features/top-search/routes/xsearch.tsx | 66 +- .../actions/to.$id.matches.$mid.server.ts | 581 ++++++++++++++++ .../components/Bracket/Match.tsx | 2 +- .../components/MatchActions.tsx | 2 +- .../components/MatchActionsBanPicker.tsx | 2 +- .../components/MatchRosters.tsx | 2 +- .../OrganizerMatchMapListDialog.tsx | 2 +- .../components/StartedMatch.tsx | 2 +- .../components/TeamRosterInputs.tsx | 2 +- .../to.$id.divisions.server.ts | 0 .../loaders/to.$id.matches.$mid.server.ts | 51 ++ ...cribe.tsx => to.$id.brackets.subscribe.ts} | 0 .../routes/to.$id.brackets.tsx | 3 +- .../routes/to.$id.divisions.tsx | 3 +- ...e.tsx => to.$id.matches.$mid.subscribe.ts} | 0 .../routes/to.$id.matches.$mid.tsx | 624 +----------------- .../tournament-bracket-utils.ts | 2 +- .../routes/org.$slug.tsx | 4 +- .../actions/to.$id.subs.new.server.ts | 45 ++ .../actions/to.$id.subs.server.ts | 34 + .../loaders/to.$id.subs.new.server.ts | 24 + .../loaders/to.$id.subs.server.ts | 46 ++ .../routes/to.$id.subs.new.tsx | 77 +-- .../tournament-subs/routes/to.$id.subs.tsx | 87 +-- .../tournament/actions/to.$id.join.server.ts | 116 ++++ .../tournament/actions/to.$id.seeds.server.ts | 53 ++ .../components/TournamentStream.tsx | 2 +- .../tournament/loaders/to.$id.join.server.ts | 12 + .../tournament/loaders/to.$id.seeds.server.ts | 18 + .../tournament/loaders/to.$id.server.ts | 57 ++ .../loaders/to.$id.streams.server.ts | 16 + .../loaders/to.$id.teams.$tid.server.ts | 31 + .../tournament/routes/{luti.tsx => luti.ts} | 0 .../{to.$id.index.tsx => to.$id.index.ts} | 0 .../tournament/routes/to.$id.join.tsx | 162 +---- .../tournament/routes/to.$id.register.tsx | 1 - .../tournament/routes/to.$id.seeds.tsx | 72 +- .../tournament/routes/to.$id.streams.tsx | 17 +- .../tournament/routes/to.$id.teams.$tid.tsx | 46 +- app/features/tournament/routes/to.$id.tsx | 69 +- app/features/tournament/tournament-utils.ts | 40 +- .../actions/u.$identifier.art.server.ts | 27 + .../actions/u.$identifier.edit.server.ts | 61 ++ ...u.$identifier.results.highlights.server.ts | 34 + .../loaders/u.$identifier.art.server.ts | 41 ++ .../loaders/u.$identifier.edit.server.ts | 45 ++ .../loaders/u.$identifier.seasons.server.ts | 78 +++ .../user-page/loaders/u.$identifier.server.ts | 25 + ...ort.$customUrl.tsx => short.$customUrl.ts} | 0 .../user-page/routes/u.$identifier.art.tsx | 77 +-- .../routes/u.$identifier.builds.new.tsx | 4 +- .../user-page/routes/u.$identifier.builds.tsx | 2 +- .../routes/u.$identifier.edit.test.ts | 6 +- .../user-page/routes/u.$identifier.edit.tsx | 235 +------ .../user-page/routes/u.$identifier.index.tsx | 2 +- .../u.$identifier.results.highlights.tsx | 48 +- .../routes/u.$identifier.results.tsx | 2 +- .../routes/u.$identifier.seasons.tsx | 90 +-- .../user-page/routes/u.$identifier.tsx | 37 +- .../user-page/user-page-schemas.server.ts | 132 ++++ app/features/user-search/loaders/u.server.ts | 30 + app/features/user-search/routes/u.tsx | 42 +- app/features/vods/actions/vods.new.server.ts | 51 ++ app/features/vods/loaders/vods.$id.server.ts | 9 + app/features/vods/loaders/vods.new.server.ts | 40 ++ app/features/vods/loaders/vods.server.ts | 28 + app/features/vods/routes/vods.$id.tsx | 22 +- app/features/vods/routes/vods.new.tsx | 98 +-- app/features/vods/routes/vods.tsx | 31 +- app/routes.ts | 36 +- e2e/tournament.spec.ts | 2 +- 171 files changed, 4980 insertions(+), 4732 deletions(-) rename app/features/api-private/routes/{patrons.tsx => patrons.ts} (100%) rename app/features/api-private/routes/{seed.tsx => seed.ts} (100%) rename app/features/api-private/routes/{users.tsx => users.ts} (100%) create mode 100644 app/features/art/actions/art.new.server.ts create mode 100644 app/features/art/loaders/art.new.server.ts create mode 100644 app/features/art/loaders/art.server.ts create mode 100644 app/features/articles/loaders/a.$slug.server.ts create mode 100644 app/features/articles/loaders/a.server.ts rename app/features/auth/routes/{auth.callback.tsx => auth.callback.ts} (100%) rename app/features/auth/routes/{auth.create-link.tsx => auth.create-link.ts} (100%) rename app/features/auth/routes/{auth.impersonate.stop.tsx => auth.impersonate.stop.ts} (100%) rename app/features/auth/routes/{auth.impersonate.tsx => auth.impersonate.ts} (100%) rename app/features/auth/routes/{auth.login.tsx => auth.login.ts} (100%) rename app/features/auth/routes/{auth.logout.tsx => auth.logout.ts} (100%) rename app/features/auth/routes/{auth.tsx => auth.ts} (100%) create mode 100644 app/features/badges/actions/badges.$id.edit.server.ts create mode 100644 app/features/badges/loaders/badges.$id.server.ts create mode 100644 app/features/badges/loaders/badges.server.ts create mode 100644 app/features/build-stats/loaders/builds.$slug.popular.server.ts create mode 100644 app/features/build-stats/loaders/builds.$slug.stats.server.ts create mode 100644 app/features/calendar/actions/calendar.$id.report-winners.server.ts create mode 100644 app/features/calendar/actions/calendar.$id.server.ts create mode 100644 app/features/calendar/calendar-schemas.ts create mode 100644 app/features/calendar/loaders/calendar.$id.report-winners.server.ts create mode 100644 app/features/calendar/loaders/calendar.$id.server.ts create mode 100644 app/features/calendar/loaders/calendar.server.ts create mode 100644 app/features/img-upload/actions/upload.admin.server.ts create mode 100644 app/features/img-upload/loaders/upload.admin.server.ts create mode 100644 app/features/img-upload/loaders/upload.server.ts create mode 100644 app/features/leaderboards/loaders/leaderboards.server.ts create mode 100644 app/features/map-list-generator/loaders/maps.server.ts create mode 100644 app/features/plus-suggestions/actions/plus.suggestions.comment.$tier.$userId.server.ts create mode 100644 app/features/plus-suggestions/actions/plus.suggestions.new.server.ts create mode 100644 app/features/plus-suggestions/actions/plus.suggestions.server.ts create mode 100644 app/features/plus-suggestions/loaders/plus.suggestions.server.ts create mode 100644 app/features/plus-suggestions/plus-suggestions-schemas.ts rename app/features/plus-suggestions/routes/{plus.index.tsx => plus.index.ts} (100%) create mode 100644 app/features/plus-voting/actions/plus.voting.server.ts create mode 100644 app/features/plus-voting/loaders/plus.voting.results.server.ts create mode 100644 app/features/plus-voting/loaders/plus.voting.server.ts create mode 100644 app/features/plus-voting/plus-voting-schemas.ts create mode 100644 app/features/sendouq-settings/actions/q.settings.server.ts create mode 100644 app/features/sendouq-settings/loaders/q.settings.server.ts create mode 100644 app/features/sendouq-streams/loaders/q.streams.server.ts create mode 100644 app/features/sendouq/actions/q.looking.server.ts create mode 100644 app/features/sendouq/actions/q.match.$id.server.ts create mode 100644 app/features/sendouq/actions/q.preparing.server.ts create mode 100644 app/features/sendouq/actions/q.server.ts create mode 100644 app/features/sendouq/loaders/q.looking.server.ts create mode 100644 app/features/sendouq/loaders/q.match.$id.server.ts create mode 100644 app/features/sendouq/loaders/q.preparing.server.ts create mode 100644 app/features/sendouq/loaders/q.server.ts create mode 100644 app/features/sendouq/loaders/tiers.server.ts rename app/features/sendouq/routes/{play.tsx => play.ts} (100%) rename app/features/sendouq/routes/{weapon-usage.tsx => weapon-usage.ts} (100%) create mode 100644 app/features/top-search/loaders/xsearch.player.$id.server.ts create mode 100644 app/features/top-search/loaders/xsearch.server.ts create mode 100644 app/features/tournament-bracket/actions/to.$id.matches.$mid.server.ts rename app/features/tournament-bracket/{loader => loaders}/to.$id.divisions.server.ts (100%) create mode 100644 app/features/tournament-bracket/loaders/to.$id.matches.$mid.server.ts rename app/features/tournament-bracket/routes/{to.$id.brackets.subscribe.tsx => to.$id.brackets.subscribe.ts} (100%) rename app/features/tournament-bracket/routes/{to.$id.matches.$mid.subscribe.tsx => to.$id.matches.$mid.subscribe.ts} (100%) create mode 100644 app/features/tournament-subs/actions/to.$id.subs.new.server.ts create mode 100644 app/features/tournament-subs/actions/to.$id.subs.server.ts create mode 100644 app/features/tournament-subs/loaders/to.$id.subs.new.server.ts create mode 100644 app/features/tournament-subs/loaders/to.$id.subs.server.ts create mode 100644 app/features/tournament/actions/to.$id.join.server.ts create mode 100644 app/features/tournament/actions/to.$id.seeds.server.ts create mode 100644 app/features/tournament/loaders/to.$id.join.server.ts create mode 100644 app/features/tournament/loaders/to.$id.seeds.server.ts create mode 100644 app/features/tournament/loaders/to.$id.server.ts create mode 100644 app/features/tournament/loaders/to.$id.streams.server.ts create mode 100644 app/features/tournament/loaders/to.$id.teams.$tid.server.ts rename app/features/tournament/routes/{luti.tsx => luti.ts} (100%) rename app/features/tournament/routes/{to.$id.index.tsx => to.$id.index.ts} (100%) create mode 100644 app/features/user-page/actions/u.$identifier.art.server.ts create mode 100644 app/features/user-page/actions/u.$identifier.edit.server.ts create mode 100644 app/features/user-page/actions/u.$identifier.results.highlights.server.ts create mode 100644 app/features/user-page/loaders/u.$identifier.art.server.ts create mode 100644 app/features/user-page/loaders/u.$identifier.edit.server.ts create mode 100644 app/features/user-page/loaders/u.$identifier.seasons.server.ts create mode 100644 app/features/user-page/loaders/u.$identifier.server.ts rename app/features/user-page/routes/{short.$customUrl.tsx => short.$customUrl.ts} (100%) create mode 100644 app/features/user-search/loaders/u.server.ts create mode 100644 app/features/vods/actions/vods.new.server.ts create mode 100644 app/features/vods/loaders/vods.$id.server.ts create mode 100644 app/features/vods/loaders/vods.new.server.ts create mode 100644 app/features/vods/loaders/vods.server.ts diff --git a/app/components/UserSearch.tsx b/app/components/UserSearch.tsx index 765029fb3..cd4555ee6 100644 --- a/app/components/UserSearch.tsx +++ b/app/components/UserSearch.tsx @@ -4,7 +4,7 @@ import clsx from "clsx"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { useDebounce } from "react-use"; -import type { UserSearchLoaderData } from "~/features/user-search/routes/u"; +import type { UserSearchLoaderData } from "~/features/user-search/loaders/u.server"; import { Avatar } from "./Avatar"; type UserSearchUserItem = NonNullable["users"][number]; diff --git a/app/features/admin/routes/admin.tsx b/app/features/admin/routes/admin.tsx index ba3658b45..47596f691 100644 --- a/app/features/admin/routes/admin.tsx +++ b/app/features/admin/routes/admin.tsx @@ -15,12 +15,12 @@ import { UserSearch } from "~/components/UserSearch"; import { useUser } from "~/features/auth/core/user"; import { FRIEND_CODE_REGEXP_PATTERN } from "~/features/sendouq/q-constants"; import { isAdmin, isMod } from "~/permissions"; +import { metaTags } from "~/utils/remix"; import { SEED_URL, STOP_IMPERSONATING_URL, impersonateUrl } from "~/utils/urls"; -import { metaTags } from "~/utils/remix"; import { action } from "../actions/admin.server"; import { loader } from "../loaders/admin.server"; -export { action, loader }; +export { loader, action }; export const meta: MetaFunction = (args) => { return metaTags({ diff --git a/app/features/api-private/routes/patrons.tsx b/app/features/api-private/routes/patrons.ts similarity index 100% rename from app/features/api-private/routes/patrons.tsx rename to app/features/api-private/routes/patrons.ts diff --git a/app/features/api-private/routes/seed.tsx b/app/features/api-private/routes/seed.ts similarity index 100% rename from app/features/api-private/routes/seed.tsx rename to app/features/api-private/routes/seed.ts diff --git a/app/features/api-private/routes/users.tsx b/app/features/api-private/routes/users.ts similarity index 100% rename from app/features/api-private/routes/users.tsx rename to app/features/api-private/routes/users.ts diff --git a/app/features/art/actions/art.new.server.ts b/app/features/art/actions/art.new.server.ts new file mode 100644 index 000000000..1133dbf4e --- /dev/null +++ b/app/features/art/actions/art.new.server.ts @@ -0,0 +1,112 @@ +import type { ActionFunction } from "@remix-run/node"; +import { + unstable_composeUploadHandlers as composeUploadHandlers, + unstable_createMemoryUploadHandler as createMemoryUploadHandler, + unstable_parseMultipartFormData as parseMultipartFormData, + redirect, +} from "@remix-run/node"; +import { nanoid } from "nanoid"; +import { requireUser } from "~/features/auth/core/user.server"; +import { s3UploadHandler } from "~/features/img-upload"; +import { notify } from "~/features/notifications/core/notify.server"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import invariant from "~/utils/invariant"; +import { + errorToastIfFalsy, + parseFormData, + parseRequestPayload, +} from "~/utils/remix.server"; +import { userArtPage } from "~/utils/urls"; +import { NEW_ART_EXISTING_SEARCH_PARAM_KEY } from "../art-constants"; +import { editArtSchema, newArtSchema } from "../art-schemas.server"; +import { addNewArt, editArt } from "../queries/addNewArt.server"; +import { findArtById } from "../queries/findArtById.server"; + +export const action: ActionFunction = async ({ request }) => { + const user = await requireUser(request); + errorToastIfFalsy(user.isArtist, "Lacking artist role"); + + const searchParams = new URL(request.url).searchParams; + const artIdRaw = searchParams.get(NEW_ART_EXISTING_SEARCH_PARAM_KEY); + + // updating logic + if (artIdRaw) { + const artId = Number(artIdRaw); + + const existingArt = findArtById(artId); + errorToastIfFalsy( + existingArt?.authorId === user.id, + "Art author is someone else", + ); + + const data = await parseRequestPayload({ + request, + schema: editArtSchema, + }); + + const editedArtId = editArt({ + authorId: user.id, + artId, + description: data.description, + isShowcase: data.isShowcase, + linkedUsers: data.linkedUsers, + tags: data.tags, + }); + + const newLinkedUsers = data.linkedUsers.filter( + (userId) => !existingArt.linkedUsers.includes(userId), + ); + + notify({ + userIds: newLinkedUsers, + notification: { + type: "TAGGED_TO_ART", + meta: { + adderUsername: user.username, + adderDiscordId: user.discordId, + artId: editedArtId, + }, + }, + }); + } else { + const uploadHandler = composeUploadHandlers( + s3UploadHandler(`art-${nanoid()}-${Date.now()}`), + createMemoryUploadHandler(), + ); + const formData = await parseMultipartFormData(request, uploadHandler); + const imgSrc = formData.get("img") as string | null; + invariant(imgSrc); + + const urlParts = imgSrc.split("/"); + const fileName = urlParts[urlParts.length - 1]; + invariant(fileName); + + const data = await parseFormData({ + formData, + schema: newArtSchema, + }); + + const addedArtId = addNewArt({ + authorId: user.id, + description: data.description, + url: fileName, + validatedAt: user.patronTier ? dateToDatabaseTimestamp(new Date()) : null, + linkedUsers: data.linkedUsers, + tags: data.tags, + }); + + notify({ + userIds: data.linkedUsers, + notification: { + type: "TAGGED_TO_ART", + meta: { + adderUsername: user.username, + adderDiscordId: user.discordId, + artId: addedArtId, + }, + }, + }); + } + + throw redirect(userArtPage(user)); +}; diff --git a/app/features/art/art-constants.ts b/app/features/art/art-constants.ts index e7581bc20..195a3f8e6 100644 --- a/app/features/art/art-constants.ts +++ b/app/features/art/art-constants.ts @@ -11,3 +11,4 @@ export const ART = { }; export const NEW_ART_EXISTING_SEARCH_PARAM_KEY = "art"; +export const FILTERED_TAG_KEY_SEARCH_PARAM_KEY = "tag"; diff --git a/app/features/art/loaders/art.new.server.ts b/app/features/art/loaders/art.new.server.ts new file mode 100644 index 000000000..7a7d0a131 --- /dev/null +++ b/app/features/art/loaders/art.new.server.ts @@ -0,0 +1,24 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { requireUser } from "~/features/auth/core/user.server"; +import { unauthorizedIfFalsy } from "~/utils/remix.server"; +import { NEW_ART_EXISTING_SEARCH_PARAM_KEY } from "../art-constants"; +import { allArtTags } from "../queries/allArtTags.server"; +import { findArtById } from "../queries/findArtById.server"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const user = await requireUser(request); + unauthorizedIfFalsy(user.isArtist); + + const artIdRaw = new URL(request.url).searchParams.get( + NEW_ART_EXISTING_SEARCH_PARAM_KEY, + ); + if (!artIdRaw) return { art: null, tags: allArtTags() }; + const artId = Number(artIdRaw); + + const art = findArtById(artId); + if (!art || art.authorId !== user.id) { + return { art: null, tags: allArtTags() }; + } + + return { art, tags: allArtTags() }; +}; diff --git a/app/features/art/loaders/art.server.ts b/app/features/art/loaders/art.server.ts new file mode 100644 index 000000000..662d84580 --- /dev/null +++ b/app/features/art/loaders/art.server.ts @@ -0,0 +1,21 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { FILTERED_TAG_KEY_SEARCH_PARAM_KEY } from "../art-constants"; +import { allArtTags } from "../queries/allArtTags.server"; +import { + showcaseArts, + showcaseArtsByTag, +} from "../queries/showcaseArts.server"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const allTags = allArtTags(); + + const filteredTagName = new URL(request.url).searchParams.get( + FILTERED_TAG_KEY_SEARCH_PARAM_KEY, + ); + const filteredTag = allTags.find((t) => t.name === filteredTagName); + + return { + arts: filteredTag ? showcaseArtsByTag(filteredTag.id) : showcaseArts(), + allTags, + }; +}; diff --git a/app/features/art/routes/art.new.tsx b/app/features/art/routes/art.new.tsx index d79648b74..c8f0e6a9a 100644 --- a/app/features/art/routes/art.new.tsx +++ b/app/features/art/routes/art.new.tsx @@ -1,14 +1,4 @@ -import type { - ActionFunction, - LoaderFunctionArgs, - MetaFunction, -} from "@remix-run/node"; -import { - unstable_composeUploadHandlers as composeUploadHandlers, - unstable_createMemoryUploadHandler as createMemoryUploadHandler, - unstable_parseMultipartFormData as parseMultipartFormData, - redirect, -} from "@remix-run/node"; +import type { MetaFunction } from "@remix-run/node"; import { Form, useLoaderData } from "@remix-run/react"; import Compressor from "compressorjs"; import { nanoid } from "nanoid"; @@ -25,31 +15,20 @@ import { UserSearch } from "~/components/UserSearch"; import { SendouSwitch } from "~/components/elements/Switch"; import { CrossIcon } from "~/components/icons/Cross"; import { useUser } from "~/features/auth/core/user"; -import { requireUser } from "~/features/auth/core/user.server"; -import { s3UploadHandler } from "~/features/img-upload"; -import { notify } from "~/features/notifications/core/notify.server"; -import { dateToDatabaseTimestamp } from "~/utils/dates"; import invariant from "~/utils/invariant"; -import { - type SendouRouteHandle, - errorToastIfFalsy, - parseFormData, - parseRequestPayload, - unauthorizedIfFalsy, -} from "~/utils/remix.server"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import { artPage, conditionalUserSubmittedImage, navIconUrl, - userArtPage, } from "~/utils/urls"; import { metaTitle } from "../../../utils/remix"; -import { ART, NEW_ART_EXISTING_SEARCH_PARAM_KEY } from "../art-constants"; -import { editArtSchema, newArtSchema } from "../art-schemas.server"; +import { ART } from "../art-constants"; import { previewUrl } from "../art-utils"; -import { addNewArt, editArt } from "../queries/addNewArt.server"; -import { allArtTags } from "../queries/allArtTags.server"; -import { findArtById } from "../queries/findArtById.server"; + +import { action } from "../actions/art.new.server"; +import { loader } from "../loaders/art.new.server"; +export { loader, action }; export const handle: SendouRouteHandle = { i18n: ["art"], @@ -66,113 +45,6 @@ export const meta: MetaFunction = () => { }); }; -export const action: ActionFunction = async ({ request }) => { - const user = await requireUser(request); - errorToastIfFalsy(user.isArtist, "Lacking artist role"); - - const searchParams = new URL(request.url).searchParams; - const artIdRaw = searchParams.get(NEW_ART_EXISTING_SEARCH_PARAM_KEY); - - // updating logic - if (artIdRaw) { - const artId = Number(artIdRaw); - - const existingArt = findArtById(artId); - errorToastIfFalsy( - existingArt?.authorId === user.id, - "Art author is someone else", - ); - - const data = await parseRequestPayload({ - request, - schema: editArtSchema, - }); - - const editedArtId = editArt({ - authorId: user.id, - artId, - description: data.description, - isShowcase: data.isShowcase, - linkedUsers: data.linkedUsers, - tags: data.tags, - }); - - const newLinkedUsers = data.linkedUsers.filter( - (userId) => !existingArt.linkedUsers.includes(userId), - ); - - notify({ - userIds: newLinkedUsers, - notification: { - type: "TAGGED_TO_ART", - meta: { - adderUsername: user.username, - adderDiscordId: user.discordId, - artId: editedArtId, - }, - }, - }); - } else { - const uploadHandler = composeUploadHandlers( - s3UploadHandler(`art-${nanoid()}-${Date.now()}`), - createMemoryUploadHandler(), - ); - const formData = await parseMultipartFormData(request, uploadHandler); - const imgSrc = formData.get("img") as string | null; - invariant(imgSrc); - - const urlParts = imgSrc.split("/"); - const fileName = urlParts[urlParts.length - 1]; - invariant(fileName); - - const data = await parseFormData({ - formData, - schema: newArtSchema, - }); - - const addedArtId = addNewArt({ - authorId: user.id, - description: data.description, - url: fileName, - validatedAt: user.patronTier ? dateToDatabaseTimestamp(new Date()) : null, - linkedUsers: data.linkedUsers, - tags: data.tags, - }); - - notify({ - userIds: data.linkedUsers, - notification: { - type: "TAGGED_TO_ART", - meta: { - adderUsername: user.username, - adderDiscordId: user.discordId, - artId: addedArtId, - }, - }, - }); - } - - throw redirect(userArtPage(user)); -}; - -export const loader = async ({ request }: LoaderFunctionArgs) => { - const user = await requireUser(request); - unauthorizedIfFalsy(user.isArtist); - - const artIdRaw = new URL(request.url).searchParams.get( - NEW_ART_EXISTING_SEARCH_PARAM_KEY, - ); - if (!artIdRaw) return { art: null, tags: allArtTags() }; - const artId = Number(artIdRaw); - - const art = findArtById(artId); - if (!art || art.authorId !== user.id) { - return { art: null, tags: allArtTags() }; - } - - return { art, tags: allArtTags() }; -}; - export default function NewArtPage() { const data = useLoaderData(); const [img, setImg] = React.useState(null); diff --git a/app/features/art/routes/art.tsx b/app/features/art/routes/art.tsx index c6af011ad..352785a5a 100644 --- a/app/features/art/routes/art.tsx +++ b/app/features/art/routes/art.tsx @@ -1,8 +1,4 @@ -import type { - LoaderFunctionArgs, - MetaFunction, - SerializeFrom, -} from "@remix-run/node"; +import type { MetaFunction, SerializeFrom } from "@remix-run/node"; import type { ShouldRevalidateFunction } from "@remix-run/react"; import { useLoaderData, useSearchParams } from "@remix-run/react"; import { useTranslation } from "react-i18next"; @@ -15,19 +11,21 @@ import { CrossIcon } from "~/components/icons/Cross"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { artPage, navIconUrl } from "~/utils/urls"; import { metaTags } from "../../../utils/remix"; +import { FILTERED_TAG_KEY_SEARCH_PARAM_KEY } from "../art-constants"; import { ArtGrid } from "../components/ArtGrid"; -import { allArtTags } from "../queries/allArtTags.server"; -import { - showcaseArts, - showcaseArtsByTag, -} from "../queries/showcaseArts.server"; -const FILTERED_TAG_KEY = "tag"; +import { loader } from "../loaders/art.server"; +export { loader }; + const OPEN_COMMISIONS_KEY = "open"; export const shouldRevalidate: ShouldRevalidateFunction = (args) => { - const currentFilteredTag = args.currentUrl.searchParams.get(FILTERED_TAG_KEY); - const nextFilteredTag = args.nextUrl.searchParams.get(FILTERED_TAG_KEY); + const currentFilteredTag = args.currentUrl.searchParams.get( + FILTERED_TAG_KEY_SEARCH_PARAM_KEY, + ); + const nextFilteredTag = args.nextUrl.searchParams.get( + FILTERED_TAG_KEY_SEARCH_PARAM_KEY, + ); if (currentFilteredTag === nextFilteredTag) return false; @@ -57,26 +55,12 @@ export const meta: MetaFunction = (args) => { }); }; -export const loader = async ({ request }: LoaderFunctionArgs) => { - const allTags = allArtTags(); - - const filteredTagName = new URL(request.url).searchParams.get( - FILTERED_TAG_KEY, - ); - const filteredTag = allTags.find((t) => t.name === filteredTagName); - - return { - arts: filteredTag ? showcaseArtsByTag(filteredTag.id) : showcaseArts(), - allTags, - }; -}; - export default function ArtPage() { const { t } = useTranslation(["art", "common"]); const data = useLoaderData(); const [searchParams, setSearchParams] = useSearchParams(); - const filteredTag = searchParams.get(FILTERED_TAG_KEY); + const filteredTag = searchParams.get(FILTERED_TAG_KEY_SEARCH_PARAM_KEY); const showOpenCommissions = searchParams.get(OPEN_COMMISIONS_KEY) === "true"; const arts = !showOpenCommissions @@ -114,7 +98,7 @@ export default function ArtPage() { if (!selection) return; setSearchParams((prev) => { - prev.set(FILTERED_TAG_KEY, selection.label); + prev.set(FILTERED_TAG_KEY_SEARCH_PARAM_KEY, selection.label); return prev; }); }} @@ -129,7 +113,7 @@ export default function ArtPage() { icon={} onClick={() => { setSearchParams((prev) => { - prev.delete(FILTERED_TAG_KEY); + prev.delete(FILTERED_TAG_KEY_SEARCH_PARAM_KEY); return prev; }); }} diff --git a/app/features/articles/loaders/a.$slug.server.ts b/app/features/articles/loaders/a.$slug.server.ts new file mode 100644 index 000000000..a392ae89f --- /dev/null +++ b/app/features/articles/loaders/a.$slug.server.ts @@ -0,0 +1,12 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import invariant from "~/utils/invariant"; +import { notFoundIfFalsy } from "~/utils/remix.server"; +import { articleBySlug } from "../core/bySlug.server"; + +export const loader = ({ params }: LoaderFunctionArgs) => { + invariant(params.slug); + + const article = notFoundIfFalsy(articleBySlug(params.slug)); + + return { ...article, slug: params.slug }; +}; diff --git a/app/features/articles/loaders/a.server.ts b/app/features/articles/loaders/a.server.ts new file mode 100644 index 000000000..cb072ff73 --- /dev/null +++ b/app/features/articles/loaders/a.server.ts @@ -0,0 +1,9 @@ +import { mostRecentArticles } from "../core/list.server"; + +const MAX_ARTICLES_COUNT = 100; + +export const loader = async () => { + return { + articles: await mostRecentArticles(MAX_ARTICLES_COUNT), + }; +}; diff --git a/app/features/articles/routes/a.$slug.tsx b/app/features/articles/routes/a.$slug.tsx index 751d907d4..c6b176ac3 100644 --- a/app/features/articles/routes/a.$slug.tsx +++ b/app/features/articles/routes/a.$slug.tsx @@ -1,15 +1,10 @@ -import type { - LoaderFunctionArgs, - MetaFunction, - SerializeFrom, -} from "@remix-run/node"; +import type { MetaFunction, SerializeFrom } from "@remix-run/node"; import { Link, useLoaderData } from "@remix-run/react"; import Markdown from "markdown-to-jsx"; import * as React from "react"; import { Main } from "~/components/Main"; import invariant from "~/utils/invariant"; import type { SendouRouteHandle } from "~/utils/remix.server"; -import { notFoundIfFalsy } from "~/utils/remix.server"; import { ARTICLES_MAIN_PAGE, articlePage, @@ -17,7 +12,9 @@ import { navIconUrl, } from "~/utils/urls"; import { metaTags } from "../../../utils/remix"; -import { articleBySlug } from "../core/bySlug.server"; + +import { loader } from "../loaders/a.$slug.server"; +export { loader }; export const handle: SendouRouteHandle = { breadcrumb: ({ match }) => { @@ -58,14 +55,6 @@ export const meta: MetaFunction = (args) => { }); }; -export const loader = ({ params }: LoaderFunctionArgs) => { - invariant(params.slug); - - const article = notFoundIfFalsy(articleBySlug(params.slug)); - - return { ...article, slug: params.slug }; -}; - export default function ArticlePage() { const data = useLoaderData(); return ( diff --git a/app/features/articles/routes/a.tsx b/app/features/articles/routes/a.tsx index a49228aad..32ae6c3ee 100644 --- a/app/features/articles/routes/a.tsx +++ b/app/features/articles/routes/a.tsx @@ -4,13 +4,13 @@ import { useTranslation } from "react-i18next"; import { Main } from "~/components/Main"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { ARTICLES_MAIN_PAGE, articlePage, navIconUrl } from "~/utils/urls"; +import { joinListToNaturalString } from "../../../utils/arrays"; import { metaTags } from "../../../utils/remix"; -import { mostRecentArticles } from "../core/list.server"; + +import { loader } from "../loaders/a.server"; +export { loader }; import "~/styles/front.css"; -import { joinListToNaturalString } from "../../../utils/arrays"; - -const MAX_ARTICLES_COUNT = 100; export const handle: SendouRouteHandle = { breadcrumb: () => ({ @@ -30,12 +30,6 @@ export const meta: MetaFunction = (args) => { }); }; -export const loader = async () => { - return { - articles: await mostRecentArticles(MAX_ARTICLES_COUNT), - }; -}; - export default function ArticlesMainPage() { const { t } = useTranslation(["common"]); const data = useLoaderData(); diff --git a/app/features/auth/routes/auth.callback.tsx b/app/features/auth/routes/auth.callback.ts similarity index 100% rename from app/features/auth/routes/auth.callback.tsx rename to app/features/auth/routes/auth.callback.ts diff --git a/app/features/auth/routes/auth.create-link.tsx b/app/features/auth/routes/auth.create-link.ts similarity index 100% rename from app/features/auth/routes/auth.create-link.tsx rename to app/features/auth/routes/auth.create-link.ts diff --git a/app/features/auth/routes/auth.impersonate.stop.tsx b/app/features/auth/routes/auth.impersonate.stop.ts similarity index 100% rename from app/features/auth/routes/auth.impersonate.stop.tsx rename to app/features/auth/routes/auth.impersonate.stop.ts diff --git a/app/features/auth/routes/auth.impersonate.tsx b/app/features/auth/routes/auth.impersonate.ts similarity index 100% rename from app/features/auth/routes/auth.impersonate.tsx rename to app/features/auth/routes/auth.impersonate.ts diff --git a/app/features/auth/routes/auth.login.tsx b/app/features/auth/routes/auth.login.ts similarity index 100% rename from app/features/auth/routes/auth.login.tsx rename to app/features/auth/routes/auth.login.ts diff --git a/app/features/auth/routes/auth.logout.tsx b/app/features/auth/routes/auth.logout.ts similarity index 100% rename from app/features/auth/routes/auth.logout.tsx rename to app/features/auth/routes/auth.logout.ts diff --git a/app/features/auth/routes/auth.tsx b/app/features/auth/routes/auth.ts similarity index 100% rename from app/features/auth/routes/auth.tsx rename to app/features/auth/routes/auth.ts diff --git a/app/features/badges/actions/badges.$id.edit.server.ts b/app/features/badges/actions/badges.$id.edit.server.ts new file mode 100644 index 000000000..b9afd1a01 --- /dev/null +++ b/app/features/badges/actions/badges.$id.edit.server.ts @@ -0,0 +1,94 @@ +import type { ActionFunction } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { z } from "zod"; +import { requireUserId } from "~/features/auth/core/user.server"; +import { notify } from "~/features/notifications/core/notify.server"; +import { canEditBadgeManagers, canEditBadgeOwners } from "~/permissions"; +import { diff } from "~/utils/arrays"; +import { + errorToastIfFalsy, + notFoundIfFalsy, + parseRequestPayload, +} from "~/utils/remix.server"; +import { assertUnreachable } from "~/utils/types"; +import { badgePage } from "~/utils/urls"; +import { actualNumber } from "~/utils/zod"; +import * as BadgeRepository from "../BadgeRepository.server"; +import { editBadgeActionSchema } from "../badges-schemas.server"; + +export const action: ActionFunction = async ({ request, params }) => { + const data = await parseRequestPayload({ + request, + schema: editBadgeActionSchema, + }); + const badgeId = z.preprocess(actualNumber, z.number()).parse(params.id); + const user = await requireUserId(request); + + const badge = notFoundIfFalsy(await BadgeRepository.findById(badgeId)); + + switch (data._action) { + case "MANAGERS": { + errorToastIfFalsy( + canEditBadgeManagers(user), + "No permissions to edit managers", + ); + + const oldManagers = await BadgeRepository.findManagersByBadgeId(badgeId); + + await BadgeRepository.replaceManagers({ + badgeId, + managerIds: data.managerIds, + }); + + const newManagers = data.managerIds.filter( + (newManagerId) => + !oldManagers.some((oldManager) => oldManager.id === newManagerId), + ); + + notify({ + userIds: newManagers, + notification: { + type: "BADGE_MANAGER_ADDED", + meta: { + badgeId, + badgeName: badge.displayName, + }, + }, + }); + break; + } + case "OWNERS": { + errorToastIfFalsy( + canEditBadgeOwners({ + user, + managers: await BadgeRepository.findManagersByBadgeId(badgeId), + }), + "No permissions to edit owners", + ); + + const oldOwners: number[] = ( + await BadgeRepository.findOwnersByBadgeId(badgeId) + ).flatMap((owner) => new Array(owner.count).fill(owner.id)); + + await BadgeRepository.replaceOwners({ badgeId, ownerIds: data.ownerIds }); + + notify({ + userIds: diff(oldOwners, data.ownerIds), + notification: { + type: "BADGE_ADDED", + meta: { + badgeName: badge.displayName, + badgeId, + }, + }, + }); + + break; + } + default: { + assertUnreachable(data); + } + } + + throw redirect(badgePage(badgeId)); +}; diff --git a/app/features/badges/loaders/badges.$id.server.ts b/app/features/badges/loaders/badges.$id.server.ts new file mode 100644 index 000000000..ed988e843 --- /dev/null +++ b/app/features/badges/loaders/badges.$id.server.ts @@ -0,0 +1,15 @@ +import type { LoaderFunctionArgs, SerializeFrom } from "@remix-run/node"; +import * as BadgeRepository from "../BadgeRepository.server"; + +export type BadgeDetailsLoaderData = SerializeFrom; +export const loader = async ({ params }: LoaderFunctionArgs) => { + const badgeId = Number(params.id); + if (Number.isNaN(badgeId)) { + throw new Response(null, { status: 404 }); + } + + return { + owners: await BadgeRepository.findOwnersByBadgeId(badgeId), + managers: await BadgeRepository.findManagersByBadgeId(badgeId), + }; +}; diff --git a/app/features/badges/loaders/badges.server.ts b/app/features/badges/loaders/badges.server.ts new file mode 100644 index 000000000..e33348d7d --- /dev/null +++ b/app/features/badges/loaders/badges.server.ts @@ -0,0 +1,8 @@ +import type { SerializeFrom } from "@remix-run/node"; +import * as BadgeRepository from "../BadgeRepository.server"; + +export type BadgesLoaderData = SerializeFrom; + +export const loader = async () => { + return { badges: await BadgeRepository.all() }; +}; diff --git a/app/features/badges/routes/badges.$id.edit.tsx b/app/features/badges/routes/badges.$id.edit.tsx index 8c00892fa..1735693ba 100644 --- a/app/features/badges/routes/badges.$id.edit.tsx +++ b/app/features/badges/routes/badges.$id.edit.tsx @@ -1,8 +1,5 @@ -import type { ActionFunction } from "@remix-run/node"; -import { redirect } from "@remix-run/node"; import { Form, useMatches, useOutletContext } from "@remix-run/react"; import * as React from "react"; -import { z } from "zod"; import { Button, LinkButton } from "~/components/Button"; import { Dialog } from "~/components/Dialog"; import { Label } from "~/components/Label"; @@ -10,98 +7,14 @@ import { UserSearch } from "~/components/UserSearch"; import { TrashIcon } from "~/components/icons/Trash"; import type { Tables } from "~/db/tables"; import { useUser } from "~/features/auth/core/user"; -import { requireUserId } from "~/features/auth/core/user.server"; -import { notify } from "~/features/notifications/core/notify.server"; import { canEditBadgeManagers, canEditBadgeOwners } from "~/permissions"; -import { atOrError, diff } from "~/utils/arrays"; -import { - errorToastIfFalsy, - notFoundIfFalsy, - parseRequestPayload, -} from "~/utils/remix.server"; -import { assertUnreachable } from "~/utils/types"; -import { badgePage } from "~/utils/urls"; -import { actualNumber } from "~/utils/zod"; -import * as BadgeRepository from "../BadgeRepository.server"; -import { editBadgeActionSchema } from "../badges-schemas.server"; -import type { BadgeDetailsContext, BadgeDetailsLoaderData } from "./badges.$id"; +import { atOrError } from "~/utils/arrays"; +import type * as BadgeRepository from "../BadgeRepository.server"; +import type { BadgeDetailsLoaderData } from "../loaders/badges.$id.server"; +import type { BadgeDetailsContext } from "./badges.$id"; -export const action: ActionFunction = async ({ request, params }) => { - const data = await parseRequestPayload({ - request, - schema: editBadgeActionSchema, - }); - const badgeId = z.preprocess(actualNumber, z.number()).parse(params.id); - const user = await requireUserId(request); - - const badge = notFoundIfFalsy(await BadgeRepository.findById(badgeId)); - - switch (data._action) { - case "MANAGERS": { - errorToastIfFalsy( - canEditBadgeManagers(user), - "No permissions to edit managers", - ); - - const oldManagers = await BadgeRepository.findManagersByBadgeId(badgeId); - - await BadgeRepository.replaceManagers({ - badgeId, - managerIds: data.managerIds, - }); - - const newManagers = data.managerIds.filter( - (newManagerId) => - !oldManagers.some((oldManager) => oldManager.id === newManagerId), - ); - - notify({ - userIds: newManagers, - notification: { - type: "BADGE_MANAGER_ADDED", - meta: { - badgeId, - badgeName: badge.displayName, - }, - }, - }); - break; - } - case "OWNERS": { - errorToastIfFalsy( - canEditBadgeOwners({ - user, - managers: await BadgeRepository.findManagersByBadgeId(badgeId), - }), - "No permissions to edit owners", - ); - - const oldOwners: number[] = ( - await BadgeRepository.findOwnersByBadgeId(badgeId) - ).flatMap((owner) => new Array(owner.count).fill(owner.id)); - - await BadgeRepository.replaceOwners({ badgeId, ownerIds: data.ownerIds }); - - notify({ - userIds: diff(oldOwners, data.ownerIds), - notification: { - type: "BADGE_ADDED", - meta: { - badgeName: badge.displayName, - badgeId, - }, - }, - }); - - break; - } - default: { - assertUnreachable(data); - } - } - - throw redirect(badgePage(badgeId)); -}; +import { action } from "../actions/badges.$id.edit.server"; +export { action }; export default function EditBadgePage() { const user = useUser(); diff --git a/app/features/badges/routes/badges.$id.tsx b/app/features/badges/routes/badges.$id.tsx index ec96cf3bf..f4855d40a 100644 --- a/app/features/badges/routes/badges.$id.tsx +++ b/app/features/badges/routes/badges.$id.tsx @@ -1,4 +1,3 @@ -import type { LoaderFunctionArgs, SerializeFrom } from "@remix-run/node"; import { Outlet, useLoaderData, useMatches, useParams } from "@remix-run/react"; import clsx from "clsx"; import { useTranslation } from "react-i18next"; @@ -8,27 +7,16 @@ import { Redirect } from "~/components/Redirect"; import { useUser } from "~/features/auth/core/user"; import { canEditBadgeOwners, isMod } from "~/permissions"; import { BADGES_PAGE } from "~/utils/urls"; -import * as BadgeRepository from "../BadgeRepository.server"; import { badgeExplanationText } from "../badges-utils"; -import type { BadgesLoaderData } from "./badges"; +import type { BadgesLoaderData } from "../loaders/badges.server"; + +import { loader } from "../loaders/badges.$id.server"; +export { loader }; export interface BadgeDetailsContext { badgeName: string; } -export type BadgeDetailsLoaderData = SerializeFrom; -export const loader = async ({ params }: LoaderFunctionArgs) => { - const badgeId = Number(params.id); - if (Number.isNaN(badgeId)) { - throw new Response(null, { status: 404 }); - } - - return { - owners: await BadgeRepository.findOwnersByBadgeId(badgeId), - managers: await BadgeRepository.findManagersByBadgeId(badgeId), - }; -}; - export default function BadgeDetailsPage() { const user = useUser(); const [, parentRoute] = useMatches(); diff --git a/app/features/badges/routes/badges.tsx b/app/features/badges/routes/badges.tsx index 15c48fb41..e73b8185d 100644 --- a/app/features/badges/routes/badges.tsx +++ b/app/features/badges/routes/badges.tsx @@ -1,4 +1,4 @@ -import type { MetaFunction, SerializeFrom } from "@remix-run/node"; +import type { MetaFunction } from "@remix-run/node"; import { NavLink, Outlet, useLoaderData } from "@remix-run/react"; import * as React from "react"; import { useTranslation } from "react-i18next"; @@ -11,7 +11,9 @@ import { useUser } from "~/features/auth/core/user"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { BADGES_DOC_LINK, BADGES_PAGE, navIconUrl } from "~/utils/urls"; import { metaTags } from "../../../utils/remix"; -import * as BadgeRepository from "../BadgeRepository.server"; + +import { type BadgesLoaderData, loader } from "../loaders/badges.server"; +export { loader }; import "~/styles/badges.css"; @@ -34,12 +36,6 @@ export const meta: MetaFunction = (args) => { }); }; -export type BadgesLoaderData = SerializeFrom; - -export const loader = async () => { - return { badges: await BadgeRepository.all() }; -}; - export default function BadgesPageLayout() { const { t } = useTranslation(["badges"]); const data = useLoaderData(); diff --git a/app/features/build-stats/loaders/builds.$slug.popular.server.ts b/app/features/build-stats/loaders/builds.$slug.popular.server.ts new file mode 100644 index 000000000..40966245d --- /dev/null +++ b/app/features/build-stats/loaders/builds.$slug.popular.server.ts @@ -0,0 +1,36 @@ +import { cachified } from "@epic-web/cachified"; +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { ONE_HOUR_IN_MS } from "~/constants"; +import { i18next } from "~/modules/i18n/i18next.server"; +import { cache, ttl } from "~/utils/cache.server"; +import { notFoundIfNullLike } from "~/utils/remix.server"; +import { weaponNameSlugToId } from "~/utils/unslugify.server"; +import { popularBuilds } from "../build-stats-utils"; +import { abilitiesByWeaponId } from "../queries/abilitiesByWeaponId.server"; + +export const loader = async ({ params, request }: LoaderFunctionArgs) => { + const t = await i18next.getFixedT(request, ["builds", "weapons"]); + const slug = params.slug; + const weaponId = notFoundIfNullLike(weaponNameSlugToId(slug)); + + const weaponName = t(`weapons:MAIN_${weaponId}`); + + const cachedPopularBuilds = await cachified({ + key: `popular-builds-${weaponId}`, + cache, + ttl: ttl(ONE_HOUR_IN_MS), + async getFreshValue() { + return popularBuilds(abilitiesByWeaponId(weaponId)); + }, + }); + + return { + popularBuilds: cachedPopularBuilds, + weaponName, + meta: { + weaponId, + slug: slug!, + breadcrumbText: t("builds:linkButton.popularBuilds"), + }, + }; +}; diff --git a/app/features/build-stats/loaders/builds.$slug.stats.server.ts b/app/features/build-stats/loaders/builds.$slug.stats.server.ts new file mode 100644 index 000000000..02627148c --- /dev/null +++ b/app/features/build-stats/loaders/builds.$slug.stats.server.ts @@ -0,0 +1,38 @@ +import { cachified } from "@epic-web/cachified"; +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { ONE_HOUR_IN_MS } from "~/constants"; +import { i18next } from "~/modules/i18n/i18next.server"; +import { cache, ttl } from "~/utils/cache.server"; +import { notFoundIfNullLike } from "~/utils/remix.server"; +import { weaponNameSlugToId } from "~/utils/unslugify.server"; +import { abilityPointCountsToAverages } from "../build-stats-utils"; +import { averageAbilityPoints } from "../queries/averageAbilityPoints.server"; + +export const loader = async ({ params, request }: LoaderFunctionArgs) => { + const t = await i18next.getFixedT(request, ["builds", "weapons"]); + const weaponId = notFoundIfNullLike(weaponNameSlugToId(params.slug)); + + const weaponName = t(`weapons:MAIN_${weaponId}`); + + const cachedStats = await cachified({ + key: `build-stats-${weaponId}`, + cache, + ttl: ttl(ONE_HOUR_IN_MS), + async getFreshValue() { + return abilityPointCountsToAverages({ + allAbilities: averageAbilityPoints(), + weaponAbilities: averageAbilityPoints(weaponId), + }); + }, + }); + + return { + stats: cachedStats, + weaponName, + weaponId, + meta: { + slug: params.slug!, + breadcrumbText: t("builds:linkButton.abilityStats"), + }, + }; +}; diff --git a/app/features/build-stats/routes/builds.$slug.popular.tsx b/app/features/build-stats/routes/builds.$slug.popular.tsx index deae932ba..80a5e9e45 100644 --- a/app/features/build-stats/routes/builds.$slug.popular.tsx +++ b/app/features/build-stats/routes/builds.$slug.popular.tsx @@ -1,22 +1,10 @@ -import { cachified } from "@epic-web/cachified"; -import type { - LoaderFunctionArgs, - MetaFunction, - SerializeFrom, -} from "@remix-run/node"; +import type { MetaFunction, SerializeFrom } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; import clsx from "clsx"; import { useTranslation } from "react-i18next"; import { Ability } from "~/components/Ability"; import { Main } from "~/components/Main"; -import { ONE_HOUR_IN_MS } from "~/constants"; -import { i18next } from "~/modules/i18n/i18next.server"; -import { cache, ttl } from "~/utils/cache.server"; -import { - type SendouRouteHandle, - notFoundIfNullLike, -} from "~/utils/remix.server"; -import { weaponNameSlugToId } from "~/utils/unslugify.server"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import { BUILDS_PAGE, navIconUrl, @@ -24,8 +12,9 @@ import { weaponBuildPage, } from "~/utils/urls"; import { metaTags } from "../../../utils/remix"; -import { popularBuilds } from "../build-stats-utils"; -import { abilitiesByWeaponId } from "../queries/abilitiesByWeaponId.server"; + +import { loader } from "../loaders/builds.$slug.popular.server"; +export { loader }; export const meta: MetaFunction = (args) => { if (!args.data) return []; @@ -65,33 +54,6 @@ export const handle: SendouRouteHandle = { }, }; -export const loader = async ({ params, request }: LoaderFunctionArgs) => { - const t = await i18next.getFixedT(request, ["builds", "weapons"]); - const slug = params.slug; - const weaponId = notFoundIfNullLike(weaponNameSlugToId(slug)); - - const weaponName = t(`weapons:MAIN_${weaponId}`); - - const cachedPopularBuilds = await cachified({ - key: `popular-builds-${weaponId}`, - cache, - ttl: ttl(ONE_HOUR_IN_MS), - async getFreshValue() { - return popularBuilds(abilitiesByWeaponId(weaponId)); - }, - }); - - return { - popularBuilds: cachedPopularBuilds, - weaponName, - meta: { - weaponId, - slug: slug!, - breadcrumbText: t("builds:linkButton.popularBuilds"), - }, - }; -}; - export default function PopularBuildsPage() { const { t } = useTranslation(["analyzer", "builds"]); const data = useLoaderData(); diff --git a/app/features/build-stats/routes/builds.$slug.stats.tsx b/app/features/build-stats/routes/builds.$slug.stats.tsx index e01ca7402..4f9a78145 100644 --- a/app/features/build-stats/routes/builds.$slug.stats.tsx +++ b/app/features/build-stats/routes/builds.$slug.stats.tsx @@ -1,22 +1,11 @@ -import { cachified } from "@epic-web/cachified"; -import type { - LoaderFunctionArgs, - MetaFunction, - SerializeFrom, -} from "@remix-run/node"; +import type { MetaFunction, SerializeFrom } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; import { useTranslation } from "react-i18next"; import { Ability } from "~/components/Ability"; import { WeaponImage } from "~/components/Image"; import { Main } from "~/components/Main"; -import { MAX_AP, ONE_HOUR_IN_MS } from "~/constants"; -import { i18next } from "~/modules/i18n/i18next.server"; -import { cache, ttl } from "~/utils/cache.server"; -import { - type SendouRouteHandle, - notFoundIfNullLike, -} from "~/utils/remix.server"; -import { weaponNameSlugToId } from "~/utils/unslugify.server"; +import { MAX_AP } from "~/constants"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import { BUILDS_PAGE, navIconUrl, @@ -24,8 +13,9 @@ import { weaponBuildPage, } from "~/utils/urls"; import { metaTags } from "../../../utils/remix"; -import { abilityPointCountsToAverages } from "../build-stats-utils"; -import { averageAbilityPoints } from "../queries/averageAbilityPoints.server"; + +import { loader } from "../loaders/builds.$slug.stats.server"; +export { loader }; import "../build-stats.css"; @@ -67,35 +57,6 @@ export const handle: SendouRouteHandle = { }, }; -export const loader = async ({ params, request }: LoaderFunctionArgs) => { - const t = await i18next.getFixedT(request, ["builds", "weapons"]); - const weaponId = notFoundIfNullLike(weaponNameSlugToId(params.slug)); - - const weaponName = t(`weapons:MAIN_${weaponId}`); - - const cachedStats = await cachified({ - key: `build-stats-${weaponId}`, - cache, - ttl: ttl(ONE_HOUR_IN_MS), - async getFreshValue() { - return abilityPointCountsToAverages({ - allAbilities: averageAbilityPoints(), - weaponAbilities: averageAbilityPoints(weaponId), - }); - }, - }); - - return { - stats: cachedStats, - weaponName, - weaponId, - meta: { - slug: params.slug!, - breadcrumbText: t("builds:linkButton.abilityStats"), - }, - }; -}; - export default function BuildStatsPage() { const { t } = useTranslation(["weapons", "builds", "analyzer"]); const data = useLoaderData(); diff --git a/app/features/calendar/actions/calendar.$id.report-winners.server.ts b/app/features/calendar/actions/calendar.$id.report-winners.server.ts new file mode 100644 index 000000000..47e05698f --- /dev/null +++ b/app/features/calendar/actions/calendar.$id.report-winners.server.ts @@ -0,0 +1,57 @@ +import type { ActionFunction } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { requireUserId } from "~/features/auth/core/user.server"; +import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; +import { canReportCalendarEventWinners } from "~/permissions"; +import { + errorToastIfFalsy, + notFoundIfFalsy, + safeParseRequestFormData, +} from "~/utils/remix.server"; +import { calendarEventPage } from "~/utils/urls"; +import { + reportWinnersActionSchema, + reportWinnersParamsSchema, +} from "../calendar-schemas"; + +export const action: ActionFunction = async ({ request, params }) => { + const user = await requireUserId(request); + const parsedParams = reportWinnersParamsSchema.parse(params); + const parsedInput = await safeParseRequestFormData({ + request, + schema: reportWinnersActionSchema, + }); + + if (!parsedInput.success) { + return { + errors: parsedInput.errors, + }; + } + + const event = notFoundIfFalsy( + await CalendarRepository.findById({ id: parsedParams.id }), + ); + errorToastIfFalsy( + canReportCalendarEventWinners({ + user, + event, + startTimes: event.startTimes, + }), + "Unauthorized", + ); + + await CalendarRepository.upsertReportedScores({ + eventId: parsedParams.id, + participantCount: parsedInput.data.participantCount, + results: parsedInput.data.team.map((t) => ({ + teamName: t.teamName, + placement: t.placement, + players: t.players.map((p) => ({ + userId: typeof p === "string" ? null : p.id, + name: typeof p === "string" ? p : null, + })), + })), + }); + + throw redirect(calendarEventPage(parsedParams.id)); +}; diff --git a/app/features/calendar/actions/calendar.$id.server.ts b/app/features/calendar/actions/calendar.$id.server.ts new file mode 100644 index 000000000..993837012 --- /dev/null +++ b/app/features/calendar/actions/calendar.$id.server.ts @@ -0,0 +1,54 @@ +import type { ActionFunction } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { z } from "zod"; +import { requireUserId } from "~/features/auth/core/user.server"; +import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; +import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; +import { + clearTournamentDataCache, + tournamentManagerData, +} from "~/features/tournament-bracket/core/Tournament.server"; +import { canDeleteCalendarEvent } from "~/permissions"; +import { databaseTimestampToDate } from "~/utils/dates"; +import { errorToastIfFalsy, notFoundIfFalsy } from "~/utils/remix.server"; +import { CALENDAR_PAGE } from "~/utils/urls"; +import { actualNumber, id } from "~/utils/zod"; + +export const action: ActionFunction = async ({ params, request }) => { + const user = await requireUserId(request); + const parsedParams = z + .object({ id: z.preprocess(actualNumber, id) }) + .parse(params); + const event = notFoundIfFalsy( + await CalendarRepository.findById({ id: parsedParams.id }), + ); + + if (event.tournamentId) { + errorToastIfFalsy( + tournamentManagerData(event.tournamentId).stage.length === 0, + "Tournament has already started", + ); + } else { + errorToastIfFalsy( + canDeleteCalendarEvent({ + user, + event, + startTime: databaseTimestampToDate(event.startTimes[0]), + }), + "Cannot delete event", + ); + } + + await CalendarRepository.deleteById({ + eventId: event.eventId, + tournamentId: event.tournamentId, + }); + + if (event.tournamentId) { + clearTournamentDataCache(event.tournamentId); + ShowcaseTournaments.clearParticipationInfoMap(); + ShowcaseTournaments.clearCachedTournaments(); + } + + throw redirect(CALENDAR_PAGE); +}; diff --git a/app/features/calendar/calendar-schemas.ts b/app/features/calendar/calendar-schemas.ts new file mode 100644 index 000000000..b5be9f4f4 --- /dev/null +++ b/app/features/calendar/calendar-schemas.ts @@ -0,0 +1,88 @@ +import { z } from "zod"; +import { CALENDAR_EVENT_RESULT } from "~/constants"; +import { + actualNumber, + id, + safeJSONParse, + safeSplit, + toArray, +} from "~/utils/zod"; +import { calendarEventTagSchema } from "./actions/calendar.new.server"; + +const playersSchema = z + .array( + z.union([ + z.string().min(1).max(CALENDAR_EVENT_RESULT.MAX_PLAYER_NAME_LENGTH), + z.object({ id }), + ]), + ) + .nonempty({ message: "forms.errors.emptyTeam" }) + .max(CALENDAR_EVENT_RESULT.MAX_PLAYERS_LENGTH) + .refine( + (val) => { + const userIds = val.flatMap((user) => + typeof user === "string" ? [] : user.id, + ); + + return userIds.length === new Set(userIds).size; + }, + { + message: "forms.errors.duplicatePlayer", + }, + ); + +export const reportWinnersActionSchema = z.object({ + participantCount: z.preprocess( + actualNumber, + z + .number() + .int() + .positive() + .max(CALENDAR_EVENT_RESULT.MAX_PARTICIPANTS_COUNT), + ), + team: z.preprocess( + toArray, + z + .array( + z.preprocess( + safeJSONParse, + z.object({ + teamName: z + .string() + .min(1) + .max(CALENDAR_EVENT_RESULT.MAX_TEAM_NAME_LENGTH), + placement: z.preprocess( + actualNumber, + z + .number() + .int() + .positive() + .max(CALENDAR_EVENT_RESULT.MAX_TEAM_PLACEMENT), + ), + players: playersSchema, + }), + ), + ) + .refine( + (val) => val.length === new Set(val.map((team) => team.teamName)).size, + { message: "forms.errors.uniqueTeamName" }, + ), + ), +}); + +export const reportWinnersParamsSchema = z.object({ + id: z.preprocess(actualNumber, id), +}); + +export const loaderWeekSearchParamsSchema = z.object({ + week: z.preprocess(actualNumber, z.number().int().min(1).max(53)), + year: z.preprocess(actualNumber, z.number().int()), +}); + +export const loaderFilterSearchParamsSchema = z.object({ + tags: z.preprocess(safeSplit(), z.array(calendarEventTagSchema)), +}); + +export const loaderTournamentsOnlySearchParamsSchema = z.object({ + tournaments: z.literal("true").nullish(), +}); diff --git a/app/features/calendar/loaders/calendar.$id.report-winners.server.ts b/app/features/calendar/loaders/calendar.$id.report-winners.server.ts new file mode 100644 index 000000000..4c394d628 --- /dev/null +++ b/app/features/calendar/loaders/calendar.$id.report-winners.server.ts @@ -0,0 +1,28 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { requireUserId } from "~/features/auth/core/user.server"; +import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; +import { canReportCalendarEventWinners } from "~/permissions"; +import { notFoundIfFalsy, unauthorizedIfFalsy } from "~/utils/remix.server"; +import { reportWinnersParamsSchema } from "../calendar-schemas"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const parsedParams = reportWinnersParamsSchema.parse(params); + const user = await requireUserId(request); + const event = notFoundIfFalsy( + await CalendarRepository.findById({ id: parsedParams.id }), + ); + + unauthorizedIfFalsy( + canReportCalendarEventWinners({ + user, + event, + startTimes: event.startTimes, + }), + ); + + return { + name: event.name, + participantCount: event.participantCount, + winners: await CalendarRepository.findResultsByEventId(parsedParams.id), + }; +}; diff --git a/app/features/calendar/loaders/calendar.$id.server.ts b/app/features/calendar/loaders/calendar.$id.server.ts new file mode 100644 index 000000000..dbde17528 --- /dev/null +++ b/app/features/calendar/loaders/calendar.$id.server.ts @@ -0,0 +1,29 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { z } from "zod"; +import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; +import { notFoundIfFalsy } from "~/utils/remix.server"; +import { tournamentPage } from "~/utils/urls"; +import { actualNumber, id } from "~/utils/zod"; + +export const loader = async ({ params }: LoaderFunctionArgs) => { + const parsedParams = z + .object({ id: z.preprocess(actualNumber, id) }) + .parse(params); + const event = notFoundIfFalsy( + await CalendarRepository.findById({ + id: parsedParams.id, + includeBadgePrizes: true, + includeMapPool: true, + }), + ); + + if (event.tournamentId) { + throw redirect(tournamentPage(event.tournamentId)); + } + + return { + event, + results: await CalendarRepository.findResultsByEventId(parsedParams.id), + }; +}; diff --git a/app/features/calendar/loaders/calendar.server.ts b/app/features/calendar/loaders/calendar.server.ts new file mode 100644 index 000000000..1d392ceca --- /dev/null +++ b/app/features/calendar/loaders/calendar.server.ts @@ -0,0 +1,105 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { addMonths, subMonths } from "date-fns"; +import type { PersistedCalendarEventTag } from "~/db/tables"; +import { getUserId } from "~/features/auth/core/user.server"; +import { + dateToThisWeeksMonday, + dateToThisWeeksSunday, + dateToWeekNumber, + weekNumberToDate, +} from "~/utils/dates"; +import * as CalendarRepository from "../CalendarRepository.server"; +import { + loaderFilterSearchParamsSchema, + loaderTournamentsOnlySearchParamsSchema, + loaderWeekSearchParamsSchema, +} from "../calendar-schemas"; +import { closeByWeeks } from "../calendar-utils"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const user = await getUserId(request); + const url = new URL(request.url); + + // separate from tags parse so they can fail independently + const parsedWeekParams = loaderWeekSearchParamsSchema.safeParse({ + year: url.searchParams.get("year"), + week: url.searchParams.get("week"), + }); + const parsedFilterParams = loaderFilterSearchParamsSchema.safeParse({ + tags: url.searchParams.get("tags"), + }); + const parsedTournamentsOnlyParams = + loaderTournamentsOnlySearchParamsSchema.safeParse({ + tournaments: url.searchParams.get("tournaments"), + }); + + const mondayDate = dateToThisWeeksMonday(new Date()); + const sundayDate = dateToThisWeeksSunday(new Date()); + const currentWeek = dateToWeekNumber(mondayDate); + + const displayedWeek = parsedWeekParams.success + ? parsedWeekParams.data.week + : currentWeek; + const displayedYear = parsedWeekParams.success + ? parsedWeekParams.data.year + : currentWeek === 1 // handle first week of the year special case + ? sundayDate.getFullYear() + : mondayDate.getFullYear(); + const tagsToFilterBy = parsedFilterParams.success + ? (parsedFilterParams.data.tags as PersistedCalendarEventTag[]) + : []; + const onlyTournaments = parsedTournamentsOnlyParams.success + ? Boolean(parsedTournamentsOnlyParams.data.tournaments) + : false; + + return { + currentWeek, + displayedWeek, + currentDay: new Date().getDay(), + nearbyStartTimes: await CalendarRepository.startTimesOfRange({ + startTime: subMonths( + weekNumberToDate({ week: displayedWeek, year: displayedYear }), + 1, + ), + endTime: addMonths( + weekNumberToDate({ week: displayedWeek, year: displayedYear }), + 1, + ), + tagsToFilterBy, + onlyTournaments, + }), + weeks: closeByWeeks({ week: displayedWeek, year: displayedYear }), + events: await fetchEventsOfWeek({ + week: displayedWeek, + year: displayedYear, + tagsToFilterBy, + onlyTournaments, + }), + eventsToReport: user + ? await CalendarRepository.eventsToReport(user.id) + : [], + }; +}; + +function fetchEventsOfWeek(args: { + week: number; + year: number; + tagsToFilterBy: PersistedCalendarEventTag[]; + onlyTournaments: boolean; +}) { + const startTime = weekNumberToDate(args); + + const endTime = new Date(startTime); + endTime.setDate(endTime.getDate() + 7); + + // handle timezone mismatch between server and client + startTime.setHours(startTime.getHours() - 12); + endTime.setHours(endTime.getHours() + 12); + + return CalendarRepository.findAllBetweenTwoTimestamps({ + startTime, + endTime, + tagsToFilterBy: args.tagsToFilterBy, + onlyTournaments: args.onlyTournaments, + }); +} diff --git a/app/features/calendar/routes/calendar.$id.report-winners.tsx b/app/features/calendar/routes/calendar.$id.report-winners.tsx index fbf72ce9f..595b96def 100644 --- a/app/features/calendar/routes/calendar.$id.report-winners.tsx +++ b/app/features/calendar/routes/calendar.$id.report-winners.tsx @@ -1,14 +1,8 @@ -import { redirect } from "@remix-run/node"; -import type { - ActionFunction, - LoaderFunctionArgs, - SerializeFrom, -} from "@remix-run/node"; +import type { SerializeFrom } from "@remix-run/node"; import { Form, useLoaderData } from "@remix-run/react"; import clsx from "clsx"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import { z } from "zod"; import { Button } from "~/components/Button"; import { FormErrors } from "~/components/FormErrors"; import { FormMessage } from "~/components/FormMessage"; @@ -16,153 +10,17 @@ import { Label } from "~/components/Label"; import { Main } from "~/components/Main"; import { UserSearch } from "~/components/UserSearch"; import { CALENDAR_EVENT_RESULT } from "~/constants"; -import { requireUserId } from "~/features/auth/core/user.server"; -import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; -import { canReportCalendarEventWinners } from "~/permissions"; -import { - type SendouRouteHandle, - errorToastIfFalsy, - notFoundIfFalsy, - safeParseRequestFormData, - unauthorizedIfFalsy, -} from "~/utils/remix.server"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import type { Unpacked } from "~/utils/types"; -import { calendarEventPage } from "~/utils/urls"; -import { actualNumber, id, safeJSONParse, toArray } from "~/utils/zod"; -const playersSchema = z - .array( - z.union([ - z.string().min(1).max(CALENDAR_EVENT_RESULT.MAX_PLAYER_NAME_LENGTH), - z.object({ id }), - ]), - ) - .nonempty({ message: "forms.errors.emptyTeam" }) - .max(CALENDAR_EVENT_RESULT.MAX_PLAYERS_LENGTH) - .refine( - (val) => { - const userIds = val.flatMap((user) => - typeof user === "string" ? [] : user.id, - ); - - return userIds.length === new Set(userIds).size; - }, - { - message: "forms.errors.duplicatePlayer", - }, - ); - -const reportWinnersActionSchema = z.object({ - participantCount: z.preprocess( - actualNumber, - z - .number() - .int() - .positive() - .max(CALENDAR_EVENT_RESULT.MAX_PARTICIPANTS_COUNT), - ), - team: z.preprocess( - toArray, - z - .array( - z.preprocess( - safeJSONParse, - z.object({ - teamName: z - .string() - .min(1) - .max(CALENDAR_EVENT_RESULT.MAX_TEAM_NAME_LENGTH), - placement: z.preprocess( - actualNumber, - z - .number() - .int() - .positive() - .max(CALENDAR_EVENT_RESULT.MAX_TEAM_PLACEMENT), - ), - players: playersSchema, - }), - ), - ) - .refine( - (val) => val.length === new Set(val.map((team) => team.teamName)).size, - { message: "forms.errors.uniqueTeamName" }, - ), - ), -}); - -const reportWinnersParamsSchema = z.object({ - id: z.preprocess(actualNumber, id), -}); - -export const action: ActionFunction = async ({ request, params }) => { - const user = await requireUserId(request); - const parsedParams = reportWinnersParamsSchema.parse(params); - const parsedInput = await safeParseRequestFormData({ - request, - schema: reportWinnersActionSchema, - }); - - if (!parsedInput.success) { - return { - errors: parsedInput.errors, - }; - } - - const event = notFoundIfFalsy( - await CalendarRepository.findById({ id: parsedParams.id }), - ); - errorToastIfFalsy( - canReportCalendarEventWinners({ - user, - event, - startTimes: event.startTimes, - }), - "Unauthorized", - ); - - await CalendarRepository.upsertReportedScores({ - eventId: parsedParams.id, - participantCount: parsedInput.data.participantCount, - results: parsedInput.data.team.map((t) => ({ - teamName: t.teamName, - placement: t.placement, - players: t.players.map((p) => ({ - userId: typeof p === "string" ? null : p.id, - name: typeof p === "string" ? p : null, - })), - })), - }); - - throw redirect(calendarEventPage(parsedParams.id)); -}; +import { action } from "../actions/calendar.$id.report-winners.server"; +import { loader } from "../loaders/calendar.$id.report-winners.server"; +export { loader, action }; export const handle: SendouRouteHandle = { i18n: "calendar", }; -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const parsedParams = reportWinnersParamsSchema.parse(params); - const user = await requireUserId(request); - const event = notFoundIfFalsy( - await CalendarRepository.findById({ id: parsedParams.id }), - ); - - unauthorizedIfFalsy( - canReportCalendarEventWinners({ - user, - event, - startTimes: event.startTimes, - }), - ); - - return { - name: event.name, - participantCount: event.participantCount, - winners: await CalendarRepository.findResultsByEventId(parsedParams.id), - }; -}; - export default function ReportWinnersPage() { const { t } = useTranslation(["common", "calendar"]); const data = useLoaderData(); diff --git a/app/features/calendar/routes/calendar.$id.tsx b/app/features/calendar/routes/calendar.$id.tsx index da54d9a75..3580c07f9 100644 --- a/app/features/calendar/routes/calendar.$id.tsx +++ b/app/features/calendar/routes/calendar.$id.tsx @@ -1,16 +1,9 @@ -import type { - ActionFunction, - LoaderFunctionArgs, - MetaFunction, - SerializeFrom, -} from "@remix-run/node"; -import { redirect } from "@remix-run/node"; +import type { MetaFunction, SerializeFrom } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; import { Link } from "@remix-run/react/dist/components"; import clsx from "clsx"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import { z } from "zod"; import { Avatar } from "~/components/Avatar"; import { Button, LinkButton } from "~/components/Button"; import { FormWithConfirm } from "~/components/FormWithConfirm"; @@ -21,14 +14,7 @@ import { Placement } from "~/components/Placement"; import { Section } from "~/components/Section"; import { Table } from "~/components/Table"; import { useUser } from "~/features/auth/core/user"; -import { requireUserId } from "~/features/auth/core/user.server"; -import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; -import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; -import { - clearTournamentDataCache, - tournamentManagerData, -} from "~/features/tournament-bracket/core/Tournament.server"; import { useIsMounted } from "~/hooks/useIsMounted"; import { canDeleteCalendarEvent, @@ -36,11 +22,7 @@ import { canReportCalendarEventWinners, } from "~/permissions"; import { databaseTimestampToDate } from "~/utils/dates"; -import { - type SendouRouteHandle, - errorToastIfFalsy, - notFoundIfFalsy, -} from "~/utils/remix.server"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import { CALENDAR_PAGE, calendarEditPage, @@ -49,55 +31,18 @@ import { navIconUrl, readonlyMapsPage, resolveBaseUrl, - tournamentPage, userPage, } from "~/utils/urls"; -import { actualNumber, id } from "~/utils/zod"; import { metaTags } from "../../../utils/remix"; import { Tags } from "../components/Tags"; +import { action } from "../actions/calendar.$id.server"; +import { loader } from "../loaders/calendar.$id.server"; +export { loader, action }; + import "~/styles/calendar-event.css"; import "~/styles/maps.css"; -export const action: ActionFunction = async ({ params, request }) => { - const user = await requireUserId(request); - const parsedParams = z - .object({ id: z.preprocess(actualNumber, id) }) - .parse(params); - const event = notFoundIfFalsy( - await CalendarRepository.findById({ id: parsedParams.id }), - ); - - if (event.tournamentId) { - errorToastIfFalsy( - tournamentManagerData(event.tournamentId).stage.length === 0, - "Tournament has already started", - ); - } else { - errorToastIfFalsy( - canDeleteCalendarEvent({ - user, - event, - startTime: databaseTimestampToDate(event.startTimes[0]), - }), - "Cannot delete event", - ); - } - - await CalendarRepository.deleteById({ - eventId: event.eventId, - tournamentId: event.tournamentId, - }); - - if (event.tournamentId) { - clearTournamentDataCache(event.tournamentId); - ShowcaseTournaments.clearParticipationInfoMap(); - ShowcaseTournaments.clearCachedTournaments(); - } - - throw redirect(CALENDAR_PAGE); -}; - export const meta: MetaFunction = (args) => { const data = args.data as SerializeFrom; @@ -134,28 +79,6 @@ export const handle: SendouRouteHandle = { }, }; -export const loader = async ({ params }: LoaderFunctionArgs) => { - const parsedParams = z - .object({ id: z.preprocess(actualNumber, id) }) - .parse(params); - const event = notFoundIfFalsy( - await CalendarRepository.findById({ - id: parsedParams.id, - includeBadgePrizes: true, - includeMapPool: true, - }), - ); - - if (event.tournamentId) { - throw redirect(tournamentPage(event.tournamentId)); - } - - return { - event, - results: await CalendarRepository.findResultsByEventId(parsedParams.id), - }; -}; - export default function CalendarEventPage() { const user = useUser(); const data = useLoaderData(); diff --git a/app/features/calendar/routes/calendar.new.tsx b/app/features/calendar/routes/calendar.new.tsx index 1808d817e..82a3b5814 100644 --- a/app/features/calendar/routes/calendar.new.tsx +++ b/app/features/calendar/routes/calendar.new.tsx @@ -53,6 +53,7 @@ import "~/styles/calendar-new.css"; import "~/styles/maps.css"; import { SendouSwitch } from "~/components/elements/Switch"; import { metaTags } from "~/utils/remix"; + import { action } from "../actions/calendar.new.server"; import { loader } from "../loaders/calendar.new.server"; export { loader, action }; diff --git a/app/features/calendar/routes/calendar.tsx b/app/features/calendar/routes/calendar.tsx index 5d6efac89..3c52ee4c9 100644 --- a/app/features/calendar/routes/calendar.tsx +++ b/app/features/calendar/routes/calendar.tsx @@ -1,30 +1,23 @@ -import type { - LoaderFunctionArgs, - MetaFunction, - SerializeFrom, -} from "@remix-run/node"; +import type { MetaFunction, SerializeFrom } from "@remix-run/node"; import { Link, useLoaderData, useSearchParams } from "@remix-run/react"; import clsx from "clsx"; -import { addMonths, subMonths } from "date-fns"; import React from "react"; import { Flipped, Flipper } from "react-flip-toolkit"; import { useTranslation } from "react-i18next"; -import { z } from "zod"; import { Alert } from "~/components/Alert"; import { Avatar } from "~/components/Avatar"; import { LinkButton } from "~/components/Button"; import { Divider } from "~/components/Divider"; import { Main } from "~/components/Main"; +import { SendouSwitch } from "~/components/elements/Switch"; import { UsersIcon } from "~/components/icons/Users"; -import { getUserId } from "~/features/auth/core/user.server"; +import type { CalendarEventTag } from "~/db/tables"; import { currentSeason } from "~/features/mmr/season"; import { HACKY_resolvePicture } from "~/features/tournament/tournament-utils"; import { useIsMounted } from "~/hooks/useIsMounted"; import { joinListToNaturalString } from "~/utils/arrays"; import { databaseTimestampToDate, - dateToThisWeeksMonday, - dateToThisWeeksSunday, dateToWeekNumber, dayToWeekStartsAtMondayDay, getWeekStartsAtMondayDay, @@ -41,17 +34,15 @@ import { tournamentPage, userSubmittedImage, } from "~/utils/urls"; -import { actualNumber, safeSplit } from "~/utils/zod"; import { Label } from "../../../components/Label"; import { metaTags } from "../../../utils/remix"; -import * as CalendarRepository from "../CalendarRepository.server"; -import { calendarEventTagSchema } from "../actions/calendar.new.server"; import { CALENDAR_EVENT } from "../calendar-constants"; -import { closeByWeeks } from "../calendar-utils"; import { Tags } from "../components/Tags"; + +import { loader } from "../loaders/calendar.server"; +export { loader }; + import "~/styles/calendar.css"; -import { SendouSwitch } from "~/components/elements/Switch"; -import type { CalendarEventTag, PersistedCalendarEventTag } from "~/db/tables"; export const meta: MetaFunction = (args) => { const data = args.data as SerializeFrom | null; @@ -89,107 +80,6 @@ export const handle: SendouRouteHandle = { }), }; -const loaderWeekSearchParamsSchema = z.object({ - week: z.preprocess(actualNumber, z.number().int().min(1).max(53)), - year: z.preprocess(actualNumber, z.number().int()), -}); - -const loaderFilterSearchParamsSchema = z.object({ - tags: z.preprocess(safeSplit(), z.array(calendarEventTagSchema)), -}); - -const loaderTournamentsOnlySearchParamsSchema = z.object({ - tournaments: z.literal("true").nullish(), -}); - -export const loader = async ({ request }: LoaderFunctionArgs) => { - const user = await getUserId(request); - const url = new URL(request.url); - - // separate from tags parse so they can fail independently - const parsedWeekParams = loaderWeekSearchParamsSchema.safeParse({ - year: url.searchParams.get("year"), - week: url.searchParams.get("week"), - }); - const parsedFilterParams = loaderFilterSearchParamsSchema.safeParse({ - tags: url.searchParams.get("tags"), - }); - const parsedTournamentsOnlyParams = - loaderTournamentsOnlySearchParamsSchema.safeParse({ - tournaments: url.searchParams.get("tournaments"), - }); - - const mondayDate = dateToThisWeeksMonday(new Date()); - const sundayDate = dateToThisWeeksSunday(new Date()); - const currentWeek = dateToWeekNumber(mondayDate); - - const displayedWeek = parsedWeekParams.success - ? parsedWeekParams.data.week - : currentWeek; - const displayedYear = parsedWeekParams.success - ? parsedWeekParams.data.year - : currentWeek === 1 // handle first week of the year special case - ? sundayDate.getFullYear() - : mondayDate.getFullYear(); - const tagsToFilterBy = parsedFilterParams.success - ? (parsedFilterParams.data.tags as PersistedCalendarEventTag[]) - : []; - const onlyTournaments = parsedTournamentsOnlyParams.success - ? Boolean(parsedTournamentsOnlyParams.data.tournaments) - : false; - - return { - currentWeek, - displayedWeek, - currentDay: new Date().getDay(), - nearbyStartTimes: await CalendarRepository.startTimesOfRange({ - startTime: subMonths( - weekNumberToDate({ week: displayedWeek, year: displayedYear }), - 1, - ), - endTime: addMonths( - weekNumberToDate({ week: displayedWeek, year: displayedYear }), - 1, - ), - tagsToFilterBy, - onlyTournaments, - }), - weeks: closeByWeeks({ week: displayedWeek, year: displayedYear }), - events: await fetchEventsOfWeek({ - week: displayedWeek, - year: displayedYear, - tagsToFilterBy, - onlyTournaments, - }), - eventsToReport: user - ? await CalendarRepository.eventsToReport(user.id) - : [], - }; -}; - -function fetchEventsOfWeek(args: { - week: number; - year: number; - tagsToFilterBy: PersistedCalendarEventTag[]; - onlyTournaments: boolean; -}) { - const startTime = weekNumberToDate(args); - - const endTime = new Date(startTime); - endTime.setDate(endTime.getDate() + 7); - - // handle timezone mismatch between server and client - startTime.setHours(startTime.getHours() - 12); - endTime.setHours(endTime.getHours() + 12); - - return CalendarRepository.findAllBetweenTwoTimestamps({ - startTime, - endTime, - tagsToFilterBy: args.tagsToFilterBy, - onlyTournaments: args.onlyTournaments, - }); -} - export default function CalendarPage() { const { t } = useTranslation("calendar"); const data = useLoaderData(); diff --git a/app/features/front-page/routes/index.tsx b/app/features/front-page/routes/index.tsx index b2da0138e..68cc97631 100644 --- a/app/features/front-page/routes/index.tsx +++ b/app/features/front-page/routes/index.tsx @@ -43,8 +43,8 @@ import { userSubmittedImage, } from "~/utils/urls"; import type * as ShowcaseTournaments from "../core/ShowcaseTournaments.server"; -import { type LeaderboardEntry, loader } from "../loaders/index.server"; +import { type LeaderboardEntry, loader } from "../loaders/index.server"; export { loader }; import "~/styles/front.css"; diff --git a/app/features/img-upload/actions/upload.admin.server.ts b/app/features/img-upload/actions/upload.admin.server.ts new file mode 100644 index 000000000..ce38f99e7 --- /dev/null +++ b/app/features/img-upload/actions/upload.admin.server.ts @@ -0,0 +1,32 @@ +import type { ActionFunction } from "@remix-run/node"; +import { requireUserId } from "~/features/auth/core/user.server"; +import { clearTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server"; +import { isMod } from "~/permissions"; +import { + badRequestIfFalsy, + errorToastIfFalsy, + parseRequestPayload, +} from "~/utils/remix.server"; +import * as ImageRepository from "../ImageRepository.server"; +import { validateImage } from "../queries/validateImage"; +import { validateImageSchema } from "../upload-schemas.server"; + +export const action: ActionFunction = async ({ request }) => { + const user = await requireUserId(request); + const data = await parseRequestPayload({ + schema: validateImageSchema, + request, + }); + + errorToastIfFalsy(isMod(user), "Only admins can validate images"); + + const image = badRequestIfFalsy(await ImageRepository.findById(data.imageId)); + + validateImage(data.imageId); + + if (image.tournamentId) { + clearTournamentDataCache(image.tournamentId); + } + + return null; +}; diff --git a/app/features/img-upload/loaders/upload.admin.server.ts b/app/features/img-upload/loaders/upload.admin.server.ts new file mode 100644 index 000000000..35ea48652 --- /dev/null +++ b/app/features/img-upload/loaders/upload.admin.server.ts @@ -0,0 +1,17 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { requireUserId } from "~/features/auth/core/user.server"; +import { isMod } from "~/permissions"; +import { notFoundIfFalsy } from "~/utils/remix.server"; +import { countAllUnvalidatedImg } from "../queries/countAllUnvalidatedImg.server"; +import { oneUnvalidatedImage } from "../queries/oneUnvalidatedImage"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const user = await requireUserId(request); + + notFoundIfFalsy(isMod(user)); + + return { + image: oneUnvalidatedImage(), + unvalidatedImgCount: countAllUnvalidatedImg(), + }; +}; diff --git a/app/features/img-upload/loaders/upload.server.ts b/app/features/img-upload/loaders/upload.server.ts new file mode 100644 index 000000000..e3528a490 --- /dev/null +++ b/app/features/img-upload/loaders/upload.server.ts @@ -0,0 +1,30 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { requireUser } from "~/features/auth/core/user.server"; +import * as TeamRepository from "~/features/team/TeamRepository.server"; +import { isTeamManager } from "~/features/team/team-utils"; +import { countUnvalidatedImg } from "../queries/countUnvalidatedImg.server"; +import { requestToImgType } from "../upload-utils"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const user = await requireUser(request); + const validatedType = requestToImgType(request); + + if (!validatedType) { + throw redirect("/"); + } + + if (validatedType === "team-pfp" || validatedType === "team-banner") { + const teamCustomUrl = new URL(request.url).searchParams.get("team") ?? ""; + const team = await TeamRepository.findByCustomUrl(teamCustomUrl); + + if (!team || !isTeamManager({ team, user })) { + throw redirect("/"); + } + } + + return { + type: validatedType, + unvalidatedImages: countUnvalidatedImg(user.id), + }; +}; diff --git a/app/features/img-upload/routes/upload.admin.tsx b/app/features/img-upload/routes/upload.admin.tsx index 3093b1ab4..698f8410e 100644 --- a/app/features/img-upload/routes/upload.admin.tsx +++ b/app/features/img-upload/routes/upload.admin.tsx @@ -1,53 +1,11 @@ -import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node"; import { Form, useLoaderData } from "@remix-run/react"; import { Main } from "~/components/Main"; import { SubmitButton } from "~/components/SubmitButton"; -import { requireUserId } from "~/features/auth/core/user.server"; -import { clearTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server"; -import { isMod } from "~/permissions"; -import { - badRequestIfFalsy, - errorToastIfFalsy, - notFoundIfFalsy, - parseRequestPayload, -} from "~/utils/remix.server"; import { userSubmittedImage } from "~/utils/urls"; -import * as ImageRepository from "../ImageRepository.server"; -import { countAllUnvalidatedImg } from "../queries/countAllUnvalidatedImg.server"; -import { oneUnvalidatedImage } from "../queries/oneUnvalidatedImage"; -import { validateImage } from "../queries/validateImage"; -import { validateImageSchema } from "../upload-schemas.server"; -export const action: ActionFunction = async ({ request }) => { - const user = await requireUserId(request); - const data = await parseRequestPayload({ - schema: validateImageSchema, - request, - }); - - errorToastIfFalsy(isMod(user), "Only admins can validate images"); - - const image = badRequestIfFalsy(await ImageRepository.findById(data.imageId)); - - validateImage(data.imageId); - - if (image.tournamentId) { - clearTournamentDataCache(image.tournamentId); - } - - return null; -}; - -export const loader = async ({ request }: LoaderFunctionArgs) => { - const user = await requireUserId(request); - - notFoundIfFalsy(isMod(user)); - - return { - image: oneUnvalidatedImage(), - unvalidatedImgCount: countAllUnvalidatedImg(), - }; -}; +import { action } from "../actions/upload.admin.server"; +import { loader } from "../loaders/upload.admin.server"; +export { action, loader }; export default function ImageUploadAdminPage() { return ( diff --git a/app/features/img-upload/routes/upload.tsx b/app/features/img-upload/routes/upload.tsx index 13d4b5dbb..13b3ca927 100644 --- a/app/features/img-upload/routes/upload.tsx +++ b/app/features/img-upload/routes/upload.tsx @@ -1,44 +1,16 @@ -import type { LoaderFunctionArgs } from "@remix-run/node"; -import { redirect } from "@remix-run/node"; import { useFetcher, useLoaderData } from "@remix-run/react"; import Compressor from "compressorjs"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { Button } from "~/components/Button"; import { Main } from "~/components/Main"; -import { requireUser } from "~/features/auth/core/user.server"; -import * as TeamRepository from "~/features/team/TeamRepository.server"; -import { isTeamManager } from "~/features/team/team-utils"; import invariant from "~/utils/invariant"; -import { action } from "../actions/upload.server"; -import { countUnvalidatedImg } from "../queries/countUnvalidatedImg.server"; import { imgTypeToDimensions, imgTypeToStyle } from "../upload-constants"; import type { ImageUploadType } from "../upload-types"; -import { requestToImgType } from "../upload-utils"; -export { action }; -export const loader = async ({ request }: LoaderFunctionArgs) => { - const user = await requireUser(request); - const validatedType = requestToImgType(request); - - if (!validatedType) { - throw redirect("/"); - } - - if (validatedType === "team-pfp" || validatedType === "team-banner") { - const teamCustomUrl = new URL(request.url).searchParams.get("team") ?? ""; - const team = await TeamRepository.findByCustomUrl(teamCustomUrl); - - if (!team || !isTeamManager({ team, user })) { - throw redirect("/"); - } - } - - return { - type: validatedType, - unvalidatedImages: countUnvalidatedImg(user.id), - }; -}; +import { action } from "../actions/upload.server"; +import { loader } from "../loaders/upload.server"; +export { action, loader }; export default function FileUploadPage() { const { t } = useTranslation(["common"]); diff --git a/app/features/leaderboards/leaderboards-constants.ts b/app/features/leaderboards/leaderboards-constants.ts index f6038d653..02a95950b 100644 --- a/app/features/leaderboards/leaderboards-constants.ts +++ b/app/features/leaderboards/leaderboards-constants.ts @@ -27,3 +27,6 @@ export const LEADERBOARD_TYPES = [ export const IGNORED_TEAMS: Map = new Map().set(5, [ [9403, 13562, 15916, 38062], // Snooze ]); + +export const TYPE_SEARCH_PARAM_KEY = "type"; +export const SEASON_SEARCH_PARAM_KEY = "season"; diff --git a/app/features/leaderboards/loaders/leaderboards.server.ts b/app/features/leaderboards/loaders/leaderboards.server.ts new file mode 100644 index 000000000..c6b625df1 --- /dev/null +++ b/app/features/leaderboards/loaders/leaderboards.server.ts @@ -0,0 +1,103 @@ +import { cachified } from "@epic-web/cachified"; +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { HALF_HOUR_IN_MS } from "~/constants"; +import { getUser } from "~/features/auth/core/user.server"; +import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server"; +import { allSeasons, currentOrPreviousSeason } from "~/features/mmr/season"; +import type { + MainWeaponId, + RankedModeShort, + weaponCategories, +} from "~/modules/in-game-lists"; +import { cache, ttl } from "~/utils/cache.server"; +import { + cachedFullUserLeaderboard, + filterByWeaponCategory, + ownEntryPeek, +} from "../core/leaderboards.server"; +import { + DEFAULT_LEADERBOARD_MAX_SIZE, + LEADERBOARD_TYPES, + SEASON_SEARCH_PARAM_KEY, + TYPE_SEARCH_PARAM_KEY, + WEAPON_LEADERBOARD_MAX_SIZE, +} from "../leaderboards-constants"; +import { + allXPLeaderboard, + modeXPLeaderboard, + weaponXPLeaderboard, +} from "../queries/XPLeaderboard.server"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const user = await getUser(request); + const unvalidatedType = new URL(request.url).searchParams.get( + TYPE_SEARCH_PARAM_KEY, + ); + const unvalidatedSeason = new URL(request.url).searchParams.get( + SEASON_SEARCH_PARAM_KEY, + ); + + const type = + LEADERBOARD_TYPES.find((type) => type === unvalidatedType) ?? + LEADERBOARD_TYPES[0]; + const season = + allSeasons(new Date()).find( + (s) => unvalidatedSeason && s === Number(unvalidatedSeason), + ) ?? currentOrPreviousSeason(new Date())!.nth; + + const fullUserLeaderboard = type.includes("USER") + ? await cachedFullUserLeaderboard(season) + : null; + + const userLeaderboard = fullUserLeaderboard?.slice( + 0, + DEFAULT_LEADERBOARD_MAX_SIZE, + ); + + const teamLeaderboard = + type === "TEAM" || type === "TEAM-ALL" + ? await cachified({ + key: `team-leaderboard-season-${season}-${type}`, + cache, + ttl: ttl(HALF_HOUR_IN_MS), + async getFreshValue() { + return LeaderboardRepository.teamLeaderboardBySeason({ + season, + onlyOneEntryPerUser: type !== "TEAM-ALL", + }); + }, + }) + : null; + + const isWeaponLeaderboard = userLeaderboard && type !== "USER"; + + const filteredLeaderboard = isWeaponLeaderboard + ? filterByWeaponCategory( + fullUserLeaderboard!, + type.split("-")[1] as (typeof weaponCategories)[number]["name"], + ).slice(0, WEAPON_LEADERBOARD_MAX_SIZE) + : userLeaderboard; + + const showOwnEntryPeek = fullUserLeaderboard && !isWeaponLeaderboard && user; + + return { + userLeaderboard: filteredLeaderboard ?? userLeaderboard, + ownEntryPeek: showOwnEntryPeek + ? ownEntryPeek({ + leaderboard: fullUserLeaderboard, + season, + userId: user.id, + }) + : null, + teamLeaderboard, + xpLeaderboard: + type === "XP-ALL" + ? allXPLeaderboard() + : type.startsWith("XP-MODE") + ? modeXPLeaderboard(type.split("-")[2] as RankedModeShort) + : type.startsWith("XP-WEAPON") + ? weaponXPLeaderboard(Number(type.split("-")[2]) as MainWeaponId) + : null, + season, + }; +}; diff --git a/app/features/leaderboards/routes/leaderboards.tsx b/app/features/leaderboards/routes/leaderboards.tsx index d9fb7e12d..5748ca57f 100644 --- a/app/features/leaderboards/routes/leaderboards.tsx +++ b/app/features/leaderboards/routes/leaderboards.tsx @@ -1,32 +1,15 @@ -import { cachified } from "@epic-web/cachified"; -import type { - LoaderFunctionArgs, - MetaFunction, - SerializeFrom, -} from "@remix-run/node"; +import type { MetaFunction, SerializeFrom } from "@remix-run/node"; import { Link, useLoaderData, useSearchParams } from "@remix-run/react"; import React from "react"; import { useTranslation } from "react-i18next"; import { Avatar } from "~/components/Avatar"; import { TierImage, WeaponImage } from "~/components/Image"; import { Main } from "~/components/Main"; -import { HALF_HOUR_IN_MS } from "~/constants"; -import { getUser } from "~/features/auth/core/user.server"; -import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server"; import { ordinalToSp } from "~/features/mmr/mmr-utils"; -import { - allSeasons, - currentOrPreviousSeason, - currentSeason, -} from "~/features/mmr/season"; +import { allSeasons, currentSeason } from "~/features/mmr/season"; import type { SkillTierInterval } from "~/features/mmr/tiered.server"; -import { - type MainWeaponId, - type RankedModeShort, - weaponCategories, -} from "~/modules/in-game-lists"; +import { weaponCategories } from "~/modules/in-game-lists"; import { rankedModesShort } from "~/modules/in-game-lists/modes"; -import { cache, ttl } from "~/utils/cache.server"; import { metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { @@ -41,22 +24,15 @@ import { import { InfoPopover } from "../../../components/InfoPopover"; import { TopTenPlayer } from "../components/TopTenPlayer"; import { - cachedFullUserLeaderboard, - filterByWeaponCategory, - ownEntryPeek, -} from "../core/leaderboards.server"; -import { - DEFAULT_LEADERBOARD_MAX_SIZE, LEADERBOARD_TYPES, - WEAPON_LEADERBOARD_MAX_SIZE, + SEASON_SEARCH_PARAM_KEY, + TYPE_SEARCH_PARAM_KEY, } from "../leaderboards-constants"; import { seasonHasTopTen } from "../leaderboards-utils"; -import { - type XPLeaderboardItem, - allXPLeaderboard, - modeXPLeaderboard, - weaponXPLeaderboard, -} from "../queries/XPLeaderboard.server"; +import type { XPLeaderboardItem } from "../queries/XPLeaderboard.server"; + +import { loader } from "../loaders/leaderboards.server"; +export { loader }; import "../../top-search/top-search.css"; @@ -83,83 +59,6 @@ export const meta: MetaFunction = (args) => { }); }; -const TYPE_SEARCH_PARAM_KEY = "type"; -const SEASON_SEARCH_PARAM_KEY = "season"; - -export const loader = async ({ request }: LoaderFunctionArgs) => { - const user = await getUser(request); - const unvalidatedType = new URL(request.url).searchParams.get( - TYPE_SEARCH_PARAM_KEY, - ); - const unvalidatedSeason = new URL(request.url).searchParams.get( - SEASON_SEARCH_PARAM_KEY, - ); - - const type = - LEADERBOARD_TYPES.find((type) => type === unvalidatedType) ?? - LEADERBOARD_TYPES[0]; - const season = - allSeasons(new Date()).find( - (s) => unvalidatedSeason && s === Number(unvalidatedSeason), - ) ?? currentOrPreviousSeason(new Date())!.nth; - - const fullUserLeaderboard = type.includes("USER") - ? await cachedFullUserLeaderboard(season) - : null; - - const userLeaderboard = fullUserLeaderboard?.slice( - 0, - DEFAULT_LEADERBOARD_MAX_SIZE, - ); - - const teamLeaderboard = - type === "TEAM" || type === "TEAM-ALL" - ? await cachified({ - key: `team-leaderboard-season-${season}-${type}`, - cache, - ttl: ttl(HALF_HOUR_IN_MS), - async getFreshValue() { - return LeaderboardRepository.teamLeaderboardBySeason({ - season, - onlyOneEntryPerUser: type !== "TEAM-ALL", - }); - }, - }) - : null; - - const isWeaponLeaderboard = userLeaderboard && type !== "USER"; - - const filteredLeaderboard = isWeaponLeaderboard - ? filterByWeaponCategory( - fullUserLeaderboard!, - type.split("-")[1] as (typeof weaponCategories)[number]["name"], - ).slice(0, WEAPON_LEADERBOARD_MAX_SIZE) - : userLeaderboard; - - const showOwnEntryPeek = fullUserLeaderboard && !isWeaponLeaderboard && user; - - return { - userLeaderboard: filteredLeaderboard ?? userLeaderboard, - ownEntryPeek: showOwnEntryPeek - ? ownEntryPeek({ - leaderboard: fullUserLeaderboard, - season, - userId: user.id, - }) - : null, - teamLeaderboard, - xpLeaderboard: - type === "XP-ALL" - ? allXPLeaderboard() - : type.startsWith("XP-MODE") - ? modeXPLeaderboard(type.split("-")[2] as RankedModeShort) - : type.startsWith("XP-WEAPON") - ? weaponXPLeaderboard(Number(type.split("-")[2]) as MainWeaponId) - : null, - season, - }; -}; - export default function LeaderboardsPage() { const { t } = useTranslation(["common", "game-misc", "weapons"]); const [searchParams, setSearchParams] = useSearchParams(); diff --git a/app/features/map-list-generator/loaders/maps.server.ts b/app/features/map-list-generator/loaders/maps.server.ts new file mode 100644 index 000000000..3ff4776ff --- /dev/null +++ b/app/features/map-list-generator/loaders/maps.server.ts @@ -0,0 +1,29 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { getUserId } from "~/features/auth/core/user.server"; +import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const user = await getUserId(request); + const url = new URL(request.url); + const calendarEventId = url.searchParams.get("eventId"); + + const event = calendarEventId + ? await CalendarRepository.findById({ + id: Number(calendarEventId), + includeMapPool: true, + }) + : undefined; + + return { + calendarEvent: event + ? { + id: event.eventId, + name: event.name, + mapPool: event.mapPool, + } + : undefined, + recentEventsWithMapPools: user + ? await CalendarRepository.findRecentMapPoolsByAuthorId(user.id) + : undefined, + }; +}; diff --git a/app/features/map-list-generator/routes/maps.tsx b/app/features/map-list-generator/routes/maps.tsx index 853b6fc3a..18213ada6 100644 --- a/app/features/map-list-generator/routes/maps.tsx +++ b/app/features/map-list-generator/routes/maps.tsx @@ -1,4 +1,4 @@ -import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; +import type { MetaFunction } from "@remix-run/node"; import type { ShouldRevalidateFunction } from "@remix-run/react"; import { Link, useLoaderData, useSearchParams } from "@remix-run/react"; import * as React from "react"; @@ -8,11 +8,13 @@ import { Button } from "~/components/Button"; import { Label } from "~/components/Label"; import { Main } from "~/components/Main"; import { MapPoolSelector, MapPoolStages } from "~/components/MapPoolSelector"; +import { SendouSwitch } from "~/components/elements/Switch"; import { EditIcon } from "~/components/icons/Edit"; -import { getUserId } from "~/features/auth/core/user.server"; -import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; +import type { Tables } from "~/db/tables"; import { type ModeWithStage, stageIds } from "~/modules/in-game-lists"; +import "~/styles/maps.css"; import invariant from "~/utils/invariant"; +import { metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { MAPS_URL, @@ -24,10 +26,9 @@ import { generateMapList } from "../core/map-list-generator/map-list"; import { modesOrder } from "../core/map-list-generator/modes"; import { mapPoolToNonEmptyModes } from "../core/map-list-generator/utils"; import { MapPool } from "../core/map-pool"; -import "~/styles/maps.css"; -import { SendouSwitch } from "~/components/elements/Switch"; -import type { Tables } from "~/db/tables"; -import { metaTags } from "~/utils/remix"; + +import { loader } from "../loaders/maps.server"; +export { loader }; const AMOUNT_OF_MAPS_IN_MAP_LIST = stageIds.length * 2; @@ -57,32 +58,6 @@ export const handle: SendouRouteHandle = { }), }; -export const loader = async ({ request }: LoaderFunctionArgs) => { - const user = await getUserId(request); - const url = new URL(request.url); - const calendarEventId = url.searchParams.get("eventId"); - - const event = calendarEventId - ? await CalendarRepository.findById({ - id: Number(calendarEventId), - includeMapPool: true, - }) - : undefined; - - return { - calendarEvent: event - ? { - id: event.eventId, - name: event.name, - mapPool: event.mapPool, - } - : undefined, - recentEventsWithMapPools: user - ? await CalendarRepository.findRecentMapPoolsByAuthorId(user.id) - : undefined, - }; -}; - export default function MapListPage() { const { t } = useTranslation(["common"]); const data = useLoaderData(); diff --git a/app/features/notifications/routes/notifications.tsx b/app/features/notifications/routes/notifications.tsx index 2c4e18942..e9e21b64a 100644 --- a/app/features/notifications/routes/notifications.tsx +++ b/app/features/notifications/routes/notifications.tsx @@ -1,18 +1,20 @@ import { Link, type MetaFunction, useLoaderData } from "@remix-run/react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; import { Main } from "~/components/Main"; +import { BellIcon } from "~/components/icons/Bell"; +import { metaTags } from "../../../utils/remix"; +import { SETTINGS_PAGE } from "../../../utils/urls"; import { NotificationItem, NotificationItemDivider, NotificationsList, } from "../components/NotificationList"; +import { useMarkNotificationsAsSeen } from "../notifications-hooks"; + import { loader } from "../loaders/notifications.server"; export { loader }; -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { BellIcon } from "~/components/icons/Bell"; -import { metaTags } from "../../../utils/remix"; -import { SETTINGS_PAGE } from "../../../utils/urls"; -import { useMarkNotificationsAsSeen } from "../notifications-hooks"; + import styles from "./notifications.module.css"; export const meta: MetaFunction = (args) => { diff --git a/app/features/plus-suggestions/actions/plus.suggestions.comment.$tier.$userId.server.ts b/app/features/plus-suggestions/actions/plus.suggestions.comment.$tier.$userId.server.ts new file mode 100644 index 000000000..f3b9c3b17 --- /dev/null +++ b/app/features/plus-suggestions/actions/plus.suggestions.comment.$tier.$userId.server.ts @@ -0,0 +1,51 @@ +import type { ActionFunctionArgs } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { requireUser } from "~/features/auth/core/user.server"; +import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server"; +import { + nextNonCompletedVoting, + rangeToMonthYear, +} from "~/features/plus-voting/core"; +import { canAddCommentToSuggestionBE } from "~/permissions"; +import { + badRequestIfFalsy, + errorToastIfFalsy, + parseRequestPayload, +} from "~/utils/remix.server"; +import { plusSuggestionPage } from "~/utils/urls"; +import { followUpCommentActionSchema } from "../plus-suggestions-schemas"; + +export const action = async ({ request }: ActionFunctionArgs) => { + const data = await parseRequestPayload({ + request, + schema: followUpCommentActionSchema, + }); + const user = await requireUser(request); + + const votingMonthYear = rangeToMonthYear( + badRequestIfFalsy(nextNonCompletedVoting(new Date())), + ); + + const suggestions = + await PlusSuggestionRepository.findAllByMonth(votingMonthYear); + + errorToastIfFalsy( + canAddCommentToSuggestionBE({ + suggestions, + user, + suggested: { id: data.suggestedId }, + targetPlusTier: data.tier, + }), + "No permissions to add this comment", + ); + + await PlusSuggestionRepository.create({ + authorId: user.id, + suggestedId: data.suggestedId, + text: data.comment, + tier: data.tier, + ...votingMonthYear, + }); + + throw redirect(plusSuggestionPage({ tier: data.tier })); +}; diff --git a/app/features/plus-suggestions/actions/plus.suggestions.new.server.ts b/app/features/plus-suggestions/actions/plus.suggestions.new.server.ts new file mode 100644 index 000000000..9357484fa --- /dev/null +++ b/app/features/plus-suggestions/actions/plus.suggestions.new.server.ts @@ -0,0 +1,67 @@ +import type { ActionFunction } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { requireUser } from "~/features/auth/core/user.server"; +import { notify } from "~/features/notifications/core/notify.server"; +import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server"; +import { + nextNonCompletedVoting, + rangeToMonthYear, +} from "~/features/plus-voting/core"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { canSuggestNewUserBE } from "~/permissions"; +import { + badRequestIfFalsy, + errorToastIfFalsy, + parseRequestPayload, +} from "~/utils/remix.server"; +import { plusSuggestionPage } from "~/utils/urls"; +import { firstCommentActionSchema } from "../plus-suggestions-schemas"; + +export const action: ActionFunction = async ({ request }) => { + const data = await parseRequestPayload({ + request, + schema: firstCommentActionSchema, + }); + + const suggested = badRequestIfFalsy( + await UserRepository.findLeanById(data.userId), + ); + + const user = await requireUser(request); + + const votingMonthYear = rangeToMonthYear( + badRequestIfFalsy(nextNonCompletedVoting(new Date())), + ); + const suggestions = + await PlusSuggestionRepository.findAllByMonth(votingMonthYear); + + errorToastIfFalsy( + canSuggestNewUserBE({ + user, + suggested, + targetPlusTier: data.tier, + suggestions, + }), + "No permissions to make this suggestion", + ); + + await PlusSuggestionRepository.create({ + authorId: user.id, + suggestedId: suggested.id, + tier: data.tier, + text: data.comment, + ...votingMonthYear, + }); + + notify({ + userIds: [suggested.id], + notification: { + type: "PLUS_SUGGESTION_ADDED", + meta: { + tier: data.tier, + }, + }, + }); + + throw redirect(plusSuggestionPage({ tier: data.tier })); +}; diff --git a/app/features/plus-suggestions/actions/plus.suggestions.server.ts b/app/features/plus-suggestions/actions/plus.suggestions.server.ts new file mode 100644 index 000000000..478395189 --- /dev/null +++ b/app/features/plus-suggestions/actions/plus.suggestions.server.ts @@ -0,0 +1,91 @@ +import type { ActionFunction } from "@remix-run/node"; +import { requireUser } from "~/features/auth/core/user.server"; +import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server"; +import { + isVotingActive, + nextNonCompletedVoting, + rangeToMonthYear, +} from "~/features/plus-voting/core"; +import { canDeleteComment, isFirstSuggestion } from "~/permissions"; +import invariant from "~/utils/invariant"; +import { + badRequestIfFalsy, + errorToastIfFalsy, + parseRequestPayload, +} from "~/utils/remix.server"; +import { assertUnreachable } from "~/utils/types"; +import { suggestionActionSchema } from "../plus-suggestions-schemas"; + +export const action: ActionFunction = async ({ request }) => { + const data = await parseRequestPayload({ + request, + schema: suggestionActionSchema, + }); + const user = await requireUser(request); + + const votingMonthYear = rangeToMonthYear( + badRequestIfFalsy(nextNonCompletedVoting(new Date())), + ); + + switch (data._action) { + case "DELETE_COMMENT": { + const suggestions = + await PlusSuggestionRepository.findAllByMonth(votingMonthYear); + + const suggestionToDelete = suggestions.find((suggestion) => + suggestion.suggestions.some( + (suggestion) => suggestion.id === data.suggestionId, + ), + ); + invariant(suggestionToDelete); + const subSuggestion = suggestionToDelete.suggestions.find( + (suggestion) => suggestion.id === data.suggestionId, + ); + invariant(subSuggestion); + + errorToastIfFalsy( + canDeleteComment({ + user, + author: subSuggestion.author, + suggestionId: data.suggestionId, + suggestions, + }), + "No permissions to delete this comment", + ); + + const suggestionHasComments = suggestionToDelete.suggestions.length > 1; + + if ( + suggestionHasComments && + isFirstSuggestion({ suggestionId: data.suggestionId, suggestions }) + ) { + // admin only action + await PlusSuggestionRepository.deleteWithCommentsBySuggestedUserId({ + tier: suggestionToDelete.tier, + userId: suggestionToDelete.suggested.id, + ...votingMonthYear, + }); + } else { + await PlusSuggestionRepository.deleteById(data.suggestionId); + } + + break; + } + case "DELETE_SUGGESTION_OF_THEMSELVES": { + invariant(!isVotingActive(), "Voting is active"); + + await PlusSuggestionRepository.deleteWithCommentsBySuggestedUserId({ + tier: data.tier, + userId: user.id, + ...votingMonthYear, + }); + + break; + } + default: { + assertUnreachable(data); + } + } + + return null; +}; diff --git a/app/features/plus-suggestions/loaders/plus.suggestions.server.ts b/app/features/plus-suggestions/loaders/plus.suggestions.server.ts new file mode 100644 index 000000000..cc1f5c689 --- /dev/null +++ b/app/features/plus-suggestions/loaders/plus.suggestions.server.ts @@ -0,0 +1,19 @@ +import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server"; +import { + nextNonCompletedVoting, + rangeToMonthYear, +} from "~/features/plus-voting/core"; + +export const loader = async () => { + const nextVotingRange = nextNonCompletedVoting(new Date()); + + if (!nextVotingRange) { + return { suggestions: [] }; + } + + return { + suggestions: await PlusSuggestionRepository.findAllByMonth( + rangeToMonthYear(nextVotingRange), + ), + }; +}; diff --git a/app/features/plus-suggestions/plus-suggestions-schemas.ts b/app/features/plus-suggestions/plus-suggestions-schemas.ts new file mode 100644 index 000000000..db9e5ec66 --- /dev/null +++ b/app/features/plus-suggestions/plus-suggestions-schemas.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; +import { + PLUS_TIERS, + PlUS_SUGGESTION_COMMENT_MAX_LENGTH, + PlUS_SUGGESTION_FIRST_COMMENT_MAX_LENGTH, +} from "~/constants"; +import { _action, actualNumber, trimmedString } from "~/utils/zod"; + +export const followUpCommentActionSchema = z.object({ + comment: z.preprocess( + trimmedString, + z.string().min(1).max(PlUS_SUGGESTION_COMMENT_MAX_LENGTH), + ), + tier: z.preprocess( + actualNumber, + z + .number() + .min(Math.min(...PLUS_TIERS)) + .max(Math.max(...PLUS_TIERS)), + ), + suggestedId: z.preprocess(actualNumber, z.number()), +}); + +export const firstCommentActionSchema = z.object({ + tier: z.preprocess( + actualNumber, + z + .number() + .min(Math.min(...PLUS_TIERS)) + .max(Math.max(...PLUS_TIERS)), + ), + comment: z.preprocess( + trimmedString, + z.string().min(1).max(PlUS_SUGGESTION_FIRST_COMMENT_MAX_LENGTH), + ), + userId: z.preprocess(actualNumber, z.number().positive()), +}); + +export const suggestionActionSchema = z.union([ + z.object({ + _action: _action("DELETE_COMMENT"), + suggestionId: z.preprocess(actualNumber, z.number()), + }), + z.object({ + _action: _action("DELETE_SUGGESTION_OF_THEMSELVES"), + tier: z.preprocess( + actualNumber, + z + .number() + .min(Math.min(...PLUS_TIERS)) + .max(Math.max(...PLUS_TIERS)), + ), + }), +]); diff --git a/app/features/plus-suggestions/routes/plus.index.tsx b/app/features/plus-suggestions/routes/plus.index.ts similarity index 100% rename from app/features/plus-suggestions/routes/plus.index.tsx rename to app/features/plus-suggestions/routes/plus.index.ts diff --git a/app/features/plus-suggestions/routes/plus.suggestions.comment.$tier.$userId.tsx b/app/features/plus-suggestions/routes/plus.suggestions.comment.$tier.$userId.tsx index 78773d730..5bb470f1f 100644 --- a/app/features/plus-suggestions/routes/plus.suggestions.comment.$tier.$userId.tsx +++ b/app/features/plus-suggestions/routes/plus.suggestions.comment.$tier.$userId.tsx @@ -1,82 +1,17 @@ -import type { ActionFunction } from "@remix-run/node"; -import { redirect } from "@remix-run/node"; import { Form, useMatches, useParams } from "@remix-run/react"; -import { z } from "zod"; import { Button, LinkButton } from "~/components/Button"; import { Dialog } from "~/components/Dialog"; import { Redirect } from "~/components/Redirect"; -import { PLUS_TIERS, PlUS_SUGGESTION_COMMENT_MAX_LENGTH } from "~/constants"; +import { PlUS_SUGGESTION_COMMENT_MAX_LENGTH } from "~/constants"; import { useUser } from "~/features/auth/core/user"; -import { requireUser } from "~/features/auth/core/user.server"; -import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server"; -import { - nextNonCompletedVoting, - rangeToMonthYear, -} from "~/features/plus-voting/core"; -import { - canAddCommentToSuggestionBE, - canAddCommentToSuggestionFE, -} from "~/permissions"; +import { canAddCommentToSuggestionFE } from "~/permissions"; import { atOrError } from "~/utils/arrays"; -import { - badRequestIfFalsy, - errorToastIfFalsy, - parseRequestPayload, -} from "~/utils/remix.server"; import { plusSuggestionPage } from "~/utils/urls"; -import { actualNumber, trimmedString } from "~/utils/zod"; import type { PlusSuggestionsLoaderData } from "./plus.suggestions"; import { CommentTextarea } from "./plus.suggestions.new"; -const commentActionSchema = z.object({ - comment: z.preprocess( - trimmedString, - z.string().min(1).max(PlUS_SUGGESTION_COMMENT_MAX_LENGTH), - ), - tier: z.preprocess( - actualNumber, - z - .number() - .min(Math.min(...PLUS_TIERS)) - .max(Math.max(...PLUS_TIERS)), - ), - suggestedId: z.preprocess(actualNumber, z.number()), -}); - -export const action: ActionFunction = async ({ request }) => { - const data = await parseRequestPayload({ - request, - schema: commentActionSchema, - }); - const user = await requireUser(request); - - const votingMonthYear = rangeToMonthYear( - badRequestIfFalsy(nextNonCompletedVoting(new Date())), - ); - - const suggestions = - await PlusSuggestionRepository.findAllByMonth(votingMonthYear); - - errorToastIfFalsy( - canAddCommentToSuggestionBE({ - suggestions, - user, - suggested: { id: data.suggestedId }, - targetPlusTier: data.tier, - }), - "No permissions to add this comment", - ); - - await PlusSuggestionRepository.create({ - authorId: user.id, - suggestedId: data.suggestedId, - text: data.comment, - tier: data.tier, - ...votingMonthYear, - }); - - throw redirect(plusSuggestionPage({ tier: data.tier })); -}; +import { action } from "../actions/plus.suggestions.comment.$tier.$userId.server"; +export { action }; export default function PlusCommentModalPage() { const user = useUser(); diff --git a/app/features/plus-suggestions/routes/plus.suggestions.new.tsx b/app/features/plus-suggestions/routes/plus.suggestions.new.tsx index 126300fe8..395373035 100644 --- a/app/features/plus-suggestions/routes/plus.suggestions.new.tsx +++ b/app/features/plus-suggestions/routes/plus.suggestions.new.tsx @@ -1,8 +1,5 @@ -import type { ActionFunction } from "@remix-run/node"; -import { redirect } from "@remix-run/node"; import { Form, useMatches } from "@remix-run/react"; import * as React from "react"; -import { z } from "zod"; import { LinkButton } from "~/components/Button"; import { Dialog } from "~/components/Dialog"; import { FormMessage } from "~/components/FormMessage"; @@ -16,93 +13,17 @@ import { } from "~/constants"; import type { UserWithPlusTier } from "~/db/tables"; import { useUser } from "~/features/auth/core/user"; -import { requireUser } from "~/features/auth/core/user.server"; -import { notify } from "~/features/notifications/core/notify.server"; -import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server"; import { - nextNonCompletedVoting, - rangeToMonthYear, -} from "~/features/plus-voting/core"; -import * as UserRepository from "~/features/user-page/UserRepository.server"; -import { - canSuggestNewUserBE, canSuggestNewUserFE, playerAlreadyMember, playerAlreadySuggested, } from "~/permissions"; import { atOrError } from "~/utils/arrays"; -import { - badRequestIfFalsy, - errorToastIfFalsy, - parseRequestPayload, -} from "~/utils/remix.server"; import { plusSuggestionPage } from "~/utils/urls"; -import { actualNumber, trimmedString } from "~/utils/zod"; import type { PlusSuggestionsLoaderData } from "./plus.suggestions"; -const commentActionSchema = z.object({ - tier: z.preprocess( - actualNumber, - z - .number() - .min(Math.min(...PLUS_TIERS)) - .max(Math.max(...PLUS_TIERS)), - ), - comment: z.preprocess( - trimmedString, - z.string().min(1).max(PlUS_SUGGESTION_FIRST_COMMENT_MAX_LENGTH), - ), - userId: z.preprocess(actualNumber, z.number().positive()), -}); - -export const action: ActionFunction = async ({ request }) => { - const data = await parseRequestPayload({ - request, - schema: commentActionSchema, - }); - - const suggested = badRequestIfFalsy( - await UserRepository.findLeanById(data.userId), - ); - - const user = await requireUser(request); - - const votingMonthYear = rangeToMonthYear( - badRequestIfFalsy(nextNonCompletedVoting(new Date())), - ); - const suggestions = - await PlusSuggestionRepository.findAllByMonth(votingMonthYear); - - errorToastIfFalsy( - canSuggestNewUserBE({ - user, - suggested, - targetPlusTier: data.tier, - suggestions, - }), - "No permissions to make this suggestion", - ); - - await PlusSuggestionRepository.create({ - authorId: user.id, - suggestedId: suggested.id, - tier: data.tier, - text: data.comment, - ...votingMonthYear, - }); - - notify({ - userIds: [suggested.id], - notification: { - type: "PLUS_SUGGESTION_ADDED", - meta: { - tier: data.tier, - }, - }, - }); - - throw redirect(plusSuggestionPage({ tier: data.tier })); -}; +import { action } from "../actions/plus.suggestions.new.server"; +export { action }; export default function PlusNewSuggestionModalPage() { const user = useUser(); diff --git a/app/features/plus-suggestions/routes/plus.suggestions.tsx b/app/features/plus-suggestions/routes/plus.suggestions.tsx index d84c7aad1..c2c634b08 100644 --- a/app/features/plus-suggestions/routes/plus.suggestions.tsx +++ b/app/features/plus-suggestions/routes/plus.suggestions.tsx @@ -1,12 +1,7 @@ -import type { - ActionFunction, - MetaFunction, - SerializeFrom, -} from "@remix-run/node"; +import type { MetaFunction, SerializeFrom } from "@remix-run/node"; import type { ShouldRevalidateFunction } from "@remix-run/react"; import { Link, Outlet, useLoaderData, useSearchParams } from "@remix-run/react"; import clsx from "clsx"; -import { z } from "zod"; import { Alert } from "~/components/Alert"; import { Avatar } from "~/components/Avatar"; import { Button, LinkButton } from "~/components/Button"; @@ -14,33 +9,26 @@ import { Catcher } from "~/components/Catcher"; import { FormWithConfirm } from "~/components/FormWithConfirm"; import { RelativeTime } from "~/components/RelativeTime"; import { TrashIcon } from "~/components/icons/Trash"; -import { PLUS_TIERS } from "~/constants"; import type { Tables } from "~/db/tables"; import { useUser } from "~/features/auth/core/user"; -import { requireUser } from "~/features/auth/core/user.server"; -import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server"; +import type * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server"; import { isVotingActive, nextNonCompletedVoting, - rangeToMonthYear, } from "~/features/plus-voting/core"; import { canAddCommentToSuggestionFE, canDeleteComment, canSuggestNewUserFE, - isFirstSuggestion, } from "~/permissions"; import { databaseTimestampToDate } from "~/utils/dates"; import invariant from "~/utils/invariant"; import { metaTags } from "~/utils/remix"; -import { - badRequestIfFalsy, - errorToastIfFalsy, - parseRequestPayload, -} from "~/utils/remix.server"; -import { assertUnreachable } from "~/utils/types"; import { userPage } from "~/utils/urls"; -import { _action, actualNumber } from "~/utils/zod"; + +import { action } from "../actions/plus.suggestions.server"; +import { loader } from "../loaders/plus.suggestions.server"; +export { action, loader }; export const meta: MetaFunction = (args) => { return metaTags({ @@ -52,97 +40,6 @@ export const meta: MetaFunction = (args) => { }); }; -const suggestionActionSchema = z.union([ - z.object({ - _action: _action("DELETE_COMMENT"), - suggestionId: z.preprocess(actualNumber, z.number()), - }), - z.object({ - _action: _action("DELETE_SUGGESTION_OF_THEMSELVES"), - tier: z.preprocess( - actualNumber, - z - .number() - .min(Math.min(...PLUS_TIERS)) - .max(Math.max(...PLUS_TIERS)), - ), - }), -]); - -export const action: ActionFunction = async ({ request }) => { - const data = await parseRequestPayload({ - request, - schema: suggestionActionSchema, - }); - const user = await requireUser(request); - - const votingMonthYear = rangeToMonthYear( - badRequestIfFalsy(nextNonCompletedVoting(new Date())), - ); - - switch (data._action) { - case "DELETE_COMMENT": { - const suggestions = - await PlusSuggestionRepository.findAllByMonth(votingMonthYear); - - const suggestionToDelete = suggestions.find((suggestion) => - suggestion.suggestions.some( - (suggestion) => suggestion.id === data.suggestionId, - ), - ); - invariant(suggestionToDelete); - const subSuggestion = suggestionToDelete.suggestions.find( - (suggestion) => suggestion.id === data.suggestionId, - ); - invariant(subSuggestion); - - errorToastIfFalsy( - canDeleteComment({ - user, - author: subSuggestion.author, - suggestionId: data.suggestionId, - suggestions, - }), - "No permissions to delete this comment", - ); - - const suggestionHasComments = suggestionToDelete.suggestions.length > 1; - - if ( - suggestionHasComments && - isFirstSuggestion({ suggestionId: data.suggestionId, suggestions }) - ) { - // admin only action - await PlusSuggestionRepository.deleteWithCommentsBySuggestedUserId({ - tier: suggestionToDelete.tier, - userId: suggestionToDelete.suggested.id, - ...votingMonthYear, - }); - } else { - await PlusSuggestionRepository.deleteById(data.suggestionId); - } - - break; - } - case "DELETE_SUGGESTION_OF_THEMSELVES": { - invariant(!isVotingActive(), "Voting is active"); - - await PlusSuggestionRepository.deleteWithCommentsBySuggestedUserId({ - tier: data.tier, - userId: user.id, - ...votingMonthYear, - }); - - break; - } - default: { - assertUnreachable(data); - } - } - - return null; -}; - export type PlusSuggestionsLoaderData = SerializeFrom; export const shouldRevalidate: ShouldRevalidateFunction = ({ formMethod }) => { @@ -150,20 +47,6 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({ formMethod }) => { return Boolean(formMethod && formMethod !== "GET"); }; -export const loader = async () => { - const nextVotingRange = nextNonCompletedVoting(new Date()); - - if (!nextVotingRange) { - return { suggestions: [] }; - } - - return { - suggestions: await PlusSuggestionRepository.findAllByMonth( - rangeToMonthYear(nextVotingRange), - ), - }; -}; - export default function PlusSuggestionsPage() { const data = useLoaderData(); const [searchParams, setSearchParams] = useSearchParams(); diff --git a/app/features/plus-voting/actions/plus.voting.server.ts b/app/features/plus-voting/actions/plus.voting.server.ts new file mode 100644 index 000000000..025241f1b --- /dev/null +++ b/app/features/plus-voting/actions/plus.voting.server.ts @@ -0,0 +1,90 @@ +import type { ActionFunction } from "@remix-run/node"; +import { PLUS_UPVOTE } from "~/constants"; +import { requireUser } from "~/features/auth/core/user.server"; +import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server"; +import type { PlusVoteFromFE } from "~/features/plus-voting/core"; +import { + nextNonCompletedVoting, + rangeToMonthYear, +} from "~/features/plus-voting/core"; +import { isVotingActive } from "~/features/plus-voting/core/voting-time"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import invariant from "~/utils/invariant"; +import { badRequestIfFalsy, parseRequestPayload } from "~/utils/remix.server"; +import { votingActionSchema } from "../plus-voting-schemas"; + +export const action: ActionFunction = async ({ request }) => { + const user = await requireUser(request); + const data = await parseRequestPayload({ + request, + schema: votingActionSchema, + }); + + if (!isVotingActive()) { + throw new Response(null, { status: 400 }); + } + + invariant(user.plusTier, "User should have plusTier"); + + const usersForVoting = await PlusVotingRepository.usersForVoting({ + id: user.id, + plusTier: user.plusTier, + }); + + // this should not be needed but makes the voting a bit more resilient + // if there is a bug that causes some user to show up twice, or some user to show up who should not be included + const seen = new Set(); + const filteredVotes = data.votes.filter((vote) => { + if (seen.has(vote.votedId)) { + return false; + } + seen.add(vote.votedId); + return usersForVoting.some((u) => u.user.id === vote.votedId); + }); + + validateVotes({ votes: filteredVotes, usersForVoting }); + + // freebie +1 for yourself if you vote + const votesForDb = [...filteredVotes].concat({ + votedId: user.id, + score: PLUS_UPVOTE, + }); + + const votingRange = badRequestIfFalsy(nextNonCompletedVoting(new Date())); + const { month, year } = rangeToMonthYear(votingRange); + await PlusVotingRepository.upsertMany( + votesForDb.map((vote) => ({ + ...vote, + authorId: user.id, + month, + year, + tier: user.plusTier!, // no clue why i couldn't make narrowing the type down above work + validAfter: dateToDatabaseTimestamp(votingRange.endDate), + })), + ); + + return null; +}; + +function validateVotes({ + votes, + usersForVoting, +}: { + votes: PlusVoteFromFE[]; + usersForVoting?: PlusVotingRepository.UsersForVoting; +}) { + if (!usersForVoting) throw new Response(null, { status: 400 }); + + // converting it to set also handles the check for duplicate ids + const votedUserIds = new Set(votes.map((v) => v.votedId)); + + if (votedUserIds.size !== usersForVoting.length) { + throw new Response(null, { status: 400 }); + } + + for (const { user } of usersForVoting) { + if (!votedUserIds.has(user.id)) { + throw new Response(null, { status: 400 }); + } + } +} diff --git a/app/features/plus-voting/loaders/plus.voting.results.server.ts b/app/features/plus-voting/loaders/plus.voting.results.server.ts new file mode 100644 index 000000000..f781a821d --- /dev/null +++ b/app/features/plus-voting/loaders/plus.voting.results.server.ts @@ -0,0 +1,96 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import type { UserWithPlusTier } from "~/db/tables"; +import { getUser } from "~/features/auth/core/user.server"; +import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server"; +import { lastCompletedVoting } from "~/features/plus-voting/core"; +import invariant from "~/utils/invariant"; +import { roundToNDecimalPlaces } from "~/utils/number"; +import { isAtLeastFiveDollarTierPatreon } from "~/utils/users"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const user = await getUser(request); + const results = await PlusVotingRepository.resultsByMonthYear( + lastCompletedVoting(new Date()), + ); + + return { + results: censorScores(results), + ownScores: ownScores({ results, user }), + lastCompletedVoting: lastCompletedVoting(new Date()), + }; +}; + +function censorScores(results: PlusVotingRepository.ResultsByMonthYearItem[]) { + return results.map((tier) => ({ + ...tier, + passed: tier.passed.map((result) => ({ + ...result, + score: undefined, + })), + failed: tier.failed.map((result) => ({ + ...result, + score: undefined, + })), + })); +} + +function ownScores({ + results, + user, +}: { + results: PlusVotingRepository.ResultsByMonthYearItem[]; + user?: Pick; +}) { + return results + .flatMap((tier) => [...tier.failed, ...tier.passed]) + .filter((result) => { + return result.id === user?.id; + }) + .map((result) => { + const showScore = + (result.wasSuggested && !result.passedVoting) || + isAtLeastFiveDollarTierPatreon(user); + + const resultsOfOwnTierExcludingOwn = () => { + const ownTierResults = results.find( + (tier) => tier.tier === result.tier, + ); + invariant(ownTierResults, "own tier results not found"); + + return [...ownTierResults.failed, ...ownTierResults.passed].filter( + (otherResult) => otherResult.id !== result.id, + ); + }; + + const mappedResult: { + tier: number; + score?: number; + passedVoting: number; + betterThan?: number; + } = { + tier: result.tier, + score: databaseAvgToPercentage(result.score), + passedVoting: result.passedVoting, + betterThan: roundToNDecimalPlaces( + (resultsOfOwnTierExcludingOwn().filter( + (otherResult) => otherResult.score <= result.score, + ).length / + resultsOfOwnTierExcludingOwn().length) * + 100, + ), + }; + + if (!showScore) mappedResult.score = undefined; + if (!isAtLeastFiveDollarTierPatreon(user) || !result.passedVoting) { + mappedResult.betterThan = undefined; + } + + return mappedResult; + }); +} + +function databaseAvgToPercentage(score: number) { + const scoreNormalized = score + 1; + + return roundToNDecimalPlaces((scoreNormalized / 2) * 100); +} diff --git a/app/features/plus-voting/loaders/plus.voting.server.ts b/app/features/plus-voting/loaders/plus.voting.server.ts new file mode 100644 index 000000000..a51ac5bca --- /dev/null +++ b/app/features/plus-voting/loaders/plus.voting.server.ts @@ -0,0 +1,96 @@ +import type { LoaderFunction } from "@remix-run/node"; +import { formatDistance } from "date-fns"; +import { getUser } from "~/features/auth/core/user.server"; +import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server"; +import { + nextNonCompletedVoting, + rangeToMonthYear, +} from "~/features/plus-voting/core"; +import { isVotingActive } from "~/features/plus-voting/core/voting-time"; + +export type PlusVotingLoaderData = + // next voting date is not in the system + | { + type: "noTimeDefinedInfo"; + } + // voting is not active OR user is not eligible to vote + | { + type: "timeInfo"; + voted?: boolean; + timeInfo: { + timestamp: number; + timing: "starts" | "ends"; + relativeTime: string; + }; + } + // user can vote + | { + type: "voting"; + usersForVoting: PlusVotingRepository.UsersForVoting; + votingEnds: { + timestamp: number; + relativeTime: string; + }; + }; + +export const loader: LoaderFunction = async ({ request }) => { + const user = await getUser(request); + + const now = new Date(); + const nextVotingRange = nextNonCompletedVoting(now); + + if (!nextVotingRange) { + return { type: "noTimeDefinedInfo" }; + } + + if (!isVotingActive()) { + return { + type: "timeInfo", + timeInfo: { + relativeTime: formatDistance(nextVotingRange.startDate, now, { + addSuffix: true, + }), + timestamp: nextVotingRange.startDate.getTime(), + timing: "starts", + }, + }; + } + + const usersForVoting = user?.plusTier + ? await PlusVotingRepository.usersForVoting({ + id: user.id, + plusTier: user.plusTier, + }) + : undefined; + const hasVoted = user + ? await PlusVotingRepository.hasVoted({ + authorId: user.id, + ...rangeToMonthYear(nextVotingRange), + }) + : false; + + if (!usersForVoting || hasVoted) { + return { + type: "timeInfo", + voted: hasVoted, + timeInfo: { + relativeTime: formatDistance(nextVotingRange.endDate, now, { + addSuffix: true, + }), + timestamp: nextVotingRange.endDate.getTime(), + timing: "ends", + }, + }; + } + + return { + type: "voting", + usersForVoting, + votingEnds: { + timestamp: nextVotingRange.endDate.getTime(), + relativeTime: formatDistance(nextVotingRange.endDate, now, { + addSuffix: true, + }), + }, + }; +}; diff --git a/app/features/plus-voting/plus-voting-schemas.ts b/app/features/plus-voting/plus-voting-schemas.ts new file mode 100644 index 000000000..affd2d894 --- /dev/null +++ b/app/features/plus-voting/plus-voting-schemas.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; +import { PLUS_DOWNVOTE, PLUS_UPVOTE } from "~/constants"; +import type { PlusVoteFromFE } from "~/features/plus-voting/core"; +import { assertType } from "~/utils/types"; +import { safeJSONParse } from "~/utils/zod"; + +export const voteSchema = z.object({ + votedId: z.number(), + score: z.number().refine((val) => [PLUS_DOWNVOTE, PLUS_UPVOTE].includes(val)), +}); + +assertType, PlusVoteFromFE>(); + +export const votingActionSchema = z.object({ + votes: z.preprocess(safeJSONParse, z.array(voteSchema)), +}); diff --git a/app/features/plus-voting/routes/plus.voting.results.tsx b/app/features/plus-voting/routes/plus.voting.results.tsx index 04fb66947..cd9832817 100644 --- a/app/features/plus-voting/routes/plus.voting.results.tsx +++ b/app/features/plus-voting/routes/plus.voting.results.tsx @@ -1,21 +1,13 @@ -import type { - LoaderFunctionArgs, - MetaFunction, - SerializeFrom, -} from "@remix-run/node"; +import type { MetaFunction, SerializeFrom } from "@remix-run/node"; import { Link, useLoaderData } from "@remix-run/react"; import clsx from "clsx"; -import { getUser } from "~/features/auth/core/user.server"; -import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server"; -import { lastCompletedVoting } from "~/features/plus-voting/core"; -import invariant from "~/utils/invariant"; -import { roundToNDecimalPlaces } from "~/utils/number"; +import { metaTags } from "~/utils/remix"; import { PLUS_SERVER_DISCORD_URL, userPage } from "~/utils/urls"; -import { isAtLeastFiveDollarTierPatreon } from "~/utils/users"; + +import { loader } from "../loaders/plus.voting.results.server"; +export { loader }; import "~/styles/plus-history.css"; -import type { UserWithPlusTier } from "~/db/tables"; -import { metaTags } from "~/utils/remix"; export const meta: MetaFunction = (args) => { return metaTags({ @@ -27,94 +19,6 @@ export const meta: MetaFunction = (args) => { }); }; -export const loader = async ({ request }: LoaderFunctionArgs) => { - const user = await getUser(request); - const results = await PlusVotingRepository.resultsByMonthYear( - lastCompletedVoting(new Date()), - ); - - return { - results: censorScores(results), - ownScores: ownScores({ results, user }), - lastCompletedVoting: lastCompletedVoting(new Date()), - }; -}; - -function databaseAvgToPercentage(score: number) { - const scoreNormalized = score + 1; - - return roundToNDecimalPlaces((scoreNormalized / 2) * 100); -} - -function censorScores(results: PlusVotingRepository.ResultsByMonthYearItem[]) { - return results.map((tier) => ({ - ...tier, - passed: tier.passed.map((result) => ({ - ...result, - score: undefined, - })), - failed: tier.failed.map((result) => ({ - ...result, - score: undefined, - })), - })); -} - -function ownScores({ - results, - user, -}: { - results: PlusVotingRepository.ResultsByMonthYearItem[]; - user?: Pick; -}) { - return results - .flatMap((tier) => [...tier.failed, ...tier.passed]) - .filter((result) => { - return result.id === user?.id; - }) - .map((result) => { - const showScore = - (result.wasSuggested && !result.passedVoting) || - isAtLeastFiveDollarTierPatreon(user); - - const resultsOfOwnTierExcludingOwn = () => { - const ownTierResults = results.find( - (tier) => tier.tier === result.tier, - ); - invariant(ownTierResults, "own tier results not found"); - - return [...ownTierResults.failed, ...ownTierResults.passed].filter( - (otherResult) => otherResult.id !== result.id, - ); - }; - - const mappedResult: { - tier: number; - score?: number; - passedVoting: number; - betterThan?: number; - } = { - tier: result.tier, - score: databaseAvgToPercentage(result.score), - passedVoting: result.passedVoting, - betterThan: roundToNDecimalPlaces( - (resultsOfOwnTierExcludingOwn().filter( - (otherResult) => otherResult.score <= result.score, - ).length / - resultsOfOwnTierExcludingOwn().length) * - 100, - ), - }; - - if (!showScore) mappedResult.score = undefined; - if (!isAtLeastFiveDollarTierPatreon(user) || !result.passedVoting) { - mappedResult.betterThan = undefined; - } - - return mappedResult; - }); -} - export default function PlusVotingResultsPage() { const data = useLoaderData(); diff --git a/app/features/plus-voting/routes/plus.voting.tsx b/app/features/plus-voting/routes/plus.voting.tsx index 5fa0da9dd..38863eb86 100644 --- a/app/features/plus-voting/routes/plus.voting.tsx +++ b/app/features/plus-voting/routes/plus.voting.tsx @@ -1,34 +1,22 @@ -import type { - ActionFunction, - LoaderFunction, - MetaFunction, -} from "@remix-run/node"; +import type { MetaFunction } from "@remix-run/node"; import { Form, useLoaderData } from "@remix-run/react"; -import { formatDistance } from "date-fns"; import * as React from "react"; -import { z } from "zod"; import { Avatar } from "~/components/Avatar"; import { Button } from "~/components/Button"; import { RelativeTime } from "~/components/RelativeTime"; import { CheckmarkIcon } from "~/components/icons/Checkmark"; -import { PLUS_DOWNVOTE, PLUS_UPVOTE } from "~/constants"; -import { getUser, requireUser } from "~/features/auth/core/user.server"; -import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server"; -import type { PlusVoteFromFE } from "~/features/plus-voting/core"; -import { - nextNonCompletedVoting, - rangeToMonthYear, - usePlusVoting, -} from "~/features/plus-voting/core"; -import { isVotingActive } from "~/features/plus-voting/core/voting-time"; -import { dateToDatabaseTimestamp } from "~/utils/dates"; -import invariant from "~/utils/invariant"; +import { usePlusVoting } from "~/features/plus-voting/core"; import { metaTags } from "~/utils/remix"; -import { badRequestIfFalsy, parseRequestPayload } from "~/utils/remix.server"; -import { assertType, assertUnreachable } from "~/utils/types"; -import { safeJSONParse } from "~/utils/zod"; +import { assertUnreachable } from "~/utils/types"; import { PlusSuggestionComments } from "../../plus-suggestions/routes/plus.suggestions"; +import { action } from "../actions/plus.voting.server"; +import { + type PlusVotingLoaderData, + loader, +} from "../loaders/plus.voting.server"; +export { action, loader }; + export const meta: MetaFunction = (args) => { return metaTags({ title: "Plus Server Voting", @@ -36,180 +24,6 @@ export const meta: MetaFunction = (args) => { }); }; -const voteSchema = z.object({ - votedId: z.number(), - score: z.number().refine((val) => [PLUS_DOWNVOTE, PLUS_UPVOTE].includes(val)), -}); - -assertType, PlusVoteFromFE>(); - -const votingActionSchema = z.object({ - votes: z.preprocess(safeJSONParse, z.array(voteSchema)), -}); - -export const action: ActionFunction = async ({ request }) => { - const user = await requireUser(request); - const data = await parseRequestPayload({ - request, - schema: votingActionSchema, - }); - - if (!isVotingActive()) { - throw new Response(null, { status: 400 }); - } - - invariant(user.plusTier, "User should have plusTier"); - - const usersForVoting = await PlusVotingRepository.usersForVoting({ - id: user.id, - plusTier: user.plusTier, - }); - - // this should not be needed but makes the voting a bit more resilient - // if there is a bug that causes some user to show up twice, or some user to show up who should not be included - const seen = new Set(); - const filteredVotes = data.votes.filter((vote) => { - if (seen.has(vote.votedId)) { - return false; - } - seen.add(vote.votedId); - return usersForVoting.some((u) => u.user.id === vote.votedId); - }); - - validateVotes({ votes: filteredVotes, usersForVoting }); - - // freebie +1 for yourself if you vote - const votesForDb = [...filteredVotes].concat({ - votedId: user.id, - score: PLUS_UPVOTE, - }); - - const votingRange = badRequestIfFalsy(nextNonCompletedVoting(new Date())); - const { month, year } = rangeToMonthYear(votingRange); - await PlusVotingRepository.upsertMany( - votesForDb.map((vote) => ({ - ...vote, - authorId: user.id, - month, - year, - tier: user.plusTier!, // no clue why i couldn't make narrowing the type down above work - validAfter: dateToDatabaseTimestamp(votingRange.endDate), - })), - ); - - return null; -}; - -function validateVotes({ - votes, - usersForVoting, -}: { - votes: PlusVoteFromFE[]; - usersForVoting?: PlusVotingRepository.UsersForVoting; -}) { - if (!usersForVoting) throw new Response(null, { status: 400 }); - - // converting it to set also handles the check for duplicate ids - const votedUserIds = new Set(votes.map((v) => v.votedId)); - - if (votedUserIds.size !== usersForVoting.length) { - throw new Response(null, { status: 400 }); - } - - for (const { user } of usersForVoting) { - if (!votedUserIds.has(user.id)) { - throw new Response(null, { status: 400 }); - } - } -} - -type PlusVotingLoaderData = - // next voting date is not in the system - | { - type: "noTimeDefinedInfo"; - } - // voting is not active OR user is not eligible to vote - | { - type: "timeInfo"; - voted?: boolean; - timeInfo: { - timestamp: number; - timing: "starts" | "ends"; - relativeTime: string; - }; - } - // user can vote - | { - type: "voting"; - usersForVoting: PlusVotingRepository.UsersForVoting; - votingEnds: { - timestamp: number; - relativeTime: string; - }; - }; - -export const loader: LoaderFunction = async ({ request }) => { - const user = await getUser(request); - - const now = new Date(); - const nextVotingRange = nextNonCompletedVoting(now); - - if (!nextVotingRange) { - return { type: "noTimeDefinedInfo" }; - } - - if (!isVotingActive()) { - return { - type: "timeInfo", - timeInfo: { - relativeTime: formatDistance(nextVotingRange.startDate, now, { - addSuffix: true, - }), - timestamp: nextVotingRange.startDate.getTime(), - timing: "starts", - }, - }; - } - - const usersForVoting = user?.plusTier - ? await PlusVotingRepository.usersForVoting({ - id: user.id, - plusTier: user.plusTier, - }) - : undefined; - const hasVoted = user - ? await PlusVotingRepository.hasVoted({ - authorId: user.id, - ...rangeToMonthYear(nextVotingRange), - }) - : false; - - if (!usersForVoting || hasVoted) { - return { - type: "timeInfo", - voted: hasVoted, - timeInfo: { - relativeTime: formatDistance(nextVotingRange.endDate, now, { - addSuffix: true, - }), - timestamp: nextVotingRange.endDate.getTime(), - timing: "ends", - }, - }; - } - - return { - type: "voting", - usersForVoting, - votingEnds: { - timestamp: nextVotingRange.endDate.getTime(), - relativeTime: formatDistance(nextVotingRange.endDate, now, { - addSuffix: true, - }), - }, - }; -}; - export default function PlusVotingPage() { const data = useLoaderData(); diff --git a/app/features/sendouq-settings/actions/q.settings.server.ts b/app/features/sendouq-settings/actions/q.settings.server.ts new file mode 100644 index 000000000..d00991469 --- /dev/null +++ b/app/features/sendouq-settings/actions/q.settings.server.ts @@ -0,0 +1,58 @@ +import type { ActionFunctionArgs } from "@remix-run/node"; +import { requireUserId } from "~/features/auth/core/user.server"; +import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server"; +import { parseRequestPayload } from "~/utils/remix.server"; +import { assertUnreachable } from "~/utils/types"; +import { settingsActionSchema } from "../q-settings-schemas.server"; + +export const action = async ({ request }: ActionFunctionArgs) => { + const user = await requireUserId(request); + const data = await parseRequestPayload({ + request, + schema: settingsActionSchema, + }); + + switch (data._action) { + case "UPDATE_MAP_MODE_PREFERENCES": { + await QSettingsRepository.updateUserMapModePreferences({ + mapModePreferences: data.mapModePreferences, + userId: user.id, + }); + break; + } + case "UPDATE_VC": { + await QSettingsRepository.updateVoiceChat({ + userId: user.id, + vc: data.vc, + languages: data.languages, + }); + break; + } + case "UPDATE_SENDOUQ_WEAPON_POOL": { + await QSettingsRepository.updateSendouQWeaponPool({ + userId: user.id, + weaponPool: data.weaponPool, + }); + break; + } + case "UPDATE_NO_SCREEN": { + await QSettingsRepository.updateNoScreen({ + userId: user.id, + noScreen: Number(data.noScreen), + }); + break; + } + case "REMOVE_TRUST": { + await QSettingsRepository.deleteTrustedUser({ + trustGiverUserId: user.id, + trustReceiverUserId: data.userToRemoveTrustFromId, + }); + break; + } + default: { + assertUnreachable(data); + } + } + + return { ok: true }; +}; diff --git a/app/features/sendouq-settings/loaders/q.settings.server.ts b/app/features/sendouq-settings/loaders/q.settings.server.ts new file mode 100644 index 000000000..5db2019bb --- /dev/null +++ b/app/features/sendouq-settings/loaders/q.settings.server.ts @@ -0,0 +1,13 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { requireUserId } from "~/features/auth/core/user.server"; +import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const user = await requireUserId(request); + + return { + settings: await QSettingsRepository.settingsByUserId(user.id), + trusted: await QSettingsRepository.findTrustedUsersByGiverId(user.id), + team: await QSettingsRepository.currentTeamByUserId(user.id), + }; +}; diff --git a/app/features/sendouq-settings/routes/q.settings.tsx b/app/features/sendouq-settings/routes/q.settings.tsx index 8ab812338..b49697e1f 100644 --- a/app/features/sendouq-settings/routes/q.settings.tsx +++ b/app/features/sendouq-settings/routes/q.settings.tsx @@ -1,13 +1,8 @@ -import type { - ActionFunctionArgs, - LoaderFunctionArgs, - MetaFunction, -} from "@remix-run/node"; +import type { MetaFunction } from "@remix-run/node"; import { useFetcher, useLoaderData } from "@remix-run/react"; import * as React from "react"; import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Trans } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; import { Avatar } from "~/components/Avatar"; import { Button } from "~/components/Button"; import { WeaponCombobox } from "~/components/Combobox"; @@ -16,6 +11,7 @@ import { FormWithConfirm } from "~/components/FormWithConfirm"; import { ModeImage, WeaponImage } from "~/components/Image"; import { Main } from "~/components/Main"; import { SubmitButton } from "~/components/SubmitButton"; +import { SendouSwitch } from "~/components/elements/Switch"; import { CrossIcon } from "~/components/icons/Cross"; import { MapIcon } from "~/components/icons/Map"; import { MicrophoneFilledIcon } from "~/components/icons/MicrophoneFilled"; @@ -24,20 +20,16 @@ import { SpeakerFilledIcon } from "~/components/icons/SpeakerFilled"; import { TrashIcon } from "~/components/icons/Trash"; import { UsersIcon } from "~/components/icons/Users"; import type { Preference, Tables, UserMapModePreferences } from "~/db/tables"; -import { requireUserId } from "~/features/auth/core/user.server"; import { soundCodeToLocalStorageKey, soundVolume, } from "~/features/chat/chat-utils"; -import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server"; import { useIsMounted } from "~/hooks/useIsMounted"; import { languagesUnified } from "~/modules/i18n/config"; import type { MainWeaponId, ModeShort } from "~/modules/in-game-lists"; import { modesShort } from "~/modules/in-game-lists/modes"; -import { - type SendouRouteHandle, - parseRequestPayload, -} from "~/utils/remix.server"; +import { metaTags } from "~/utils/remix"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; import { SENDOUQ_PAGE, @@ -47,15 +39,17 @@ import { } from "~/utils/urls"; import { BANNED_MAPS } from "../banned-maps"; import { ModeMapPoolPicker } from "../components/ModeMapPoolPicker"; +import { PreferenceRadioGroup } from "../components/PreferenceRadioGroup"; import { AMOUNT_OF_MAPS_IN_POOL_PER_MODE, SENDOUQ_WEAPON_POOL_MAX_SIZE, } from "../q-settings-constants"; -import { settingsActionSchema } from "../q-settings-schemas.server"; + +import { action } from "../actions/q.settings.server"; +import { loader } from "../loaders/q.settings.server"; +export { loader, action }; + import "../q-settings.css"; -import { SendouSwitch } from "~/components/elements/Switch"; -import { metaTags } from "~/utils/remix"; -import { PreferenceRadioGroup } from "../components/PreferenceRadioGroup"; export const handle: SendouRouteHandle = { i18n: ["q"], @@ -80,79 +74,15 @@ export const meta: MetaFunction = (args) => { }); }; -export const action = async ({ request }: ActionFunctionArgs) => { - const user = await requireUserId(request); - const data = await parseRequestPayload({ - request, - schema: settingsActionSchema, - }); - - switch (data._action) { - case "UPDATE_MAP_MODE_PREFERENCES": { - await QSettingsRepository.updateUserMapModePreferences({ - mapModePreferences: data.mapModePreferences, - userId: user.id, - }); - break; - } - case "UPDATE_VC": { - await QSettingsRepository.updateVoiceChat({ - userId: user.id, - vc: data.vc, - languages: data.languages, - }); - break; - } - case "UPDATE_SENDOUQ_WEAPON_POOL": { - await QSettingsRepository.updateSendouQWeaponPool({ - userId: user.id, - weaponPool: data.weaponPool, - }); - break; - } - case "UPDATE_NO_SCREEN": { - await QSettingsRepository.updateNoScreen({ - userId: user.id, - noScreen: Number(data.noScreen), - }); - break; - } - case "REMOVE_TRUST": { - await QSettingsRepository.deleteTrustedUser({ - trustGiverUserId: user.id, - trustReceiverUserId: data.userToRemoveTrustFromId, - }); - break; - } - default: { - assertUnreachable(data); - } - } - - return { ok: true }; -}; - -export const loader = async ({ request }: LoaderFunctionArgs) => { - const user = await requireUserId(request); - - return { - settings: await QSettingsRepository.settingsByUserId(user.id), - trusted: await QSettingsRepository.findTrustedUsersByGiverId(user.id), - team: await QSettingsRepository.currentTeamByUserId(user.id), - }; -}; - export default function SendouQSettingsPage() { return ( -
-
- - - - - - -
+
+ + + + + +
); } diff --git a/app/features/sendouq-streams/loaders/q.streams.server.ts b/app/features/sendouq-streams/loaders/q.streams.server.ts new file mode 100644 index 000000000..00fcc4c0b --- /dev/null +++ b/app/features/sendouq-streams/loaders/q.streams.server.ts @@ -0,0 +1,7 @@ +import { cachedStreams } from "../core/streams.server"; + +export const loader = async () => { + return { + streams: await cachedStreams(), + }; +}; diff --git a/app/features/sendouq-streams/routes/q.streams.tsx b/app/features/sendouq-streams/routes/q.streams.tsx index 15495bb00..5d86c4935 100644 --- a/app/features/sendouq-streams/routes/q.streams.tsx +++ b/app/features/sendouq-streams/routes/q.streams.tsx @@ -12,7 +12,9 @@ import { databaseTimestampToDate } from "~/utils/dates"; import { metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { FAQ_PAGE, sendouQMatchPage, twitchUrl, userPage } from "~/utils/urls"; -import { cachedStreams } from "../core/streams.server"; + +import { loader } from "../loaders/q.streams.server"; +export { loader }; import "~/features/sendouq/q.css"; @@ -28,12 +30,6 @@ export const meta: MetaFunction = (args) => { }); }; -export const loader = async () => { - return { - streams: await cachedStreams(), - }; -}; - export default function SendouQStreamsPage() { const { t } = useTranslation(["q"]); const data = useLoaderData(); diff --git a/app/features/sendouq/actions/q.looking.server.ts b/app/features/sendouq/actions/q.looking.server.ts new file mode 100644 index 000000000..5fdeae87f --- /dev/null +++ b/app/features/sendouq/actions/q.looking.server.ts @@ -0,0 +1,366 @@ +import type { ActionFunction } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { requireUser } from "~/features/auth/core/user.server"; +import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; +import { notify } from "~/features/notifications/core/notify.server"; +import * as QRepository from "~/features/sendouq/QRepository.server"; +import invariant from "~/utils/invariant"; +import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server"; +import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql"; +import { assertUnreachable } from "~/utils/types"; +import { SENDOUQ_PAGE, sendouQMatchPage } from "~/utils/urls"; +import { groupAfterMorph } from "../core/groups"; +import { membersNeededForFull } from "../core/groups.server"; +import { createMatchMemento, matchMapList } from "../core/match.server"; +import { FULL_GROUP_SIZE } from "../q-constants"; +import { lookingSchema } from "../q-schemas.server"; +import { addLike } from "../queries/addLike.server"; +import { addManagerRole } from "../queries/addManagerRole.server"; +import { chatCodeByGroupId } from "../queries/chatCodeByGroupId.server"; +import { createMatch } from "../queries/createMatch.server"; +import { deleteLike } from "../queries/deleteLike.server"; +import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server"; +import { groupHasMatch } from "../queries/groupHasMatch.server"; +import { groupSize } from "../queries/groupSize.server"; +import { groupSuccessorOwner } from "../queries/groupSuccessorOwner"; +import { leaveGroup } from "../queries/leaveGroup.server"; +import { likeExists } from "../queries/likeExists.server"; +import { morphGroups } from "../queries/morphGroups.server"; +import { refreshGroup } from "../queries/refreshGroup.server"; +import { removeManagerRole } from "../queries/removeManagerRole.server"; +import { updateNote } from "../queries/updateNote.server"; + +// this function doesn't throw normally because we are assuming +// if there is a validation error the user saw stale data +// and when we return null we just force a refresh +export const action: ActionFunction = async ({ request }) => { + const user = await requireUser(request); + const data = await parseRequestPayload({ + request, + schema: lookingSchema, + }); + const currentGroup = findCurrentGroupByUserId(user.id); + if (!currentGroup) return null; + + // this throws because there should normally be no way user loses ownership by the action of some other user + const validateIsGroupOwner = () => + errorToastIfFalsy(currentGroup.role === "OWNER", "Not owner"); + const isGroupManager = () => + currentGroup.role === "MANAGER" || currentGroup.role === "OWNER"; + + switch (data._action) { + case "LIKE": { + if (!isGroupManager()) return null; + + try { + addLike({ + likerGroupId: currentGroup.id, + targetGroupId: data.targetGroupId, + }); + } catch (e) { + if (!(e instanceof Error)) throw e; + // the group disbanded before we could like it + if (errorIsSqliteForeignKeyConstraintFailure(e)) return null; + + throw e; + } + refreshGroup(currentGroup.id); + + const targetChatCode = chatCodeByGroupId(data.targetGroupId); + if (targetChatCode) { + ChatSystemMessage.send({ + room: targetChatCode, + type: "LIKE_RECEIVED", + revalidateOnly: true, + }); + } + + break; + } + case "RECHALLENGE": { + if (!isGroupManager()) return null; + + await QRepository.rechallenge({ + likerGroupId: currentGroup.id, + targetGroupId: data.targetGroupId, + }); + + const targetChatCode = chatCodeByGroupId(data.targetGroupId); + if (targetChatCode) { + ChatSystemMessage.send({ + room: targetChatCode, + type: "LIKE_RECEIVED", + revalidateOnly: true, + }); + } + break; + } + case "UNLIKE": { + if (!isGroupManager()) return null; + + deleteLike({ + likerGroupId: currentGroup.id, + targetGroupId: data.targetGroupId, + }); + refreshGroup(currentGroup.id); + + break; + } + case "GROUP_UP": { + if (!isGroupManager()) return null; + if ( + !likeExists({ + targetGroupId: currentGroup.id, + likerGroupId: data.targetGroupId, + }) + ) { + return null; + } + + const lookingGroups = await QRepository.findLookingGroups({ + maxGroupSize: membersNeededForFull(groupSize(currentGroup.id)), + ownGroupId: currentGroup.id, + includeChatCode: true, + }); + + const ourGroup = lookingGroups.find( + (group) => group.id === currentGroup.id, + ); + if (!ourGroup) return null; + const theirGroup = lookingGroups.find( + (group) => group.id === data.targetGroupId, + ); + if (!theirGroup) return null; + + const { id: survivingGroupId } = groupAfterMorph({ + liker: "THEM", + ourGroup, + theirGroup, + }); + + const otherGroup = + ourGroup.id === survivingGroupId ? theirGroup : ourGroup; + + invariant(ourGroup.members, "our group has no members"); + invariant(otherGroup.members, "other group has no members"); + + morphGroups({ + survivingGroupId, + otherGroupId: otherGroup.id, + newMembers: otherGroup.members.map((m) => m.id), + }); + refreshGroup(survivingGroupId); + + if (ourGroup.chatCode && theirGroup.chatCode) { + ChatSystemMessage.send([ + { + room: ourGroup.chatCode, + type: "NEW_GROUP", + revalidateOnly: true, + }, + { + room: theirGroup.chatCode, + type: "NEW_GROUP", + revalidateOnly: true, + }, + ]); + } + + break; + } + case "MATCH_UP_RECHALLENGE": + case "MATCH_UP": { + if (!isGroupManager()) return null; + if ( + !likeExists({ + targetGroupId: currentGroup.id, + likerGroupId: data.targetGroupId, + }) + ) { + return null; + } + + const lookingGroups = await QRepository.findLookingGroups({ + minGroupSize: FULL_GROUP_SIZE, + ownGroupId: currentGroup.id, + includeChatCode: true, + }); + + const ourGroup = lookingGroups.find( + (group) => group.id === currentGroup.id, + ); + if (!ourGroup) return null; + const theirGroup = lookingGroups.find( + (group) => group.id === data.targetGroupId, + ); + if (!theirGroup) return null; + + errorToastIfFalsy( + ourGroup.members.length === FULL_GROUP_SIZE, + "Our group is not full", + ); + errorToastIfFalsy( + theirGroup.members.length === FULL_GROUP_SIZE, + "Their group is not full", + ); + + errorToastIfFalsy( + !groupHasMatch(ourGroup.id), + "Our group already has a match", + ); + errorToastIfFalsy( + !groupHasMatch(theirGroup.id), + "Their group already has a match", + ); + + const ourGroupPreferences = await QRepository.mapModePreferencesByGroupId( + ourGroup.id, + ); + const theirGroupPreferences = + await QRepository.mapModePreferencesByGroupId(theirGroup.id); + const mapList = matchMapList( + { + id: ourGroup.id, + preferences: ourGroupPreferences, + }, + { + id: theirGroup.id, + preferences: theirGroupPreferences, + ignoreModePreferences: data._action === "MATCH_UP_RECHALLENGE", + }, + ); + const createdMatch = createMatch({ + alphaGroupId: ourGroup.id, + bravoGroupId: theirGroup.id, + mapList, + memento: createMatchMemento({ + own: { group: ourGroup, preferences: ourGroupPreferences }, + their: { group: theirGroup, preferences: theirGroupPreferences }, + mapList, + }), + }); + + if (ourGroup.chatCode && theirGroup.chatCode) { + ChatSystemMessage.send([ + { + room: ourGroup.chatCode, + type: "MATCH_STARTED", + revalidateOnly: true, + }, + { + room: theirGroup.chatCode, + type: "MATCH_STARTED", + revalidateOnly: true, + }, + ]); + } + + notify({ + userIds: [ + ...ourGroup.members.map((m) => m.id), + ...theirGroup.members.map((m) => m.id), + ], + defaultSeenUserIds: [user.id], + notification: { + type: "SQ_NEW_MATCH", + meta: { + matchId: createdMatch.id, + }, + }, + }); + + throw redirect(sendouQMatchPage(createdMatch.id)); + } + case "GIVE_MANAGER": { + validateIsGroupOwner(); + + addManagerRole({ + groupId: currentGroup.id, + userId: data.userId, + }); + refreshGroup(currentGroup.id); + + break; + } + case "REMOVE_MANAGER": { + validateIsGroupOwner(); + + removeManagerRole({ + groupId: currentGroup.id, + userId: data.userId, + }); + refreshGroup(currentGroup.id); + + break; + } + case "LEAVE_GROUP": { + errorToastIfFalsy( + !currentGroup.matchId, + "Can't leave group while in a match", + ); + let newOwnerId: number | null = null; + if (currentGroup.role === "OWNER") { + newOwnerId = groupSuccessorOwner(currentGroup.id); + } + + leaveGroup({ + groupId: currentGroup.id, + userId: user.id, + newOwnerId, + wasOwner: currentGroup.role === "OWNER", + }); + + const targetChatCode = chatCodeByGroupId(currentGroup.id); + if (targetChatCode) { + ChatSystemMessage.send({ + room: targetChatCode, + type: "USER_LEFT", + context: { name: user.username }, + }); + } + + throw redirect(SENDOUQ_PAGE); + } + case "KICK_FROM_GROUP": { + validateIsGroupOwner(); + errorToastIfFalsy(data.userId !== user.id, "Can't kick yourself"); + + leaveGroup({ + groupId: currentGroup.id, + userId: data.userId, + newOwnerId: null, + wasOwner: false, + }); + + break; + } + case "REFRESH_GROUP": { + refreshGroup(currentGroup.id); + + break; + } + case "UPDATE_NOTE": { + updateNote({ + note: data.value, + groupId: currentGroup.id, + userId: user.id, + }); + refreshGroup(currentGroup.id); + + break; + } + case "DELETE_PRIVATE_USER_NOTE": { + await QRepository.deletePrivateUserNote({ + authorId: user.id, + targetId: data.targetId, + }); + + break; + } + default: { + assertUnreachable(data); + } + } + + return null; +}; diff --git a/app/features/sendouq/actions/q.match.$id.server.ts b/app/features/sendouq/actions/q.match.$id.server.ts new file mode 100644 index 000000000..af5ecc330 --- /dev/null +++ b/app/features/sendouq/actions/q.match.$id.server.ts @@ -0,0 +1,306 @@ +import type { ActionFunctionArgs } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { sql } from "~/db/sql"; +import type { ReportedWeapon } from "~/db/tables"; +import { requireUser } from "~/features/auth/core/user.server"; +import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; +import type { ChatMessage } from "~/features/chat/chat-types"; +import { currentOrPreviousSeason, currentSeason } from "~/features/mmr/season"; +import { refreshUserSkills } from "~/features/mmr/tiered.server"; +import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server"; +import { refreshStreamsCache } from "~/features/sendouq-streams/core/streams.server"; +import * as QRepository from "~/features/sendouq/QRepository.server"; +import { isMod } from "~/permissions"; +import invariant from "~/utils/invariant"; +import { logger } from "~/utils/logger"; +import { + errorToastIfFalsy, + notFoundIfFalsy, + parseParams, + parseRequestPayload, +} from "~/utils/remix.server"; +import { assertUnreachable } from "~/utils/types"; +import { SENDOUQ_PREPARING_PAGE, sendouQMatchPage } from "~/utils/urls"; +import { compareMatchToReportedScores } from "../core/match.server"; +import { mergeReportedWeapons } from "../core/reported-weapons.server"; +import { calculateMatchSkills } from "../core/skills.server"; +import { + summarizeMaps, + summarizePlayerResults, +} from "../core/summarizer.server"; +import { matchSchema, qMatchPageParamsSchema } from "../q-schemas.server"; +import { winnersArrayToWinner } from "../q-utils"; +import { addDummySkill } from "../queries/addDummySkill.server"; +import { addMapResults } from "../queries/addMapResults.server"; +import { addPlayerResults } from "../queries/addPlayerResults.server"; +import { addReportedWeapons } from "../queries/addReportedWeapons.server"; +import { addSkills } from "../queries/addSkills.server"; +import { deleteReporterWeaponsByMatchId } from "../queries/deleteReportedWeaponsByMatchId.server"; +import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server"; +import { findMatchById } from "../queries/findMatchById.server"; +import { reportScore } from "../queries/reportScore.server"; +import { reportedWeaponsByMatchId } from "../queries/reportedWeaponsByMatchId.server"; +import { setGroupAsInactive } from "../queries/setGroupAsInactive.server"; + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const matchId = parseParams({ + params, + schema: qMatchPageParamsSchema, + }).id; + const user = await requireUser(request); + const data = await parseRequestPayload({ + request, + schema: matchSchema, + }); + + switch (data._action) { + case "REPORT_SCORE": { + const reportWeapons = () => { + const oldReportedWeapons = reportedWeaponsByMatchId(matchId) ?? []; + + const mergedWeapons = mergeReportedWeapons({ + oldWeapons: oldReportedWeapons, + newWeapons: data.weapons as (ReportedWeapon & { + mapIndex: number; + groupMatchMapId: number; + })[], + newReportedMapsCount: data.winners.length, + }); + + sql.transaction(() => { + deleteReporterWeaponsByMatchId(matchId); + addReportedWeapons(mergedWeapons); + })(); + }; + + const match = notFoundIfFalsy(findMatchById(matchId)); + if (match.isLocked) { + reportWeapons(); + return null; + } + + errorToastIfFalsy( + !data.adminReport || isMod(user), + "Only mods can report scores as admin", + ); + const members = [ + ...(await QMatchRepository.findGroupById({ + groupId: match.alphaGroupId, + }))!.members.map((m) => ({ + ...m, + groupId: match.alphaGroupId, + })), + ...(await QMatchRepository.findGroupById({ + groupId: match.bravoGroupId, + }))!.members.map((m) => ({ + ...m, + groupId: match.bravoGroupId, + })), + ]; + + const groupMemberOfId = members.find((m) => m.id === user.id)?.groupId; + invariant( + groupMemberOfId || data.adminReport, + "User is not a member of any group", + ); + + const winner = winnersArrayToWinner(data.winners); + const winnerGroupId = + winner === "ALPHA" ? match.alphaGroupId : match.bravoGroupId; + const loserGroupId = + winner === "ALPHA" ? match.bravoGroupId : match.alphaGroupId; + + // when admin reports match gets locked right away + const compared = data.adminReport + ? "SAME" + : compareMatchToReportedScores({ + match, + winners: data.winners, + newReporterGroupId: groupMemberOfId!, + previousReporterGroupId: match.reportedByUserId + ? members.find((m) => m.id === match.reportedByUserId)!.groupId + : undefined, + }); + + // same group reporting same score, probably by mistake + if (compared === "DUPLICATE") { + reportWeapons(); + return null; + } + + const matchIsBeingCanceled = data.winners.length === 0; + + const { newSkills, differences } = + compared === "SAME" && !matchIsBeingCanceled + ? calculateMatchSkills({ + groupMatchId: match.id, + winner: (await QMatchRepository.findGroupById({ + groupId: winnerGroupId, + }))!.members.map((m) => m.id), + loser: (await QMatchRepository.findGroupById({ + groupId: loserGroupId, + }))!.members.map((m) => m.id), + winnerGroupId, + loserGroupId, + }) + : { newSkills: null, differences: null }; + + const shouldLockMatchWithoutChangingRecords = + compared === "SAME" && matchIsBeingCanceled; + + let clearCaches = false; + sql.transaction(() => { + if ( + compared === "FIX_PREVIOUS" || + compared === "FIRST_REPORT" || + data.adminReport + ) { + reportScore({ + matchId, + reportedByUserId: user.id, + winners: data.winners, + }); + } + // own group gets set inactive + if (groupMemberOfId) setGroupAsInactive(groupMemberOfId); + // skills & map/player results only update after both teams have reported + if (newSkills) { + addMapResults( + summarizeMaps({ match, members, winners: data.winners }), + ); + addPlayerResults( + summarizePlayerResults({ match, members, winners: data.winners }), + ); + addSkills({ + skills: newSkills, + differences, + groupMatchId: match.id, + oldMatchMemento: match.memento, + }); + clearCaches = true; + } + if (shouldLockMatchWithoutChangingRecords) { + addDummySkill(match.id); + clearCaches = true; + } + // fix edge case where they 1) report score 2) report weapons 3) report score again, but with different amount of maps played + if (compared === "FIX_PREVIOUS") { + deleteReporterWeaponsByMatchId(matchId); + } + // admin reporting, just set both groups inactive + if (data.adminReport) { + setGroupAsInactive(match.alphaGroupId); + setGroupAsInactive(match.bravoGroupId); + } + })(); + + if (clearCaches) { + // this is kind of useless to do when admin reports since skills don't change + // but it's not the most common case so it's ok + try { + refreshUserSkills(currentOrPreviousSeason(new Date())!.nth); + } catch (error) { + logger.warn("Error refreshing user skills", error); + } + + refreshStreamsCache(); + } + + if (compared === "DIFFERENT") { + return { + error: matchIsBeingCanceled + ? ("cant-cancel" as const) + : ("different" as const), + }; + } + + // in a different transaction but it's okay + reportWeapons(); + + if (match.chatCode) { + const type = (): NonNullable => { + if (compared === "SAME") { + return matchIsBeingCanceled + ? "CANCEL_CONFIRMED" + : "SCORE_CONFIRMED"; + } + + return matchIsBeingCanceled ? "CANCEL_REPORTED" : "SCORE_REPORTED"; + }; + + ChatSystemMessage.send({ + room: match.chatCode, + type: type(), + context: { + name: user.username, + }, + }); + } + + break; + } + case "LOOK_AGAIN": { + const season = currentSeason(new Date()); + errorToastIfFalsy(season, "Season is not active"); + + const previousGroup = await QMatchRepository.findGroupById({ + groupId: data.previousGroupId, + }); + errorToastIfFalsy(previousGroup, "Previous group not found"); + + for (const member of previousGroup.members) { + const currentGroup = findCurrentGroupByUserId(member.id); + errorToastIfFalsy(!currentGroup, "Member is already in a group"); + if (member.id === user.id) { + errorToastIfFalsy( + member.role === "OWNER", + "You are not the owner of the group", + ); + } + } + + await QRepository.createGroupFromPrevious({ + previousGroupId: data.previousGroupId, + members: previousGroup.members.map((m) => ({ id: m.id, role: m.role })), + }); + + throw redirect(SENDOUQ_PREPARING_PAGE); + } + case "REPORT_WEAPONS": { + const match = notFoundIfFalsy(findMatchById(matchId)); + errorToastIfFalsy(match.reportedAt, "Match has not been reported yet"); + + const oldReportedWeapons = reportedWeaponsByMatchId(matchId) ?? []; + + const mergedWeapons = mergeReportedWeapons({ + oldWeapons: oldReportedWeapons, + newWeapons: data.weapons as (ReportedWeapon & { + mapIndex: number; + groupMatchMapId: number; + })[], + }); + + sql.transaction(() => { + deleteReporterWeaponsByMatchId(matchId); + addReportedWeapons(mergedWeapons); + })(); + + break; + } + case "ADD_PRIVATE_USER_NOTE": { + await QRepository.upsertPrivateUserNote({ + authorId: user.id, + sentiment: data.sentiment, + targetId: data.targetId, + text: data.comment, + }); + + throw redirect(sendouQMatchPage(matchId)); + } + default: { + assertUnreachable(data); + } + } + + return null; +}; diff --git a/app/features/sendouq/actions/q.preparing.server.ts b/app/features/sendouq/actions/q.preparing.server.ts new file mode 100644 index 000000000..e9a4786e3 --- /dev/null +++ b/app/features/sendouq/actions/q.preparing.server.ts @@ -0,0 +1,99 @@ +import type { ActionFunctionArgs } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { requireUser } from "~/features/auth/core/user.server"; +import { currentSeason } from "~/features/mmr/season"; +import { notify } from "~/features/notifications/core/notify.server"; +import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server"; +import * as QRepository from "~/features/sendouq/QRepository.server"; +import invariant from "~/utils/invariant"; +import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server"; +import { assertUnreachable } from "~/utils/types"; +import { SENDOUQ_LOOKING_PAGE } from "~/utils/urls"; +import { hasGroupManagerPerms } from "../core/groups"; +import { FULL_GROUP_SIZE } from "../q-constants"; +import { preparingSchema } from "../q-schemas.server"; +import { addMember } from "../queries/addMember.server"; +import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server"; +import { refreshGroup } from "../queries/refreshGroup.server"; +import { setGroupAsActive } from "../queries/setGroupAsActive.server"; + +export type SendouQPreparingAction = typeof action; + +export const action = async ({ request }: ActionFunctionArgs) => { + const user = await requireUser(request); + const data = await parseRequestPayload({ + request, + schema: preparingSchema, + }); + + const currentGroup = findCurrentGroupByUserId(user.id); + errorToastIfFalsy(currentGroup, "No group found"); + + if (!hasGroupManagerPerms(currentGroup.role)) { + return null; + } + + const season = currentSeason(new Date()); + errorToastIfFalsy(season, "Season is not active"); + + switch (data._action) { + case "JOIN_QUEUE": { + if (currentGroup.status !== "PREPARING") { + return null; + } + + setGroupAsActive(currentGroup.id); + refreshGroup(currentGroup.id); + + return redirect(SENDOUQ_LOOKING_PAGE); + } + case "ADD_TRUSTED": { + const available = await QRepository.findActiveGroupMembers(); + if (available.some(({ userId }) => userId === data.id)) { + return { error: "taken" } as const; + } + + errorToastIfFalsy( + (await QRepository.usersThatTrusted(user.id)).trusters.some( + (trusterUser) => trusterUser.id === data.id, + ), + "Not trusted", + ); + + const ownGroupWithMembers = await QMatchRepository.findGroupById({ + groupId: currentGroup.id, + }); + invariant(ownGroupWithMembers, "No own group found"); + errorToastIfFalsy( + ownGroupWithMembers.members.length < FULL_GROUP_SIZE, + "Group is full", + ); + + addMember({ + groupId: currentGroup.id, + userId: data.id, + role: "MANAGER", + }); + + await QRepository.refreshTrust({ + trustGiverUserId: data.id, + trustReceiverUserId: user.id, + }); + + notify({ + userIds: [data.id], + notification: { + type: "SQ_ADDED_TO_GROUP", + meta: { + adderUsername: user.username, + }, + }, + }); + + return null; + } + default: { + assertUnreachable(data); + } + } +}; diff --git a/app/features/sendouq/actions/q.server.ts b/app/features/sendouq/actions/q.server.ts new file mode 100644 index 000000000..2895405fb --- /dev/null +++ b/app/features/sendouq/actions/q.server.ts @@ -0,0 +1,113 @@ +import type { ActionFunction } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { sql } from "~/db/sql"; +import { requireUser } from "~/features/auth/core/user.server"; +import { currentSeason } from "~/features/mmr/season"; +import * as QRepository from "~/features/sendouq/QRepository.server"; +import { giveTrust } from "~/features/tournament/queries/giveTrust.server"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import invariant from "~/utils/invariant"; +import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server"; +import { assertUnreachable } from "~/utils/types"; +import { SENDOUQ_LOOKING_PAGE, SENDOUQ_PREPARING_PAGE } from "~/utils/urls"; +import { FULL_GROUP_SIZE, JOIN_CODE_SEARCH_PARAM_KEY } from "../q-constants"; +import { frontPageSchema } from "../q-schemas.server"; +import { userCanJoinQueueAt } from "../q-utils"; +import { addMember } from "../queries/addMember.server"; +import { deleteLikesByGroupId } from "../queries/deleteLikesByGroupId.server"; +import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server"; +import { findGroupByInviteCode } from "../queries/findGroupByInviteCode.server"; + +export const action: ActionFunction = async ({ request }) => { + const user = await requireUser(request); + const data = await parseRequestPayload({ + request, + schema: frontPageSchema, + }); + + switch (data._action) { + case "JOIN_QUEUE": { + await validateCanJoinQ(user); + + await QRepository.createGroup({ + status: data.direct === "true" ? "ACTIVE" : "PREPARING", + userId: user.id, + }); + + return redirect( + data.direct === "true" ? SENDOUQ_LOOKING_PAGE : SENDOUQ_PREPARING_PAGE, + ); + } + case "JOIN_TEAM_WITH_TRUST": + case "JOIN_TEAM": { + await validateCanJoinQ(user); + + const code = new URL(request.url).searchParams.get( + JOIN_CODE_SEARCH_PARAM_KEY, + ); + + const groupInvitedTo = + code && user ? findGroupByInviteCode(code) : undefined; + errorToastIfFalsy( + groupInvitedTo, + "Invite code doesn't match any active team", + ); + errorToastIfFalsy( + groupInvitedTo.members.length < FULL_GROUP_SIZE, + "Team is full", + ); + + sql.transaction(() => { + addMember({ + groupId: groupInvitedTo.id, + userId: user.id, + role: "MANAGER", + }); + deleteLikesByGroupId(groupInvitedTo.id); + + if (data._action === "JOIN_TEAM_WITH_TRUST") { + const owner = groupInvitedTo.members.find((m) => m.role === "OWNER"); + invariant(owner, "Owner not found"); + + giveTrust({ + trustGiverUserId: user.id, + trustReceiverUserId: owner.id, + }); + } + })(); + + return redirect( + groupInvitedTo.status === "PREPARING" + ? SENDOUQ_PREPARING_PAGE + : SENDOUQ_LOOKING_PAGE, + ); + } + case "ADD_FRIEND_CODE": { + errorToastIfFalsy( + !(await UserRepository.currentFriendCodeByUserId(user.id)), + "Friend code already set", + ); + + await UserRepository.insertFriendCode({ + userId: user.id, + friendCode: data.friendCode, + submitterUserId: user.id, + }); + + return null; + } + default: { + assertUnreachable(data); + } + } +}; + +async function validateCanJoinQ(user: { id: number; discordId: string }) { + const friendCode = await UserRepository.currentFriendCodeByUserId(user.id); + errorToastIfFalsy(friendCode, "No friend code"); + const canJoinQueue = userCanJoinQueueAt(user, friendCode) === "NOW"; + + errorToastIfFalsy(currentSeason(new Date()), "Season is not active"); + errorToastIfFalsy(!findCurrentGroupByUserId(user.id), "Already in a group"); + errorToastIfFalsy(canJoinQueue, "Can't join queue right now"); +} diff --git a/app/features/sendouq/components/MemberAdder.tsx b/app/features/sendouq/components/MemberAdder.tsx index 40783e458..5eb15645a 100644 --- a/app/features/sendouq/components/MemberAdder.tsx +++ b/app/features/sendouq/components/MemberAdder.tsx @@ -13,7 +13,7 @@ import { SENDOU_INK_BASE_URL, sendouQInviteLink, } from "~/utils/urls"; -import type { SendouQPreparingAction } from "../routes/q.preparing"; +import type { SendouQPreparingAction } from "../actions/q.preparing.server"; export function MemberAdder({ inviteCode, diff --git a/app/features/sendouq/loaders/q.looking.server.ts b/app/features/sendouq/loaders/q.looking.server.ts new file mode 100644 index 000000000..4f98551f4 --- /dev/null +++ b/app/features/sendouq/loaders/q.looking.server.ts @@ -0,0 +1,133 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { getUser } from "~/features/auth/core/user.server"; +import { currentOrPreviousSeason } from "~/features/mmr/season"; +import { userSkills } from "~/features/mmr/tiered.server"; +import { cachedStreams } from "~/features/sendouq-streams/core/streams.server"; +import * as QRepository from "~/features/sendouq/QRepository.server"; +import invariant from "~/utils/invariant"; +import { isAtLeastFiveDollarTierPatreon } from "~/utils/users"; +import { hasGroupManagerPerms } from "../core/groups"; +import { + addFutureMatchModes, + addNoScreenIndicator, + addReplayIndicator, + addSkillRangeToGroups, + addSkillsToGroups, + censorGroups, + censorGroupsIfOwnExpired, + divideGroups, + groupExpiryStatus, + membersNeededForFull, + sortGroupsBySkillAndSentiment, +} from "../core/groups.server"; +import { FULL_GROUP_SIZE } from "../q-constants"; +import { groupRedirectLocationByCurrentLocation } from "../q-utils"; +import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server"; +import { findLikes } from "../queries/findLikes"; +import { findRecentMatchPlayersByUserId } from "../queries/findRecentMatchPlayersByUserId.server"; +import { groupSize } from "../queries/groupSize.server"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const user = await getUser(request); + + const isPreview = Boolean( + new URL(request.url).searchParams.get("preview") === "true" && + user && + isAtLeastFiveDollarTierPatreon(user), + ); + + const currentGroup = + user && !isPreview ? findCurrentGroupByUserId(user.id) : undefined; + const redirectLocation = isPreview + ? undefined + : groupRedirectLocationByCurrentLocation({ + group: currentGroup, + currentLocation: "looking", + }); + + if (redirectLocation) { + throw redirect(redirectLocation); + } + + invariant(currentGroup || isPreview, "currentGroup is undefined"); + + const currentGroupSize = currentGroup ? groupSize(currentGroup.id) : 1; + const groupIsFull = currentGroupSize === FULL_GROUP_SIZE; + + const dividedGroups = divideGroups({ + groups: await QRepository.findLookingGroups({ + maxGroupSize: + groupIsFull || isPreview + ? undefined + : membersNeededForFull(currentGroupSize), + minGroupSize: groupIsFull && !isPreview ? FULL_GROUP_SIZE : undefined, + ownGroupId: currentGroup?.id, + includeMapModePreferences: Boolean(groupIsFull || isPreview), + loggedInUserId: user?.id, + }), + ownGroupId: currentGroup?.id, + likes: currentGroup ? findLikes(currentGroup.id) : [], + }); + + const season = currentOrPreviousSeason(new Date()); + + const { + intervals, + userSkills: calculatedUserSkills, + isAccurateTiers, + } = userSkills(season!.nth); + const groupsWithSkills = addSkillsToGroups({ + groups: dividedGroups, + intervals, + userSkills: calculatedUserSkills, + }); + + const groupsWithFutureMatchModes = addFutureMatchModes(groupsWithSkills); + + const groupsWithNoScreenIndicator = addNoScreenIndicator( + groupsWithFutureMatchModes, + ); + + const groupsWithReplayIndicator = groupIsFull + ? addReplayIndicator({ + groups: groupsWithNoScreenIndicator, + recentMatchPlayers: findRecentMatchPlayersByUserId(user!.id), + userId: user!.id, + }) + : groupsWithNoScreenIndicator; + + const censoredGroups = censorGroups({ + groups: groupsWithReplayIndicator, + showInviteCode: currentGroup + ? hasGroupManagerPerms(currentGroup.role) && !groupIsFull + : false, + }); + + const rangedGroups = addSkillRangeToGroups({ + groups: censoredGroups, + hasLeviathan: isAccurateTiers, + isPreview, + }); + + const sortedGroups = sortGroupsBySkillAndSentiment({ + groups: rangedGroups, + intervals, + userSkills: calculatedUserSkills, + userId: user?.id, + }); + + const expiryStatus = groupExpiryStatus(currentGroup); + + return { + groups: censorGroupsIfOwnExpired({ + groups: sortedGroups, + ownGroupExpiryStatus: expiryStatus, + }), + role: currentGroup ? currentGroup.role : ("PREVIEWER" as const), + chatCode: currentGroup?.chatCode, + lastUpdated: new Date().getTime(), + streamsCount: (await cachedStreams()).length, + expiryStatus: groupExpiryStatus(currentGroup), + }; +}; diff --git a/app/features/sendouq/loaders/q.match.$id.server.ts b/app/features/sendouq/loaders/q.match.$id.server.ts new file mode 100644 index 000000000..bd20b3c9f --- /dev/null +++ b/app/features/sendouq/loaders/q.match.$id.server.ts @@ -0,0 +1,116 @@ +import cachified from "@epic-web/cachified"; +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { getUserId } from "~/features/auth/core/user.server"; +import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server"; +import { isMod } from "~/permissions"; +import { cache } from "~/utils/cache.server"; +import { databaseTimestampToDate } from "~/utils/dates"; +import invariant from "~/utils/invariant"; +import { notFoundIfFalsy, parseParams } from "~/utils/remix.server"; +import { reportedWeaponsToArrayOfArrays } from "../core/reported-weapons.server"; +import { qMatchPageParamsSchema } from "../q-schemas.server"; +import { reportedWeaponsByMatchId } from "../queries/reportedWeaponsByMatchId.server"; + +export const loader = async ({ params, request }: LoaderFunctionArgs) => { + const user = await getUserId(request); + const matchId = parseParams({ + params, + schema: qMatchPageParamsSchema, + }).id; + const match = notFoundIfFalsy(await QMatchRepository.findById(matchId)); + + const [groupAlpha, groupBravo] = await Promise.all([ + QMatchRepository.findGroupById({ + groupId: match.alphaGroupId, + loggedInUserId: user?.id, + }), + QMatchRepository.findGroupById({ + groupId: match.bravoGroupId, + loggedInUserId: user?.id, + }), + ]); + invariant(groupAlpha, "Group alpha not found"); + invariant(groupBravo, "Group bravo not found"); + + const isTeamAlphaMember = groupAlpha.members.some((m) => m.id === user?.id); + const isTeamBravoMember = groupBravo.members.some((m) => m.id === user?.id); + const isMatchInsider = isTeamAlphaMember || isTeamBravoMember || isMod(user); + const matchHappenedInTheLastMonth = + databaseTimestampToDate(match.createdAt).getTime() > + Date.now() - 30 * 24 * 3600 * 1000; + + const censoredGroupAlpha = { + ...groupAlpha, + chatCode: undefined, + members: groupAlpha.members.map((m) => ({ + ...m, + friendCode: + isMatchInsider && matchHappenedInTheLastMonth + ? m.friendCode + : undefined, + })), + }; + const censoredGroupBravo = { + ...groupBravo, + chatCode: undefined, + members: groupBravo.members.map((m) => ({ + ...m, + friendCode: + isMatchInsider && matchHappenedInTheLastMonth + ? m.friendCode + : undefined, + })), + }; + const censoredMatch = { ...match, chatCode: undefined }; + + const groupChatCode = () => { + if (isTeamAlphaMember) return groupAlpha.chatCode; + if (isTeamBravoMember) return groupBravo.chatCode; + + return null; + }; + + const rawReportedWeapons = match.reportedAt + ? reportedWeaponsByMatchId(matchId) + : null; + + const banScreen = !match.isLocked + ? await cachified({ + key: `matches-screen-ban-${match.id}`, + cache, + async getFreshValue() { + const noScreenSettings = + await QMatchRepository.groupMembersNoScreenSettings([ + groupAlpha, + groupBravo, + ]); + + return noScreenSettings.some((user) => user.noScreen); + }, + }) + : null; + + return { + match: censoredMatch, + matchChatCode: isMatchInsider ? match.chatCode : null, + canPostChatMessages: isTeamAlphaMember || isTeamBravoMember, + groupChatCode: groupChatCode(), + groupAlpha: censoredGroupAlpha, + groupBravo: censoredGroupBravo, + banScreen, + groupMemberOf: isTeamAlphaMember + ? ("ALPHA" as const) + : isTeamBravoMember + ? ("BRAVO" as const) + : null, + reportedWeapons: match.reportedAt + ? reportedWeaponsToArrayOfArrays({ + groupAlpha, + groupBravo, + mapList: match.mapList, + reportedWeapons: rawReportedWeapons, + }) + : null, + rawReportedWeapons, + }; +}; diff --git a/app/features/sendouq/loaders/q.preparing.server.ts b/app/features/sendouq/loaders/q.preparing.server.ts new file mode 100644 index 000000000..ba421fac1 --- /dev/null +++ b/app/features/sendouq/loaders/q.preparing.server.ts @@ -0,0 +1,30 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { getUser } from "~/features/auth/core/user.server"; +import invariant from "~/utils/invariant"; +import { groupRedirectLocationByCurrentLocation } from "../q-utils"; +import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server"; +import { findPreparingGroup } from "../queries/findPreparingGroup.server"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const user = await getUser(request); + + const currentGroup = user ? findCurrentGroupByUserId(user.id) : undefined; + const redirectLocation = groupRedirectLocationByCurrentLocation({ + group: currentGroup, + currentLocation: "preparing", + }); + + if (redirectLocation) { + throw redirect(redirectLocation); + } + + const ownGroup = findPreparingGroup(currentGroup!.id); + invariant(ownGroup, "No own group found"); + + return { + lastUpdated: new Date().getTime(), + group: ownGroup, + role: currentGroup!.role, + }; +}; diff --git a/app/features/sendouq/loaders/q.server.ts b/app/features/sendouq/loaders/q.server.ts new file mode 100644 index 000000000..533c5abfe --- /dev/null +++ b/app/features/sendouq/loaders/q.server.ts @@ -0,0 +1,41 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { getUserId } from "~/features/auth/core/user.server"; +import { currentSeason, nextSeason } from "~/features/mmr/season"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { JOIN_CODE_SEARCH_PARAM_KEY } from "../q-constants"; +import { groupRedirectLocationByCurrentLocation } from "../q-utils"; +import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server"; +import { findGroupByInviteCode } from "../queries/findGroupByInviteCode.server"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const user = await getUserId(request); + + const code = new URL(request.url).searchParams.get( + JOIN_CODE_SEARCH_PARAM_KEY, + ); + + const redirectLocation = groupRedirectLocationByCurrentLocation({ + group: user ? findCurrentGroupByUserId(user.id) : undefined, + currentLocation: "default", + }); + + if (redirectLocation) { + throw redirect(`${redirectLocation}${code ? "?joining=true" : ""}`); + } + + const groupInvitedTo = code && user ? findGroupByInviteCode(code) : undefined; + + const now = new Date(); + const season = currentSeason(now); + const upcomingSeason = !season ? nextSeason(now) : undefined; + + return { + season, + upcomingSeason, + groupInvitedTo, + friendCode: user + ? await UserRepository.currentFriendCodeByUserId(user.id) + : undefined, + }; +}; diff --git a/app/features/sendouq/loaders/tiers.server.ts b/app/features/sendouq/loaders/tiers.server.ts new file mode 100644 index 000000000..4bd87e00f --- /dev/null +++ b/app/features/sendouq/loaders/tiers.server.ts @@ -0,0 +1,11 @@ +import { currentOrPreviousSeason } from "~/features/mmr/season"; +import { userSkills } from "~/features/mmr/tiered.server"; + +export const loader = () => { + const season = currentOrPreviousSeason(new Date()); + const { intervals } = userSkills(season!.nth); + + return { + intervals, + }; +}; diff --git a/app/features/sendouq/routes/play.tsx b/app/features/sendouq/routes/play.ts similarity index 100% rename from app/features/sendouq/routes/play.tsx rename to app/features/sendouq/routes/play.ts diff --git a/app/features/sendouq/routes/q.looking.tsx b/app/features/sendouq/routes/q.looking.tsx index 20f8af61d..6529ab8ef 100644 --- a/app/features/sendouq/routes/q.looking.tsx +++ b/app/features/sendouq/routes/q.looking.tsx @@ -1,9 +1,4 @@ -import type { - ActionFunction, - LoaderFunctionArgs, - MetaFunction, -} from "@remix-run/node"; -import { redirect } from "@remix-run/node"; +import type { MetaFunction } from "@remix-run/node"; import { useFetcher, useLoaderData, useSearchParams } from "@remix-run/react"; import clsx from "clsx"; import * as React from "react"; @@ -16,74 +11,28 @@ import { Main } from "~/components/Main"; import { NewTabs } from "~/components/NewTabs"; import { SubmitButton } from "~/components/SubmitButton"; import { useUser } from "~/features/auth/core/user"; -import { getUser, requireUser } from "~/features/auth/core/user.server"; -import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import { Chat, useChat } from "~/features/chat/components/Chat"; -import { currentOrPreviousSeason } from "~/features/mmr/season"; -import { userSkills } from "~/features/mmr/tiered.server"; -import { notify } from "~/features/notifications/core/notify.server"; -import { cachedStreams } from "~/features/sendouq-streams/core/streams.server"; -import * as QRepository from "~/features/sendouq/QRepository.server"; import { useAutoRefresh } from "~/hooks/useAutoRefresh"; import { useIsMounted } from "~/hooks/useIsMounted"; import { useWindowSize } from "~/hooks/useWindowSize"; -import invariant from "~/utils/invariant"; import { metaTags } from "~/utils/remix"; -import { - type SendouRouteHandle, - errorToastIfFalsy, - parseRequestPayload, -} from "~/utils/remix.server"; -import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql"; -import { assertUnreachable } from "~/utils/types"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import { SENDOUQ_LOOKING_PAGE, SENDOUQ_PAGE, SENDOUQ_SETTINGS_PAGE, SENDOUQ_STREAMS_PAGE, navIconUrl, - sendouQMatchPage, } from "~/utils/urls"; -import { isAtLeastFiveDollarTierPatreon } from "~/utils/users"; import { GroupCard } from "../components/GroupCard"; import { GroupLeaver } from "../components/GroupLeaver"; import { MemberAdder } from "../components/MemberAdder"; -import { groupAfterMorph, hasGroupManagerPerms } from "../core/groups"; -import { - addFutureMatchModes, - addNoScreenIndicator, - addReplayIndicator, - addSkillRangeToGroups, - addSkillsToGroups, - censorGroups, - censorGroupsIfOwnExpired, - divideGroups, - groupExpiryStatus, - membersNeededForFull, - sortGroupsBySkillAndSentiment, -} from "../core/groups.server"; -import { createMatchMemento, matchMapList } from "../core/match.server"; import { FULL_GROUP_SIZE } from "../q-constants"; -import { lookingSchema } from "../q-schemas.server"; import type { LookingGroupWithInviteCode } from "../q-types"; -import { groupRedirectLocationByCurrentLocation } from "../q-utils"; -import { addLike } from "../queries/addLike.server"; -import { addManagerRole } from "../queries/addManagerRole.server"; -import { chatCodeByGroupId } from "../queries/chatCodeByGroupId.server"; -import { createMatch } from "../queries/createMatch.server"; -import { deleteLike } from "../queries/deleteLike.server"; -import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server"; -import { findLikes } from "../queries/findLikes"; -import { findRecentMatchPlayersByUserId } from "../queries/findRecentMatchPlayersByUserId.server"; -import { groupHasMatch } from "../queries/groupHasMatch.server"; -import { groupSize } from "../queries/groupSize.server"; -import { groupSuccessorOwner } from "../queries/groupSuccessorOwner"; -import { leaveGroup } from "../queries/leaveGroup.server"; -import { likeExists } from "../queries/likeExists.server"; -import { morphGroups } from "../queries/morphGroups.server"; -import { refreshGroup } from "../queries/refreshGroup.server"; -import { removeManagerRole } from "../queries/removeManagerRole.server"; -import { updateNote } from "../queries/updateNote.server"; + +import { action } from "../actions/q.looking.server"; +import { loader } from "../loaders/q.looking.server"; +export { action, loader }; import "../q.css"; @@ -103,445 +52,6 @@ export const meta: MetaFunction = (args) => { }); }; -// this function doesn't throw normally because we are assuming -// if there is a validation error the user saw stale data -// and when we return null we just force a refresh -export const action: ActionFunction = async ({ request }) => { - const user = await requireUser(request); - const data = await parseRequestPayload({ - request, - schema: lookingSchema, - }); - const currentGroup = findCurrentGroupByUserId(user.id); - if (!currentGroup) return null; - - // this throws because there should normally be no way user loses ownership by the action of some other user - const validateIsGroupOwner = () => - errorToastIfFalsy(currentGroup.role === "OWNER", "Not owner"); - const isGroupManager = () => - currentGroup.role === "MANAGER" || currentGroup.role === "OWNER"; - - switch (data._action) { - case "LIKE": { - if (!isGroupManager()) return null; - - try { - addLike({ - likerGroupId: currentGroup.id, - targetGroupId: data.targetGroupId, - }); - } catch (e) { - if (!(e instanceof Error)) throw e; - // the group disbanded before we could like it - if (errorIsSqliteForeignKeyConstraintFailure(e)) return null; - - throw e; - } - refreshGroup(currentGroup.id); - - const targetChatCode = chatCodeByGroupId(data.targetGroupId); - if (targetChatCode) { - ChatSystemMessage.send({ - room: targetChatCode, - type: "LIKE_RECEIVED", - revalidateOnly: true, - }); - } - - break; - } - case "RECHALLENGE": { - if (!isGroupManager()) return null; - - await QRepository.rechallenge({ - likerGroupId: currentGroup.id, - targetGroupId: data.targetGroupId, - }); - - const targetChatCode = chatCodeByGroupId(data.targetGroupId); - if (targetChatCode) { - ChatSystemMessage.send({ - room: targetChatCode, - type: "LIKE_RECEIVED", - revalidateOnly: true, - }); - } - break; - } - case "UNLIKE": { - if (!isGroupManager()) return null; - - deleteLike({ - likerGroupId: currentGroup.id, - targetGroupId: data.targetGroupId, - }); - refreshGroup(currentGroup.id); - - break; - } - case "GROUP_UP": { - if (!isGroupManager()) return null; - if ( - !likeExists({ - targetGroupId: currentGroup.id, - likerGroupId: data.targetGroupId, - }) - ) { - return null; - } - - const lookingGroups = await QRepository.findLookingGroups({ - maxGroupSize: membersNeededForFull(groupSize(currentGroup.id)), - ownGroupId: currentGroup.id, - includeChatCode: true, - }); - - const ourGroup = lookingGroups.find( - (group) => group.id === currentGroup.id, - ); - if (!ourGroup) return null; - const theirGroup = lookingGroups.find( - (group) => group.id === data.targetGroupId, - ); - if (!theirGroup) return null; - - const { id: survivingGroupId } = groupAfterMorph({ - liker: "THEM", - ourGroup, - theirGroup, - }); - - const otherGroup = - ourGroup.id === survivingGroupId ? theirGroup : ourGroup; - - invariant(ourGroup.members, "our group has no members"); - invariant(otherGroup.members, "other group has no members"); - - morphGroups({ - survivingGroupId, - otherGroupId: otherGroup.id, - newMembers: otherGroup.members.map((m) => m.id), - }); - refreshGroup(survivingGroupId); - - if (ourGroup.chatCode && theirGroup.chatCode) { - ChatSystemMessage.send([ - { - room: ourGroup.chatCode, - type: "NEW_GROUP", - revalidateOnly: true, - }, - { - room: theirGroup.chatCode, - type: "NEW_GROUP", - revalidateOnly: true, - }, - ]); - } - - break; - } - case "MATCH_UP_RECHALLENGE": - case "MATCH_UP": { - if (!isGroupManager()) return null; - if ( - !likeExists({ - targetGroupId: currentGroup.id, - likerGroupId: data.targetGroupId, - }) - ) { - return null; - } - - const lookingGroups = await QRepository.findLookingGroups({ - minGroupSize: FULL_GROUP_SIZE, - ownGroupId: currentGroup.id, - includeChatCode: true, - }); - - const ourGroup = lookingGroups.find( - (group) => group.id === currentGroup.id, - ); - if (!ourGroup) return null; - const theirGroup = lookingGroups.find( - (group) => group.id === data.targetGroupId, - ); - if (!theirGroup) return null; - - errorToastIfFalsy( - ourGroup.members.length === FULL_GROUP_SIZE, - "Our group is not full", - ); - errorToastIfFalsy( - theirGroup.members.length === FULL_GROUP_SIZE, - "Their group is not full", - ); - - errorToastIfFalsy( - !groupHasMatch(ourGroup.id), - "Our group already has a match", - ); - errorToastIfFalsy( - !groupHasMatch(theirGroup.id), - "Their group already has a match", - ); - - const ourGroupPreferences = await QRepository.mapModePreferencesByGroupId( - ourGroup.id, - ); - const theirGroupPreferences = - await QRepository.mapModePreferencesByGroupId(theirGroup.id); - const mapList = matchMapList( - { - id: ourGroup.id, - preferences: ourGroupPreferences, - }, - { - id: theirGroup.id, - preferences: theirGroupPreferences, - ignoreModePreferences: data._action === "MATCH_UP_RECHALLENGE", - }, - ); - const createdMatch = createMatch({ - alphaGroupId: ourGroup.id, - bravoGroupId: theirGroup.id, - mapList, - memento: createMatchMemento({ - own: { group: ourGroup, preferences: ourGroupPreferences }, - their: { group: theirGroup, preferences: theirGroupPreferences }, - mapList, - }), - }); - - if (ourGroup.chatCode && theirGroup.chatCode) { - ChatSystemMessage.send([ - { - room: ourGroup.chatCode, - type: "MATCH_STARTED", - revalidateOnly: true, - }, - { - room: theirGroup.chatCode, - type: "MATCH_STARTED", - revalidateOnly: true, - }, - ]); - } - - notify({ - userIds: [ - ...ourGroup.members.map((m) => m.id), - ...theirGroup.members.map((m) => m.id), - ], - defaultSeenUserIds: [user.id], - notification: { - type: "SQ_NEW_MATCH", - meta: { - matchId: createdMatch.id, - }, - }, - }); - - throw redirect(sendouQMatchPage(createdMatch.id)); - } - case "GIVE_MANAGER": { - validateIsGroupOwner(); - - addManagerRole({ - groupId: currentGroup.id, - userId: data.userId, - }); - refreshGroup(currentGroup.id); - - break; - } - case "REMOVE_MANAGER": { - validateIsGroupOwner(); - - removeManagerRole({ - groupId: currentGroup.id, - userId: data.userId, - }); - refreshGroup(currentGroup.id); - - break; - } - case "LEAVE_GROUP": { - errorToastIfFalsy( - !currentGroup.matchId, - "Can't leave group while in a match", - ); - let newOwnerId: number | null = null; - if (currentGroup.role === "OWNER") { - newOwnerId = groupSuccessorOwner(currentGroup.id); - } - - leaveGroup({ - groupId: currentGroup.id, - userId: user.id, - newOwnerId, - wasOwner: currentGroup.role === "OWNER", - }); - - const targetChatCode = chatCodeByGroupId(currentGroup.id); - if (targetChatCode) { - ChatSystemMessage.send({ - room: targetChatCode, - type: "USER_LEFT", - context: { name: user.username }, - }); - } - - throw redirect(SENDOUQ_PAGE); - } - case "KICK_FROM_GROUP": { - validateIsGroupOwner(); - errorToastIfFalsy(data.userId !== user.id, "Can't kick yourself"); - - leaveGroup({ - groupId: currentGroup.id, - userId: data.userId, - newOwnerId: null, - wasOwner: false, - }); - - break; - } - case "REFRESH_GROUP": { - refreshGroup(currentGroup.id); - - break; - } - case "UPDATE_NOTE": { - updateNote({ - note: data.value, - groupId: currentGroup.id, - userId: user.id, - }); - refreshGroup(currentGroup.id); - - break; - } - case "DELETE_PRIVATE_USER_NOTE": { - await QRepository.deletePrivateUserNote({ - authorId: user.id, - targetId: data.targetId, - }); - - break; - } - default: { - assertUnreachable(data); - } - } - - return null; -}; - -export const loader = async ({ request }: LoaderFunctionArgs) => { - const user = await getUser(request); - - const isPreview = Boolean( - new URL(request.url).searchParams.get("preview") === "true" && - user && - isAtLeastFiveDollarTierPatreon(user), - ); - - const currentGroup = - user && !isPreview ? findCurrentGroupByUserId(user.id) : undefined; - const redirectLocation = isPreview - ? undefined - : groupRedirectLocationByCurrentLocation({ - group: currentGroup, - currentLocation: "looking", - }); - - if (redirectLocation) { - throw redirect(redirectLocation); - } - - invariant(currentGroup || isPreview, "currentGroup is undefined"); - - const currentGroupSize = currentGroup ? groupSize(currentGroup.id) : 1; - const groupIsFull = currentGroupSize === FULL_GROUP_SIZE; - - const dividedGroups = divideGroups({ - groups: await QRepository.findLookingGroups({ - maxGroupSize: - groupIsFull || isPreview - ? undefined - : membersNeededForFull(currentGroupSize), - minGroupSize: groupIsFull && !isPreview ? FULL_GROUP_SIZE : undefined, - ownGroupId: currentGroup?.id, - includeMapModePreferences: Boolean(groupIsFull || isPreview), - loggedInUserId: user?.id, - }), - ownGroupId: currentGroup?.id, - likes: currentGroup ? findLikes(currentGroup.id) : [], - }); - - const season = currentOrPreviousSeason(new Date()); - - const { - intervals, - userSkills: calculatedUserSkills, - isAccurateTiers, - } = userSkills(season!.nth); - const groupsWithSkills = addSkillsToGroups({ - groups: dividedGroups, - intervals, - userSkills: calculatedUserSkills, - }); - - const groupsWithFutureMatchModes = addFutureMatchModes(groupsWithSkills); - - const groupsWithNoScreenIndicator = addNoScreenIndicator( - groupsWithFutureMatchModes, - ); - - const groupsWithReplayIndicator = groupIsFull - ? addReplayIndicator({ - groups: groupsWithNoScreenIndicator, - recentMatchPlayers: findRecentMatchPlayersByUserId(user!.id), - userId: user!.id, - }) - : groupsWithNoScreenIndicator; - - const censoredGroups = censorGroups({ - groups: groupsWithReplayIndicator, - showInviteCode: currentGroup - ? hasGroupManagerPerms(currentGroup.role) && !groupIsFull - : false, - }); - - const rangedGroups = addSkillRangeToGroups({ - groups: censoredGroups, - hasLeviathan: isAccurateTiers, - isPreview, - }); - - const sortedGroups = sortGroupsBySkillAndSentiment({ - groups: rangedGroups, - intervals, - userSkills: calculatedUserSkills, - userId: user?.id, - }); - - const expiryStatus = groupExpiryStatus(currentGroup); - - return { - groups: censorGroupsIfOwnExpired({ - groups: sortedGroups, - ownGroupExpiryStatus: expiryStatus, - }), - role: currentGroup ? currentGroup.role : ("PREVIEWER" as const), - chatCode: currentGroup?.chatCode, - lastUpdated: new Date().getTime(), - streamsCount: (await cachedStreams()).length, - expiryStatus: groupExpiryStatus(currentGroup), - }; -}; - export default function QLookingPage() { const { t } = useTranslation(["q"]); const user = useUser(); diff --git a/app/features/sendouq/routes/q.match.$id.tsx b/app/features/sendouq/routes/q.match.$id.tsx index 6ff74f8a0..2eb9f52a8 100644 --- a/app/features/sendouq/routes/q.match.$id.tsx +++ b/app/features/sendouq/routes/q.match.$id.tsx @@ -1,11 +1,4 @@ -import cachified from "@epic-web/cachified"; -import type { - ActionFunctionArgs, - LoaderFunctionArgs, - MetaFunction, - SerializeFrom, -} from "@remix-run/node"; -import { redirect } from "@remix-run/node"; +import type { MetaFunction, SerializeFrom } from "@remix-run/node"; import type { FetcherWithComponents } from "@remix-run/react"; import { Link, @@ -30,23 +23,17 @@ import { NewTabs } from "~/components/NewTabs"; import { SubmitButton } from "~/components/SubmitButton"; import { SendouButton } from "~/components/elements/Button"; import { SendouPopover } from "~/components/elements/Popover"; +import { SendouSwitch } from "~/components/elements/Switch"; import { ArchiveBoxIcon } from "~/components/icons/ArchiveBox"; import { CrossIcon } from "~/components/icons/Cross"; import { DiscordIcon } from "~/components/icons/Discord"; import { RefreshArrowsIcon } from "~/components/icons/RefreshArrows"; import { ScaleIcon } from "~/components/icons/Scale"; -import { sql } from "~/db/sql"; +import type { Tables } from "~/db/tables"; import { useUser } from "~/features/auth/core/user"; -import { getUserId, requireUser } from "~/features/auth/core/user.server"; -import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; -import type { ChatMessage } from "~/features/chat/chat-types"; import { Chat, type ChatProps, useChat } from "~/features/chat/components/Chat"; -import { currentOrPreviousSeason, currentSeason } from "~/features/mmr/season"; -import { refreshUserSkills } from "~/features/mmr/tiered.server"; -import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server"; +import { currentSeason } from "~/features/mmr/season"; import { AddPrivateNoteDialog } from "~/features/sendouq-match/components/AddPrivateNoteDialog"; -import { refreshStreamsCache } from "~/features/sendouq-streams/core/streams.server"; -import * as QRepository from "~/features/sendouq/QRepository.server"; import { resolveRoomPass } from "~/features/tournament-bracket/tournament-bracket-utils"; import { useIsMounted } from "~/hooks/useIsMounted"; import { useWindowSize } from "~/hooks/useWindowSize"; @@ -54,25 +41,17 @@ import type { MainWeaponId } from "~/modules/in-game-lists"; import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids"; import { isMod } from "~/permissions"; import { joinListToNaturalString } from "~/utils/arrays"; -import { cache } from "~/utils/cache.server"; import { databaseTimestampToDate } from "~/utils/dates"; import { animate } from "~/utils/flip"; import invariant from "~/utils/invariant"; -import { logger } from "~/utils/logger"; import { safeNumberParse } from "~/utils/number"; +import { metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; -import { - errorToastIfFalsy, - notFoundIfFalsy, - parseParams, - parseRequestPayload, -} from "~/utils/remix.server"; import { inGameNameWithoutDiscriminator } from "~/utils/strings"; import type { Unpacked } from "~/utils/types"; import { assertUnreachable } from "~/utils/types"; import { SENDOUQ_PAGE, - SENDOUQ_PREPARING_PAGE, SENDOUQ_RULES_PAGE, SENDOU_INK_DISCORD_URL, navIconUrl, @@ -84,36 +63,15 @@ import { } from "~/utils/urls"; import { GroupCard } from "../components/GroupCard"; import { matchEndedAtIndex } from "../core/match"; -import { compareMatchToReportedScores } from "../core/match.server"; import type { ReportedWeaponForMerging } from "../core/reported-weapons.server"; -import { - mergeReportedWeapons, - reportedWeaponsToArrayOfArrays, -} from "../core/reported-weapons.server"; -import { calculateMatchSkills } from "../core/skills.server"; -import { - summarizeMaps, - summarizePlayerResults, -} from "../core/summarizer.server"; import { FULL_GROUP_SIZE } from "../q-constants"; import { useRecentlyReportedWeapons } from "../q-hooks"; -import { matchSchema, qMatchPageParamsSchema } from "../q-schemas.server"; -import { winnersArrayToWinner } from "../q-utils"; -import { addDummySkill } from "../queries/addDummySkill.server"; -import { addMapResults } from "../queries/addMapResults.server"; -import { addPlayerResults } from "../queries/addPlayerResults.server"; -import { addReportedWeapons } from "../queries/addReportedWeapons.server"; -import { addSkills } from "../queries/addSkills.server"; -import { deleteReporterWeaponsByMatchId } from "../queries/deleteReportedWeaponsByMatchId.server"; -import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server"; -import { findMatchById } from "../queries/findMatchById.server"; -import { reportScore } from "../queries/reportScore.server"; -import { reportedWeaponsByMatchId } from "../queries/reportedWeaponsByMatchId.server"; -import { setGroupAsInactive } from "../queries/setGroupAsInactive.server"; + +import { action } from "../actions/q.match.$id.server"; +import { loader } from "../loaders/q.match.$id.server"; +export { loader, action }; + import "../q.css"; -import { SendouSwitch } from "~/components/elements/Switch"; -import type { ReportedWeapon, Tables } from "~/db/tables"; -import { metaTags } from "~/utils/remix"; export const meta: MetaFunction = (args) => { const data = args.data as SerializeFrom | null; @@ -140,373 +98,6 @@ export const handle: SendouRouteHandle = { }), }; -export const action = async ({ request, params }: ActionFunctionArgs) => { - const matchId = parseParams({ - params, - schema: qMatchPageParamsSchema, - }).id; - const user = await requireUser(request); - const data = await parseRequestPayload({ - request, - schema: matchSchema, - }); - - switch (data._action) { - case "REPORT_SCORE": { - const reportWeapons = () => { - const oldReportedWeapons = reportedWeaponsByMatchId(matchId) ?? []; - - const mergedWeapons = mergeReportedWeapons({ - oldWeapons: oldReportedWeapons, - newWeapons: data.weapons as (ReportedWeapon & { - mapIndex: number; - groupMatchMapId: number; - })[], - newReportedMapsCount: data.winners.length, - }); - - sql.transaction(() => { - deleteReporterWeaponsByMatchId(matchId); - addReportedWeapons(mergedWeapons); - })(); - }; - - const match = notFoundIfFalsy(findMatchById(matchId)); - if (match.isLocked) { - reportWeapons(); - return null; - } - - errorToastIfFalsy( - !data.adminReport || isMod(user), - "Only mods can report scores as admin", - ); - const members = [ - ...(await QMatchRepository.findGroupById({ - groupId: match.alphaGroupId, - }))!.members.map((m) => ({ - ...m, - groupId: match.alphaGroupId, - })), - ...(await QMatchRepository.findGroupById({ - groupId: match.bravoGroupId, - }))!.members.map((m) => ({ - ...m, - groupId: match.bravoGroupId, - })), - ]; - - const groupMemberOfId = members.find((m) => m.id === user.id)?.groupId; - invariant( - groupMemberOfId || data.adminReport, - "User is not a member of any group", - ); - - const winner = winnersArrayToWinner(data.winners); - const winnerGroupId = - winner === "ALPHA" ? match.alphaGroupId : match.bravoGroupId; - const loserGroupId = - winner === "ALPHA" ? match.bravoGroupId : match.alphaGroupId; - - // when admin reports match gets locked right away - const compared = data.adminReport - ? "SAME" - : compareMatchToReportedScores({ - match, - winners: data.winners, - newReporterGroupId: groupMemberOfId!, - previousReporterGroupId: match.reportedByUserId - ? members.find((m) => m.id === match.reportedByUserId)!.groupId - : undefined, - }); - - // same group reporting same score, probably by mistake - if (compared === "DUPLICATE") { - reportWeapons(); - return null; - } - - const matchIsBeingCanceled = data.winners.length === 0; - - const { newSkills, differences } = - compared === "SAME" && !matchIsBeingCanceled - ? calculateMatchSkills({ - groupMatchId: match.id, - winner: (await QMatchRepository.findGroupById({ - groupId: winnerGroupId, - }))!.members.map((m) => m.id), - loser: (await QMatchRepository.findGroupById({ - groupId: loserGroupId, - }))!.members.map((m) => m.id), - winnerGroupId, - loserGroupId, - }) - : { newSkills: null, differences: null }; - - const shouldLockMatchWithoutChangingRecords = - compared === "SAME" && matchIsBeingCanceled; - - let clearCaches = false; - sql.transaction(() => { - if ( - compared === "FIX_PREVIOUS" || - compared === "FIRST_REPORT" || - data.adminReport - ) { - reportScore({ - matchId, - reportedByUserId: user.id, - winners: data.winners, - }); - } - // own group gets set inactive - if (groupMemberOfId) setGroupAsInactive(groupMemberOfId); - // skills & map/player results only update after both teams have reported - if (newSkills) { - addMapResults( - summarizeMaps({ match, members, winners: data.winners }), - ); - addPlayerResults( - summarizePlayerResults({ match, members, winners: data.winners }), - ); - addSkills({ - skills: newSkills, - differences, - groupMatchId: match.id, - oldMatchMemento: match.memento, - }); - clearCaches = true; - } - if (shouldLockMatchWithoutChangingRecords) { - addDummySkill(match.id); - clearCaches = true; - } - // fix edge case where they 1) report score 2) report weapons 3) report score again, but with different amount of maps played - if (compared === "FIX_PREVIOUS") { - deleteReporterWeaponsByMatchId(matchId); - } - // admin reporting, just set both groups inactive - if (data.adminReport) { - setGroupAsInactive(match.alphaGroupId); - setGroupAsInactive(match.bravoGroupId); - } - })(); - - if (clearCaches) { - // this is kind of useless to do when admin reports since skills don't change - // but it's not the most common case so it's ok - try { - refreshUserSkills(currentOrPreviousSeason(new Date())!.nth); - } catch (error) { - logger.warn("Error refreshing user skills", error); - } - - refreshStreamsCache(); - } - - if (compared === "DIFFERENT") { - return { - error: matchIsBeingCanceled - ? ("cant-cancel" as const) - : ("different" as const), - }; - } - - // in a different transaction but it's okay - reportWeapons(); - - if (match.chatCode) { - const type = (): NonNullable => { - if (compared === "SAME") { - return matchIsBeingCanceled - ? "CANCEL_CONFIRMED" - : "SCORE_CONFIRMED"; - } - - return matchIsBeingCanceled ? "CANCEL_REPORTED" : "SCORE_REPORTED"; - }; - - ChatSystemMessage.send({ - room: match.chatCode, - type: type(), - context: { - name: user.username, - }, - }); - } - - break; - } - case "LOOK_AGAIN": { - const season = currentSeason(new Date()); - errorToastIfFalsy(season, "Season is not active"); - - const previousGroup = await QMatchRepository.findGroupById({ - groupId: data.previousGroupId, - }); - errorToastIfFalsy(previousGroup, "Previous group not found"); - - for (const member of previousGroup.members) { - const currentGroup = findCurrentGroupByUserId(member.id); - errorToastIfFalsy(!currentGroup, "Member is already in a group"); - if (member.id === user.id) { - errorToastIfFalsy( - member.role === "OWNER", - "You are not the owner of the group", - ); - } - } - - await QRepository.createGroupFromPrevious({ - previousGroupId: data.previousGroupId, - members: previousGroup.members.map((m) => ({ id: m.id, role: m.role })), - }); - - throw redirect(SENDOUQ_PREPARING_PAGE); - } - case "REPORT_WEAPONS": { - const match = notFoundIfFalsy(findMatchById(matchId)); - errorToastIfFalsy(match.reportedAt, "Match has not been reported yet"); - - const oldReportedWeapons = reportedWeaponsByMatchId(matchId) ?? []; - - const mergedWeapons = mergeReportedWeapons({ - oldWeapons: oldReportedWeapons, - newWeapons: data.weapons as (ReportedWeapon & { - mapIndex: number; - groupMatchMapId: number; - })[], - }); - - sql.transaction(() => { - deleteReporterWeaponsByMatchId(matchId); - addReportedWeapons(mergedWeapons); - })(); - - break; - } - case "ADD_PRIVATE_USER_NOTE": { - await QRepository.upsertPrivateUserNote({ - authorId: user.id, - sentiment: data.sentiment, - targetId: data.targetId, - text: data.comment, - }); - - throw redirect(sendouQMatchPage(matchId)); - } - default: { - assertUnreachable(data); - } - } - - return null; -}; - -export const loader = async ({ params, request }: LoaderFunctionArgs) => { - const user = await getUserId(request); - const matchId = parseParams({ - params, - schema: qMatchPageParamsSchema, - }).id; - const match = notFoundIfFalsy(await QMatchRepository.findById(matchId)); - - const [groupAlpha, groupBravo] = await Promise.all([ - QMatchRepository.findGroupById({ - groupId: match.alphaGroupId, - loggedInUserId: user?.id, - }), - QMatchRepository.findGroupById({ - groupId: match.bravoGroupId, - loggedInUserId: user?.id, - }), - ]); - invariant(groupAlpha, "Group alpha not found"); - invariant(groupBravo, "Group bravo not found"); - - const isTeamAlphaMember = groupAlpha.members.some((m) => m.id === user?.id); - const isTeamBravoMember = groupBravo.members.some((m) => m.id === user?.id); - const isMatchInsider = isTeamAlphaMember || isTeamBravoMember || isMod(user); - const matchHappenedInTheLastMonth = - databaseTimestampToDate(match.createdAt).getTime() > - Date.now() - 30 * 24 * 3600 * 1000; - - const censoredGroupAlpha = { - ...groupAlpha, - chatCode: undefined, - members: groupAlpha.members.map((m) => ({ - ...m, - friendCode: - isMatchInsider && matchHappenedInTheLastMonth - ? m.friendCode - : undefined, - })), - }; - const censoredGroupBravo = { - ...groupBravo, - chatCode: undefined, - members: groupBravo.members.map((m) => ({ - ...m, - friendCode: - isMatchInsider && matchHappenedInTheLastMonth - ? m.friendCode - : undefined, - })), - }; - const censoredMatch = { ...match, chatCode: undefined }; - - const groupChatCode = () => { - if (isTeamAlphaMember) return groupAlpha.chatCode; - if (isTeamBravoMember) return groupBravo.chatCode; - - return null; - }; - - const rawReportedWeapons = match.reportedAt - ? reportedWeaponsByMatchId(matchId) - : null; - - const banScreen = !match.isLocked - ? await cachified({ - key: `matches-screen-ban-${match.id}`, - cache, - async getFreshValue() { - const noScreenSettings = - await QMatchRepository.groupMembersNoScreenSettings([ - groupAlpha, - groupBravo, - ]); - - return noScreenSettings.some((user) => user.noScreen); - }, - }) - : null; - - return { - match: censoredMatch, - matchChatCode: isMatchInsider ? match.chatCode : null, - canPostChatMessages: isTeamAlphaMember || isTeamBravoMember, - groupChatCode: groupChatCode(), - groupAlpha: censoredGroupAlpha, - groupBravo: censoredGroupBravo, - banScreen, - groupMemberOf: isTeamAlphaMember - ? ("ALPHA" as const) - : isTeamBravoMember - ? ("BRAVO" as const) - : null, - reportedWeapons: match.reportedAt - ? reportedWeaponsToArrayOfArrays({ - groupAlpha, - groupBravo, - mapList: match.mapList, - reportedWeapons: rawReportedWeapons, - }) - : null, - rawReportedWeapons, - }; -}; - export default function QMatchPage() { const user = useUser(); const isMounted = useIsMounted(); diff --git a/app/features/sendouq/routes/q.preparing.tsx b/app/features/sendouq/routes/q.preparing.tsx index 3a6df47f4..a780d4ddf 100644 --- a/app/features/sendouq/routes/q.preparing.tsx +++ b/app/features/sendouq/routes/q.preparing.tsx @@ -1,41 +1,21 @@ -import type { - ActionFunctionArgs, - LoaderFunctionArgs, - MetaFunction, -} from "@remix-run/node"; -import { redirect } from "@remix-run/node"; +import type { MetaFunction } from "@remix-run/node"; import { useFetcher, useLoaderData } from "@remix-run/react"; import { useTranslation } from "react-i18next"; import { Main } from "~/components/Main"; import { SubmitButton } from "~/components/SubmitButton"; -import { getUser, requireUser } from "~/features/auth/core/user.server"; -import { currentSeason } from "~/features/mmr/season"; -import { notify } from "~/features/notifications/core/notify.server"; -import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server"; -import * as QRepository from "~/features/sendouq/QRepository.server"; import { useAutoRefresh } from "~/hooks/useAutoRefresh"; -import invariant from "~/utils/invariant"; import { metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; -import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server"; -import { assertUnreachable } from "~/utils/types"; -import { - SENDOUQ_LOOKING_PAGE, - SENDOUQ_PREPARING_PAGE, - navIconUrl, -} from "~/utils/urls"; +import { SENDOUQ_PREPARING_PAGE, navIconUrl } from "~/utils/urls"; import { GroupCard } from "../components/GroupCard"; import { GroupLeaver } from "../components/GroupLeaver"; import { MemberAdder } from "../components/MemberAdder"; import { hasGroupManagerPerms } from "../core/groups"; import { FULL_GROUP_SIZE } from "../q-constants"; -import { preparingSchema } from "../q-schemas.server"; -import { groupRedirectLocationByCurrentLocation } from "../q-utils"; -import { addMember } from "../queries/addMember.server"; -import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server"; -import { findPreparingGroup } from "../queries/findPreparingGroup.server"; -import { refreshGroup } from "../queries/refreshGroup.server"; -import { setGroupAsActive } from "../queries/setGroupAsActive.server"; + +import { action } from "../actions/q.preparing.server"; +import { loader } from "../loaders/q.preparing.server"; +export { loader, action }; import "../q.css"; @@ -55,110 +35,6 @@ export const meta: MetaFunction = (args) => { }); }; -export type SendouQPreparingAction = typeof action; - -export const action = async ({ request }: ActionFunctionArgs) => { - const user = await requireUser(request); - const data = await parseRequestPayload({ - request, - schema: preparingSchema, - }); - - const currentGroup = findCurrentGroupByUserId(user.id); - errorToastIfFalsy(currentGroup, "No group found"); - - if (!hasGroupManagerPerms(currentGroup.role)) { - return null; - } - - const season = currentSeason(new Date()); - errorToastIfFalsy(season, "Season is not active"); - - switch (data._action) { - case "JOIN_QUEUE": { - if (currentGroup.status !== "PREPARING") { - return null; - } - - setGroupAsActive(currentGroup.id); - refreshGroup(currentGroup.id); - - return redirect(SENDOUQ_LOOKING_PAGE); - } - case "ADD_TRUSTED": { - const available = await QRepository.findActiveGroupMembers(); - if (available.some(({ userId }) => userId === data.id)) { - return { error: "taken" } as const; - } - - errorToastIfFalsy( - (await QRepository.usersThatTrusted(user.id)).trusters.some( - (trusterUser) => trusterUser.id === data.id, - ), - "Not trusted", - ); - - const ownGroupWithMembers = await QMatchRepository.findGroupById({ - groupId: currentGroup.id, - }); - invariant(ownGroupWithMembers, "No own group found"); - errorToastIfFalsy( - ownGroupWithMembers.members.length < FULL_GROUP_SIZE, - "Group is full", - ); - - addMember({ - groupId: currentGroup.id, - userId: data.id, - role: "MANAGER", - }); - - await QRepository.refreshTrust({ - trustGiverUserId: data.id, - trustReceiverUserId: user.id, - }); - - notify({ - userIds: [data.id], - notification: { - type: "SQ_ADDED_TO_GROUP", - meta: { - adderUsername: user.username, - }, - }, - }); - - return null; - } - default: { - assertUnreachable(data); - } - } -}; - -export const loader = async ({ request }: LoaderFunctionArgs) => { - const user = await getUser(request); - - const currentGroup = user ? findCurrentGroupByUserId(user.id) : undefined; - const redirectLocation = groupRedirectLocationByCurrentLocation({ - group: currentGroup, - currentLocation: "preparing", - }); - - if (redirectLocation) { - throw redirect(redirectLocation); - } - - const ownGroup = findPreparingGroup(currentGroup!.id); - invariant(ownGroup, "No own group found"); - - return { - lastUpdated: new Date().getTime(), - group: ownGroup, - role: currentGroup!.role, - }; -}; - export default function QPreparingPage() { const { t } = useTranslation(["q"]); const data = useLoaderData(); diff --git a/app/features/sendouq/routes/q.tsx b/app/features/sendouq/routes/q.tsx index 2911510f9..8712b0388 100644 --- a/app/features/sendouq/routes/q.tsx +++ b/app/features/sendouq/routes/q.tsx @@ -1,10 +1,4 @@ -import type { - ActionFunction, - LoaderFunctionArgs, - MetaFunction, - SerializeFrom, -} from "@remix-run/node"; -import { redirect } from "@remix-run/node"; +import type { MetaFunction, SerializeFrom } from "@remix-run/node"; import { Link, useFetcher, useLoaderData } from "@remix-run/react"; import clsx from "clsx"; import * as React from "react"; @@ -20,32 +14,21 @@ import { Main } from "~/components/Main"; import { SubmitButton } from "~/components/SubmitButton"; import { UserIcon } from "~/components/icons/User"; import { UsersIcon } from "~/components/icons/Users"; -import { sql } from "~/db/sql"; +import type { Tables } from "~/db/tables"; import { useUser } from "~/features/auth/core/user"; -import { getUserId, requireUser } from "~/features/auth/core/user.server"; import type { RankingSeason } from "~/features/mmr/season"; -import { currentSeason, nextSeason } from "~/features/mmr/season"; -import * as QRepository from "~/features/sendouq/QRepository.server"; -import { giveTrust } from "~/features/tournament/queries/giveTrust.server"; -import * as UserRepository from "~/features/user-page/UserRepository.server"; import { useAutoRerender } from "~/hooks/useAutoRerender"; import { useIsMounted } from "~/hooks/useIsMounted"; import { joinListToNaturalString } from "~/utils/arrays"; import invariant from "~/utils/invariant"; -import { - type SendouRouteHandle, - errorToastIfFalsy, - parseRequestPayload, -} from "~/utils/remix.server"; -import { assertUnreachable } from "~/utils/types"; +import { metaTags } from "~/utils/remix"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import { LEADERBOARDS_PAGE, LOG_IN_URL, SENDOUQ_INFO_PAGE, - SENDOUQ_LOOKING_PAGE, SENDOUQ_LOOKING_PREVIEW_PAGE, SENDOUQ_PAGE, - SENDOUQ_PREPARING_PAGE, SENDOUQ_RULES_PAGE, SENDOUQ_SETTINGS_PAGE, SENDOUQ_STREAMS_PAGE, @@ -55,19 +38,14 @@ import { import { isAtLeastFiveDollarTierPatreon } from "~/utils/users"; import { SendouButton } from "../../../components/elements/Button"; import { SendouPopover } from "../../../components/elements/Popover"; -import { FULL_GROUP_SIZE, JOIN_CODE_SEARCH_PARAM_KEY } from "../q-constants"; -import { frontPageSchema } from "../q-schemas.server"; -import { - groupRedirectLocationByCurrentLocation, - userCanJoinQueueAt, -} from "../q-utils"; -import { addMember } from "../queries/addMember.server"; -import { deleteLikesByGroupId } from "../queries/deleteLikesByGroupId.server"; -import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server"; -import { findGroupByInviteCode } from "../queries/findGroupByInviteCode.server"; +import { FULL_GROUP_SIZE } from "../q-constants"; +import { userCanJoinQueueAt } from "../q-utils"; + +import { action } from "../actions/q.server"; +import { loader } from "../loaders/q.server"; +export { loader, action }; + import "../q.css"; -import type { Tables } from "~/db/tables"; -import { metaTags } from "~/utils/remix"; export const handle: SendouRouteHandle = { i18n: ["q"], @@ -87,132 +65,6 @@ export const meta: MetaFunction = (args) => { }); }; -const validateCanJoinQ = async (user: { id: number; discordId: string }) => { - const friendCode = await UserRepository.currentFriendCodeByUserId(user.id); - errorToastIfFalsy(friendCode, "No friend code"); - const canJoinQueue = userCanJoinQueueAt(user, friendCode) === "NOW"; - - errorToastIfFalsy(currentSeason(new Date()), "Season is not active"); - errorToastIfFalsy(!findCurrentGroupByUserId(user.id), "Already in a group"); - errorToastIfFalsy(canJoinQueue, "Can't join queue right now"); -}; - -export const action: ActionFunction = async ({ request }) => { - const user = await requireUser(request); - const data = await parseRequestPayload({ - request, - schema: frontPageSchema, - }); - - switch (data._action) { - case "JOIN_QUEUE": { - await validateCanJoinQ(user); - - await QRepository.createGroup({ - status: data.direct === "true" ? "ACTIVE" : "PREPARING", - userId: user.id, - }); - - return redirect( - data.direct === "true" ? SENDOUQ_LOOKING_PAGE : SENDOUQ_PREPARING_PAGE, - ); - } - case "JOIN_TEAM_WITH_TRUST": - case "JOIN_TEAM": { - await validateCanJoinQ(user); - - const code = new URL(request.url).searchParams.get( - JOIN_CODE_SEARCH_PARAM_KEY, - ); - - const groupInvitedTo = - code && user ? findGroupByInviteCode(code) : undefined; - errorToastIfFalsy( - groupInvitedTo, - "Invite code doesn't match any active team", - ); - errorToastIfFalsy( - groupInvitedTo.members.length < FULL_GROUP_SIZE, - "Team is full", - ); - - sql.transaction(() => { - addMember({ - groupId: groupInvitedTo.id, - userId: user.id, - role: "MANAGER", - }); - deleteLikesByGroupId(groupInvitedTo.id); - - if (data._action === "JOIN_TEAM_WITH_TRUST") { - const owner = groupInvitedTo.members.find((m) => m.role === "OWNER"); - invariant(owner, "Owner not found"); - - giveTrust({ - trustGiverUserId: user.id, - trustReceiverUserId: owner.id, - }); - } - })(); - - return redirect( - groupInvitedTo.status === "PREPARING" - ? SENDOUQ_PREPARING_PAGE - : SENDOUQ_LOOKING_PAGE, - ); - } - case "ADD_FRIEND_CODE": { - errorToastIfFalsy( - !(await UserRepository.currentFriendCodeByUserId(user.id)), - "Friend code already set", - ); - - await UserRepository.insertFriendCode({ - userId: user.id, - friendCode: data.friendCode, - submitterUserId: user.id, - }); - - return null; - } - default: { - assertUnreachable(data); - } - } -}; - -export const loader = async ({ request }: LoaderFunctionArgs) => { - const user = await getUserId(request); - - const code = new URL(request.url).searchParams.get( - JOIN_CODE_SEARCH_PARAM_KEY, - ); - - const redirectLocation = groupRedirectLocationByCurrentLocation({ - group: user ? findCurrentGroupByUserId(user.id) : undefined, - currentLocation: "default", - }); - - if (redirectLocation) { - throw redirect(`${redirectLocation}${code ? "?joining=true" : ""}`); - } - - const groupInvitedTo = code && user ? findGroupByInviteCode(code) : undefined; - - const now = new Date(); - const season = currentSeason(now); - const upcomingSeason = !season ? nextSeason(now) : undefined; - - return { - season, - upcomingSeason, - groupInvitedTo, - friendCode: user - ? await UserRepository.currentFriendCodeByUserId(user.id) - : undefined, - }; -}; - export default function QPage() { const { t } = useTranslation(["q"]); const [dialogOpen, setDialogOpen] = React.useState(true); diff --git a/app/features/sendouq/routes/tiers.tsx b/app/features/sendouq/routes/tiers.tsx index 2bc4244f2..57bed77d4 100644 --- a/app/features/sendouq/routes/tiers.tsx +++ b/app/features/sendouq/routes/tiers.tsx @@ -9,11 +9,12 @@ import { USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN, } from "~/features/mmr/mmr-constants"; import { ordinalToSp } from "~/features/mmr/mmr-utils"; -import { currentOrPreviousSeason } from "~/features/mmr/season"; -import { userSkills } from "~/features/mmr/tiered.server"; import { metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; +import { loader } from "../loaders/tiers.server"; +export { loader }; + export const meta: MetaFunction = (args) => { return metaTags({ title: "SendouQ - Tiers", @@ -27,15 +28,6 @@ export const handle: SendouRouteHandle = { i18n: ["q"], }; -export const loader = () => { - const season = currentOrPreviousSeason(new Date()); - const { intervals } = userSkills(season!.nth); - - return { - intervals, - }; -}; - export default function TiersPage() { const data = useLoaderData(); const { t } = useTranslation(["q"]); diff --git a/app/features/sendouq/routes/weapon-usage.tsx b/app/features/sendouq/routes/weapon-usage.ts similarity index 100% rename from app/features/sendouq/routes/weapon-usage.tsx rename to app/features/sendouq/routes/weapon-usage.ts diff --git a/app/features/settings/routes/settings.tsx b/app/features/settings/routes/settings.tsx index 5ca4083c8..3d3327e89 100644 --- a/app/features/settings/routes/settings.tsx +++ b/app/features/settings/routes/settings.tsx @@ -15,6 +15,7 @@ import type { SendouRouteHandle } from "~/utils/remix.server"; import { SETTINGS_PAGE, navIconUrl } from "~/utils/urls"; import { SendouButton } from "../../../components/elements/Button"; import { SendouPopover } from "../../../components/elements/Popover"; + import { action } from "../actions/settings.server"; export { action }; diff --git a/app/features/team/routes/t.$customUrl.edit.tsx b/app/features/team/routes/t.$customUrl.edit.tsx index 2577871be..a2d5d935b 100644 --- a/app/features/team/routes/t.$customUrl.edit.tsx +++ b/app/features/team/routes/t.$customUrl.edit.tsx @@ -19,13 +19,13 @@ import { teamPage, uploadImagePage, } from "~/utils/urls"; -import { action } from "../actions/t.$customUrl.edit.server"; -import { loader } from "../loaders/t.$customUrl.edit.server"; import { TEAM } from "../team-constants"; import { canAddCustomizedColors, isTeamOwner } from "../team-utils"; import "../team.css"; import { metaTags } from "~/utils/remix"; +import { action } from "../actions/t.$customUrl.edit.server"; +import { loader } from "../loaders/t.$customUrl.edit.server"; export { action, loader }; export const meta: MetaFunction = (args) => { diff --git a/app/features/team/routes/t.$customUrl.join.tsx b/app/features/team/routes/t.$customUrl.join.tsx index 75b11a171..5949e49bd 100644 --- a/app/features/team/routes/t.$customUrl.join.tsx +++ b/app/features/team/routes/t.$customUrl.join.tsx @@ -3,10 +3,10 @@ import { useTranslation } from "react-i18next"; import { Main } from "~/components/Main"; import { SubmitButton } from "~/components/SubmitButton"; import type { SendouRouteHandle } from "~/utils/remix.server"; -import { action } from "../actions/t.$customUrl.join.server"; -import { loader } from "../loaders/t.$customUrl.join.server"; import "../team.css"; +import { action } from "../actions/t.$customUrl.join.server"; +import { loader } from "../loaders/t.$customUrl.join.server"; export { loader, action }; export const handle: SendouRouteHandle = { diff --git a/app/features/team/routes/t.$customUrl.roster.tsx b/app/features/team/routes/t.$customUrl.roster.tsx index 98e7c5ad2..61be13e6e 100644 --- a/app/features/team/routes/t.$customUrl.roster.tsx +++ b/app/features/team/routes/t.$customUrl.roster.tsx @@ -26,9 +26,9 @@ import { TEAM_MEMBER_ROLES } from "../team-constants"; import { isTeamFull } from "../team-utils"; import "../team.css"; import { metaTags } from "~/utils/remix"; + import { action } from "../actions/t.$customUrl.roster.server"; import { loader } from "../loaders/t.$customUrl.roster.server"; - export { loader, action }; export const meta: MetaFunction = (args) => { diff --git a/app/features/team/routes/t.$customUrl.tsx b/app/features/team/routes/t.$customUrl.tsx index 98e613a04..faaee848d 100644 --- a/app/features/team/routes/t.$customUrl.tsx +++ b/app/features/team/routes/t.$customUrl.tsx @@ -29,8 +29,6 @@ import { userSubmittedImage, } from "~/utils/urls"; import type * as TeamRepository from "../TeamRepository.server"; -import { action } from "../actions/t.$customUrl.server"; -import { loader } from "../loaders/t.$customUrl.server"; import { isTeamManager, isTeamMember, @@ -39,6 +37,9 @@ import { } from "../team-utils"; import "../team.css"; import { metaTags } from "~/utils/remix"; + +import { action } from "../actions/t.$customUrl.server"; +import { loader } from "../loaders/t.$customUrl.server"; export { action, loader }; export const meta: MetaFunction = (args) => { diff --git a/app/features/team/routes/t.tsx b/app/features/team/routes/t.tsx index fd8cce566..a4db88d8c 100644 --- a/app/features/team/routes/t.tsx +++ b/app/features/team/routes/t.tsx @@ -31,12 +31,12 @@ import { import { isAtLeastFiveDollarTierPatreon } from "~/utils/users"; import { TEAM, TEAMS_PER_PAGE } from "../team-constants"; -import "../team.css"; - import { action } from "../actions/t.server"; import { loader } from "../loaders/t.server"; export { loader, action }; +import "../team.css"; + export const meta: MetaFunction = (args) => { return metaTags({ title: "Team Search", diff --git a/app/features/top-search/loaders/xsearch.player.$id.server.ts b/app/features/top-search/loaders/xsearch.player.$id.server.ts new file mode 100644 index 000000000..0460792a8 --- /dev/null +++ b/app/features/top-search/loaders/xsearch.player.$id.server.ts @@ -0,0 +1,25 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { removeDuplicates } from "~/utils/arrays"; +import { notFoundIfFalsy } from "~/utils/remix.server"; +import { findPlacementsByPlayerId } from "../queries/findPlacements.server"; + +export const loader = async ({ params }: LoaderFunctionArgs) => { + const placements = notFoundIfFalsy( + findPlacementsByPlayerId(Number(params.id)), + ); + + const primaryName = placements[0].name; + const aliases = removeDuplicates( + placements + .map((placement) => placement.name) + .filter((name) => name !== primaryName), + ); + + return { + placements, + names: { + primary: primaryName, + aliases, + }, + }; +}; diff --git a/app/features/top-search/loaders/xsearch.server.ts b/app/features/top-search/loaders/xsearch.server.ts new file mode 100644 index 000000000..b25f21e52 --- /dev/null +++ b/app/features/top-search/loaders/xsearch.server.ts @@ -0,0 +1,62 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import type { RankedModeShort } from "~/modules/in-game-lists"; +import { rankedModesShort } from "~/modules/in-game-lists/modes"; +import { findPlacementsOfMonth } from "../queries/findPlacements.server"; +import { monthYears } from "../queries/monthYears"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const availableMonthYears = monthYears(); + const { month: latestMonth, year: latestYear } = availableMonthYears[0]; + + const url = new URL(request.url); + const mode = (() => { + const mode = url.searchParams.get("mode"); + if (rankedModesShort.includes(mode as any)) { + return mode as RankedModeShort; + } + + return "SZ"; + })(); + const region = (() => { + const region = url.searchParams.get("region"); + if (region === "WEST" || region === "JPN") { + return region; + } + + return "WEST"; + })(); + const month = (() => { + const month = url.searchParams.get("month"); + if (month) { + const monthNumber = Number(month); + if (monthNumber >= 1 && monthNumber <= 12) { + return monthNumber; + } + } + + return latestMonth; + })(); + const year = (() => { + const year = url.searchParams.get("year"); + if (year) { + const yearNumber = Number(year); + if (yearNumber >= 2023) { + return yearNumber; + } + } + + return latestYear; + })(); + + const placements = findPlacementsOfMonth({ + mode, + region, + month, + year, + }); + + return { + placements, + availableMonthYears, + }; +}; diff --git a/app/features/top-search/routes/xsearch.player.$id.tsx b/app/features/top-search/routes/xsearch.player.$id.tsx index 698945ec3..61985edf4 100644 --- a/app/features/top-search/routes/xsearch.player.$id.tsx +++ b/app/features/top-search/routes/xsearch.player.$id.tsx @@ -1,14 +1,9 @@ -import type { - LoaderFunctionArgs, - MetaFunction, - SerializeFrom, -} from "@remix-run/node"; +import type { MetaFunction, SerializeFrom } from "@remix-run/node"; import { Link, useLoaderData } from "@remix-run/react"; import { useTranslation } from "react-i18next"; import { Main } from "~/components/Main"; -import { removeDuplicates } from "~/utils/arrays"; import { metaTags } from "~/utils/remix"; -import { type SendouRouteHandle, notFoundIfFalsy } from "~/utils/remix.server"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import { navIconUrl, topSearchPage, @@ -16,7 +11,9 @@ import { userPage, } from "~/utils/urls"; import { PlacementsTable } from "../components/Placements"; -import { findPlacementsByPlayerId } from "../queries/findPlacements.server"; + +import { loader } from "../loaders/xsearch.player.$id.server"; +export { loader }; import "../top-search.css"; @@ -58,27 +55,6 @@ export const meta: MetaFunction = (args) => { }); }; -export const loader = async ({ params }: LoaderFunctionArgs) => { - const placements = notFoundIfFalsy( - findPlacementsByPlayerId(Number(params.id)), - ); - - const primaryName = placements[0].name; - const aliases = removeDuplicates( - placements - .map((placement) => placement.name) - .filter((name) => name !== primaryName), - ); - - return { - placements, - names: { - primary: primaryName, - aliases, - }, - }; -}; - export default function XSearchPlayerPage() { const { t } = useTranslation(["common"]); const data = useLoaderData(); diff --git a/app/features/top-search/routes/xsearch.tsx b/app/features/top-search/routes/xsearch.tsx index c4481fdc0..68cc2e887 100644 --- a/app/features/top-search/routes/xsearch.tsx +++ b/app/features/top-search/routes/xsearch.tsx @@ -1,8 +1,9 @@ -import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; +import type { MetaFunction } from "@remix-run/node"; import { useLoaderData, useSearchParams } from "@remix-run/react"; import { nanoid } from "nanoid"; import { useTranslation } from "react-i18next"; import { Main } from "~/components/Main"; +import type { Tables } from "~/db/tables"; import type { RankedModeShort } from "~/modules/in-game-lists"; import { rankedModesShort } from "~/modules/in-game-lists/modes"; import invariant from "~/utils/invariant"; @@ -10,12 +11,12 @@ import { metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { navIconUrl, topSearchPage } from "~/utils/urls"; import { PlacementsTable } from "../components/Placements"; -import { findPlacementsOfMonth } from "../queries/findPlacements.server"; -import { monthYears } from "../queries/monthYears"; import type { MonthYear } from "../top-search-utils"; +import { loader } from "../loaders/xsearch.server"; +export { loader }; + import "../top-search.css"; -import type { Tables } from "~/db/tables"; export const handle: SendouRouteHandle = { breadcrumb: () => ({ @@ -35,63 +36,6 @@ export const meta: MetaFunction = (args) => { }); }; -export const loader = async ({ request }: LoaderFunctionArgs) => { - const availableMonthYears = monthYears(); - const { month: latestMonth, year: latestYear } = availableMonthYears[0]; - - const url = new URL(request.url); - const mode = (() => { - const mode = url.searchParams.get("mode"); - if (rankedModesShort.includes(mode as any)) { - return mode as RankedModeShort; - } - - return "SZ"; - })(); - const region = (() => { - const region = url.searchParams.get("region"); - if (region === "WEST" || region === "JPN") { - return region; - } - - return "WEST"; - })(); - const month = (() => { - const month = url.searchParams.get("month"); - if (month) { - const monthNumber = Number(month); - if (monthNumber >= 1 && monthNumber <= 12) { - return monthNumber; - } - } - - return latestMonth; - })(); - const year = (() => { - const year = url.searchParams.get("year"); - if (year) { - const yearNumber = Number(year); - if (yearNumber >= 2023) { - return yearNumber; - } - } - - return latestYear; - })(); - - const placements = findPlacementsOfMonth({ - mode, - region, - month, - year, - }); - - return { - placements, - availableMonthYears, - }; -}; - export default function XSearchPage() { const [searchParams, setSearchParams] = useSearchParams(); const { t } = useTranslation(["common", "game-misc"]); diff --git a/app/features/tournament-bracket/actions/to.$id.matches.$mid.server.ts b/app/features/tournament-bracket/actions/to.$id.matches.$mid.server.ts new file mode 100644 index 000000000..d473cd08d --- /dev/null +++ b/app/features/tournament-bracket/actions/to.$id.matches.$mid.server.ts @@ -0,0 +1,581 @@ +import type { ActionFunction } from "@remix-run/node"; +import { nanoid } from "nanoid"; +import { sql } from "~/db/sql"; +import { requireUser } from "~/features/auth/core/user.server"; +import { tournamentIdFromParams } from "~/features/tournament"; +import * as TournamentMatchRepository from "~/features/tournament-bracket/TournamentMatchRepository.server"; +import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; +import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; +import { canReportTournamentScore } from "~/permissions"; +import invariant from "~/utils/invariant"; +import { logger } from "~/utils/logger"; +import { + errorToastIfFalsy, + notFoundIfFalsy, + parseParams, + parseRequestPayload, +} from "~/utils/remix.server"; +import { assertUnreachable } from "~/utils/types"; +import * as PickBan from "../core/PickBan"; +import { + clearTournamentDataCache, + tournamentFromDB, +} from "../core/Tournament.server"; +import { getServerTournamentManager } from "../core/brackets-manager/manager.server"; +import { emitter } from "../core/emitters.server"; +import { resolveMapList } from "../core/mapList.server"; +import { deleteMatchPickBanEvents } from "../queries/deleteMatchPickBanEvents.server"; +import { deleteParticipantsByMatchGameResultId } from "../queries/deleteParticipantsByMatchGameResultId.server"; +import { deletePickBanEvent } from "../queries/deletePickBanEvent.server"; +import { deleteTournamentMatchGameResultById } from "../queries/deleteTournamentMatchGameResultById.server"; +import { findMatchById } from "../queries/findMatchById.server"; +import { findResultsByMatchId } from "../queries/findResultsByMatchId.server"; +import { insertTournamentMatchGameResult } from "../queries/insertTournamentMatchGameResult.server"; +import { insertTournamentMatchGameResultParticipant } from "../queries/insertTournamentMatchGameResultParticipant.server"; +import { updateMatchGameResultPoints } from "../queries/updateMatchGameResultPoints.server"; +import { + matchPageParamsSchema, + matchSchema, +} from "../tournament-bracket-schemas.server"; +import { + bracketSubscriptionKey, + isSetOverByScore, + matchIsLocked, + matchSubscriptionKey, + tournamentTeamToActiveRosterUserIds, +} from "../tournament-bracket-utils"; + +export const action: ActionFunction = async ({ params, request }) => { + const user = await requireUser(request); + const matchId = parseParams({ + params, + schema: matchPageParamsSchema, + }).mid; + const match = notFoundIfFalsy(findMatchById(matchId)); + const data = await parseRequestPayload({ + request, + schema: matchSchema, + }); + + const tournamentId = tournamentIdFromParams(params); + const tournament = await tournamentFromDB({ tournamentId, user }); + + const validateCanReportScore = () => { + const isMemberOfATeamInTheMatch = match.players.some( + (p) => p.id === user?.id, + ); + + errorToastIfFalsy( + canReportTournamentScore({ + match, + isMemberOfATeamInTheMatch, + isOrganizer: tournament.isOrganizer(user), + }), + "Unauthorized", + ); + }; + + const manager = getServerTournamentManager(); + + const scores: [number, number] = [ + match.opponentOne?.score ?? 0, + match.opponentTwo?.score ?? 0, + ]; + + const pickBanEvents = match.roundMaps?.pickBan + ? await TournamentRepository.pickBanEventsByMatchId(match.id) + : []; + + const mapList = + match.opponentOne?.id && match.opponentTwo?.id + ? resolveMapList({ + bestOf: match.bestOf, + tournamentId, + matchId, + teams: [match.opponentOne.id, match.opponentTwo.id], + mapPickingStyle: match.mapPickingStyle, + maps: match.roundMaps, + pickBanEvents, + }) + : null; + + let emitMatchUpdate = false; + let emitBracketUpdate = false; + switch (data._action) { + case "REPORT_SCORE": { + // they are trying to report score that was already reported + // assume that it was already reported and make their page refresh + if (data.position !== scores[0] + scores[1]) { + return null; + } + + validateCanReportScore(); + errorToastIfFalsy( + match.opponentOne?.id === data.winnerTeamId || + match.opponentTwo?.id === data.winnerTeamId, + "Winner team id is invalid", + ); + errorToastIfFalsy( + match.opponentOne && match.opponentTwo, + "Teams are missing", + ); + errorToastIfFalsy( + !matchIsLocked({ matchId: match.id, tournament, scores }), + "Match is locked", + ); + + const currentMap = mapList?.filter((m) => !m.bannedByTournamentTeamId)[ + data.position + ]; + invariant(currentMap, "Can't resolve current map"); + + const scoreToIncrement = () => { + if (data.winnerTeamId === match.opponentOne?.id) return 0; + if (data.winnerTeamId === match.opponentTwo?.id) return 1; + + errorToastIfFalsy(false, "Winner team id is invalid"); + }; + + errorToastIfFalsy( + !data.points || + (scoreToIncrement() === 0 && data.points[0] > data.points[1]) || + (scoreToIncrement() === 1 && data.points[1] > data.points[0]), + "Points are invalid (winner must have more points than loser)", + ); + + // TODO: could also validate that if bracket demands it then points are defined + + scores[scoreToIncrement()]++; + + const setOver = isSetOverByScore({ + count: match.roundMaps?.count ?? match.bestOf, + countType: match.roundMaps?.type ?? "BEST_OF", + scores, + }); + + const teamOneRoster = tournamentTeamToActiveRosterUserIds( + tournament.teamById(match.opponentOne.id!)!, + tournament.minMembersPerTeam, + ); + const teamTwoRoster = tournamentTeamToActiveRosterUserIds( + tournament.teamById(match.opponentTwo.id!)!, + tournament.minMembersPerTeam, + ); + + errorToastIfFalsy(teamOneRoster, "Team one has no active roster"); + errorToastIfFalsy(teamTwoRoster, "Team two has no active roster"); + + errorToastIfFalsy( + new Set([...teamOneRoster, ...teamTwoRoster]).size === + tournament.minMembersPerTeam * 2, + "Duplicate user in rosters", + ); + + sql.transaction(() => { + manager.update.match({ + id: match.id, + opponent1: { + score: scores[0], + result: setOver && scores[0] > scores[1] ? "win" : undefined, + }, + opponent2: { + score: scores[1], + result: setOver && scores[1] > scores[0] ? "win" : undefined, + }, + }); + + const result = insertTournamentMatchGameResult({ + matchId: match.id, + mode: currentMap.mode, + stageId: currentMap.stageId, + reporterId: user.id, + winnerTeamId: data.winnerTeamId, + number: data.position + 1, + source: String(currentMap.source), + opponentOnePoints: data.points?.[0] ?? null, + opponentTwoPoints: data.points?.[1] ?? null, + }); + + for (const userId of teamOneRoster) { + insertTournamentMatchGameResultParticipant({ + matchGameResultId: result.id, + userId, + tournamentTeamId: match.opponentOne!.id!, + }); + } + for (const userId of teamTwoRoster) { + insertTournamentMatchGameResultParticipant({ + matchGameResultId: result.id, + userId, + tournamentTeamId: match.opponentTwo!.id!, + }); + } + })(); + + emitMatchUpdate = true; + emitBracketUpdate = true; + + break; + } + case "SET_ACTIVE_ROSTER": { + errorToastIfFalsy(!tournament.everyBracketOver, "Tournament is over"); + errorToastIfFalsy( + tournament.isOrganizer(user) || + tournament.teamMemberOfByUser(user)?.id === data.teamId, + "Unauthorized", + ); + errorToastIfFalsy( + data.roster.length === tournament.minMembersPerTeam, + "Invalid roster length", + ); + + const team = tournament.teamById(data.teamId)!; + errorToastIfFalsy( + data.roster.every((userId) => + team.members.some((m) => m.userId === userId), + ), + "Invalid roster", + ); + + await TournamentTeamRepository.setActiveRoster({ + teamId: data.teamId, + activeRosterUserIds: data.roster, + }); + + emitMatchUpdate = true; + + break; + } + case "UNDO_REPORT_SCORE": { + validateCanReportScore(); + // they are trying to remove score from the past + if (data.position !== scores[0] + scores[1] - 1) { + return null; + } + + const results = findResultsByMatchId(matchId); + const lastResult = results[results.length - 1]; + invariant(lastResult, "Last result is missing"); + + const shouldReset = results.length === 1; + + if (lastResult.winnerTeamId === match.opponentOne?.id) { + scores[0]--; + } else { + scores[1]--; + } + + logger.info( + `Undoing score: Position: ${data.position}; User ID: ${user.id}; Match ID: ${match.id}`, + ); + + const pickBanEventToDeleteNumber = await (async () => { + if (!match.roundMaps?.pickBan) return; + + const pickBanEvents = await TournamentRepository.pickBanEventsByMatchId( + match.id, + ); + + const unplayedPicks = pickBanEvents + .filter((e) => e.type === "PICK") + .filter( + (e) => + !results.some( + (r) => r.stageId === e.stageId && r.mode === e.mode, + ), + ); + invariant(unplayedPicks.length <= 1, "Too many unplayed picks"); + + return unplayedPicks[0]?.number; + })(); + + sql.transaction(() => { + deleteTournamentMatchGameResultById(lastResult.id); + + manager.update.match({ + id: match.id, + opponent1: { + score: shouldReset ? undefined : scores[0], + }, + opponent2: { + score: shouldReset ? undefined : scores[1], + }, + }); + + if (shouldReset) { + manager.reset.matchResults(match.id); + } + + if (typeof pickBanEventToDeleteNumber === "number") { + deletePickBanEvent({ matchId, number: pickBanEventToDeleteNumber }); + } + })(); + + emitMatchUpdate = true; + emitBracketUpdate = true; + + break; + } + case "UPDATE_REPORTED_SCORE": { + errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer"); + errorToastIfFalsy(!tournament.ctx.isFinalized, "Tournament is finalized"); + + const result = await TournamentMatchRepository.findResultById( + data.resultId, + ); + errorToastIfFalsy(result, "Result not found"); + errorToastIfFalsy( + data.rosters[0].length === tournament.minMembersPerTeam && + data.rosters[1].length === tournament.minMembersPerTeam, + "Invalid roster length", + ); + + const hadPoints = typeof result.opponentOnePoints === "number"; + const willHavePoints = typeof data.points?.[0] === "number"; + errorToastIfFalsy( + (hadPoints && willHavePoints) || (!hadPoints && !willHavePoints), + "Points mismatch", + ); + + if (data.points) { + if (data.points[0] !== result.opponentOnePoints) { + // changing points at this point could retroactively change who advanced from the group + errorToastIfFalsy( + tournament.matchCanBeReopened(match.id), + "Bracket has progressed", + ); + } + + if (result.opponentOnePoints! > result.opponentTwoPoints!) { + errorToastIfFalsy( + data.points[0] > data.points[1], + "Winner must have more points than loser", + ); + } else { + errorToastIfFalsy( + data.points[0] < data.points[1], + "Winner must have more points than loser", + ); + } + } + + sql.transaction(() => { + if (data.points) { + updateMatchGameResultPoints({ + matchGameResultId: result.id, + opponentOnePoints: data.points[0], + opponentTwoPoints: data.points[1], + }); + } + + deleteParticipantsByMatchGameResultId(result.id); + + for (const userId of data.rosters[0]) { + insertTournamentMatchGameResultParticipant({ + matchGameResultId: result.id, + userId, + tournamentTeamId: match.opponentOne!.id!, + }); + } + for (const userId of data.rosters[1]) { + insertTournamentMatchGameResultParticipant({ + matchGameResultId: result.id, + userId, + tournamentTeamId: match.opponentTwo!.id!, + }); + } + })(); + + emitMatchUpdate = true; + emitBracketUpdate = true; + + break; + } + case "BAN_PICK": { + const results = findResultsByMatchId(matchId); + + const teamOne = match.opponentOne?.id + ? tournament.teamById(match.opponentOne.id) + : undefined; + const teamTwo = match.opponentTwo?.id + ? tournament.teamById(match.opponentTwo.id) + : undefined; + invariant(teamOne && teamTwo, "Teams are missing"); + + invariant( + match.roundMaps && match.opponentOne?.id && match.opponentTwo?.id, + "Missing fields to pick/ban", + ); + const pickerTeamId = PickBan.turnOf({ + results, + maps: match.roundMaps, + teams: [match.opponentOne.id, match.opponentTwo.id], + mapList, + }); + errorToastIfFalsy(pickerTeamId, "Not time to pick/ban"); + errorToastIfFalsy( + tournament.isOrganizer(user) || + tournament.ownedTeamByUser(user)?.id === pickerTeamId, + "Unauthorized", + ); + + errorToastIfFalsy( + PickBan.isLegal({ + results, + map: data, + maps: match.roundMaps, + toSetMapPool: + tournament.ctx.mapPickingStyle === "TO" + ? await TournamentRepository.findTOSetMapPoolById(tournamentId) + : [], + mapList, + tieBreakerMapPool: tournament.ctx.tieBreakerMapPool, + teams: [teamOne, teamTwo], + pickerTeamId, + }), + "Illegal pick", + ); + + const pickBanEvents = await TournamentRepository.pickBanEventsByMatchId( + match.id, + ); + await TournamentRepository.addPickBanEvent({ + authorId: user.id, + matchId: match.id, + stageId: data.stageId, + mode: data.mode, + number: pickBanEvents.length + 1, + type: match.roundMaps.pickBan === "BAN_2" ? "BAN" : "PICK", + }); + + emitMatchUpdate = true; + + break; + } + case "REOPEN_MATCH": { + const scoreOne = match.opponentOne?.score ?? 0; + const scoreTwo = match.opponentTwo?.score ?? 0; + invariant(typeof scoreOne === "number", "Score one is missing"); + invariant(typeof scoreTwo === "number", "Score two is missing"); + invariant(scoreOne !== scoreTwo, "Scores are equal"); + + errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer"); + errorToastIfFalsy( + tournament.matchCanBeReopened(match.id), + "Match can't be reopened, bracket has progressed", + ); + + const results = findResultsByMatchId(matchId); + const lastResult = results[results.length - 1]; + invariant(lastResult, "Last result is missing"); + + if (scoreOne > scoreTwo) { + scores[0]--; + } else { + scores[1]--; + } + + logger.info( + `Reopening match: User ID: ${user.id}; Match ID: ${match.id}`, + ); + + const followingMatches = tournament.followingMatches(match.id); + sql.transaction(() => { + for (const match of followingMatches) { + deleteMatchPickBanEvents({ matchId: match.id }); + } + deleteTournamentMatchGameResultById(lastResult.id); + manager.update.match({ + id: match.id, + opponent1: { + score: scores[0], + result: undefined, + }, + opponent2: { + score: scores[1], + result: undefined, + }, + }); + })(); + + emitMatchUpdate = true; + emitBracketUpdate = true; + + break; + } + case "SET_AS_CASTED": { + errorToastIfFalsy( + tournament.isOrganizerOrStreamer(user), + "Not an organizer or streamer", + ); + + await TournamentRepository.setMatchAsCasted({ + matchId: match.id, + tournamentId: tournament.ctx.id, + twitchAccount: data.twitchAccount, + }); + + emitBracketUpdate = true; + + break; + } + case "LOCK": { + errorToastIfFalsy( + tournament.isOrganizerOrStreamer(user), + "Not an organizer or streamer", + ); + + // can't lock, let's update their view to reflect that + if (match.opponentOne?.id && match.opponentTwo?.id) { + return null; + } + + await TournamentRepository.lockMatch({ + matchId: match.id, + tournamentId: tournament.ctx.id, + }); + + emitMatchUpdate = true; + + break; + } + case "UNLOCK": { + errorToastIfFalsy( + tournament.isOrganizerOrStreamer(user), + "Not an organizer or streamer", + ); + + await TournamentRepository.unlockMatch({ + matchId: match.id, + tournamentId: tournament.ctx.id, + }); + + emitMatchUpdate = true; + + break; + } + default: { + assertUnreachable(data); + } + } + + if (emitMatchUpdate) { + emitter.emit(matchSubscriptionKey(match.id), { + eventId: nanoid(), + userId: user.id, + }); + } + if (emitBracketUpdate) { + emitter.emit(bracketSubscriptionKey(tournament.ctx.id), { + matchId: match.id, + scores, + isOver: + scores[0] === Math.ceil(match.bestOf / 2) || + scores[1] === Math.ceil(match.bestOf / 2), + }); + } + + clearTournamentDataCache(tournamentId); + + return null; +}; diff --git a/app/features/tournament-bracket/components/Bracket/Match.tsx b/app/features/tournament-bracket/components/Bracket/Match.tsx index b1c685336..dc449729a 100644 --- a/app/features/tournament-bracket/components/Bracket/Match.tsx +++ b/app/features/tournament-bracket/components/Bracket/Match.tsx @@ -6,11 +6,11 @@ import { SendouButton } from "~/components/elements/Button"; import { SendouPopover } from "~/components/elements/Popover"; import { useUser } from "~/features/auth/core/user"; import { TournamentStream } from "~/features/tournament/components/TournamentStream"; +import type { TournamentStreamsLoader } from "~/features/tournament/loaders/to.$id.streams.server"; import { useStreamingParticipants, useTournament, } from "~/features/tournament/routes/to.$id"; -import type { TournamentStreamsLoader } from "~/features/tournament/routes/to.$id.streams"; import type { Unpacked } from "~/utils/types"; import { tournamentMatchPage, tournamentStreamsPage } from "~/utils/urls"; import type { Bracket } from "../../core/Bracket"; diff --git a/app/features/tournament-bracket/components/MatchActions.tsx b/app/features/tournament-bracket/components/MatchActions.tsx index 48d224177..de6381701 100644 --- a/app/features/tournament-bracket/components/MatchActions.tsx +++ b/app/features/tournament-bracket/components/MatchActions.tsx @@ -10,7 +10,7 @@ import { resolveLeagueRoundStartDate } from "~/features/tournament/tournament-ut import invariant from "~/utils/invariant"; import * as PickBan from "../core/PickBan"; import type { TournamentDataTeam } from "../core/Tournament.server"; -import type { TournamentMatchLoaderData } from "../routes/to.$id.matches.$mid"; +import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; import { isSetOverByScore, matchIsLocked, diff --git a/app/features/tournament-bracket/components/MatchActionsBanPicker.tsx b/app/features/tournament-bracket/components/MatchActionsBanPicker.tsx index 307c858b9..078f4affd 100644 --- a/app/features/tournament-bracket/components/MatchActionsBanPicker.tsx +++ b/app/features/tournament-bracket/components/MatchActionsBanPicker.tsx @@ -19,7 +19,7 @@ import invariant from "~/utils/invariant"; import { stageImageUrl } from "~/utils/urls"; import * as PickBan from "../core/PickBan"; import type { TournamentDataTeam } from "../core/Tournament.server"; -import type { TournamentMatchLoaderData } from "../routes/to.$id.matches.$mid"; +import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; export function MatchActionsBanPicker({ teams, diff --git a/app/features/tournament-bracket/components/MatchRosters.tsx b/app/features/tournament-bracket/components/MatchRosters.tsx index 97cb8fe67..c493523dd 100644 --- a/app/features/tournament-bracket/components/MatchRosters.tsx +++ b/app/features/tournament-bracket/components/MatchRosters.tsx @@ -3,7 +3,7 @@ import clsx from "clsx"; import { Avatar } from "~/components/Avatar"; import { useTournament } from "~/features/tournament/routes/to.$id"; import { tournamentTeamPage, userPage } from "~/utils/urls"; -import type { TournamentMatchLoaderData } from "../routes/to.$id.matches.$mid"; +import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; const INACTIVE_PLAYER_CSS = "tournament__team-with-roster__member__inactive text-lighter-important"; diff --git a/app/features/tournament-bracket/components/OrganizerMatchMapListDialog.tsx b/app/features/tournament-bracket/components/OrganizerMatchMapListDialog.tsx index 3a5478dc7..2655acf6a 100644 --- a/app/features/tournament-bracket/components/OrganizerMatchMapListDialog.tsx +++ b/app/features/tournament-bracket/components/OrganizerMatchMapListDialog.tsx @@ -7,7 +7,7 @@ import { Dialog } from "~/components/Dialog"; import { MapIcon } from "~/components/icons/Map"; import { useTournament } from "~/features/tournament/routes/to.$id"; import { nullFilledArray } from "~/utils/arrays"; -import type { TournamentMatchLoaderData } from "../routes/to.$id.matches.$mid"; +import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; import { pickInfoText } from "../tournament-bracket-utils"; export function OrganizerMatchMapListDialog({ diff --git a/app/features/tournament-bracket/components/StartedMatch.tsx b/app/features/tournament-bracket/components/StartedMatch.tsx index 1200573f1..cfffe1fe2 100644 --- a/app/features/tournament-bracket/components/StartedMatch.tsx +++ b/app/features/tournament-bracket/components/StartedMatch.tsx @@ -32,7 +32,7 @@ import { import type { Bracket } from "../core/Bracket"; import * as PickBan from "../core/PickBan"; import type { TournamentDataTeam } from "../core/Tournament.server"; -import type { TournamentMatchLoaderData } from "../routes/to.$id.matches.$mid"; +import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; import { groupNumberToLetters, mapCountPlayedInSetWithCertainty, diff --git a/app/features/tournament-bracket/components/TeamRosterInputs.tsx b/app/features/tournament-bracket/components/TeamRosterInputs.tsx index d67b3c989..5701b756d 100644 --- a/app/features/tournament-bracket/components/TeamRosterInputs.tsx +++ b/app/features/tournament-bracket/components/TeamRosterInputs.tsx @@ -10,7 +10,7 @@ import { inGameNameWithoutDiscriminator } from "~/utils/strings"; import { tournamentTeamPage, userPage } from "~/utils/urls"; import { useTournament } from "../../tournament/routes/to.$id"; import type { TournamentDataTeam } from "../core/Tournament.server"; -import type { TournamentMatchLoaderData } from "../routes/to.$id.matches.$mid"; +import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; import { tournamentTeamToActiveRosterUserIds } from "../tournament-bracket-utils"; import type { Result } from "./StartedMatch"; diff --git a/app/features/tournament-bracket/loader/to.$id.divisions.server.ts b/app/features/tournament-bracket/loaders/to.$id.divisions.server.ts similarity index 100% rename from app/features/tournament-bracket/loader/to.$id.divisions.server.ts rename to app/features/tournament-bracket/loaders/to.$id.divisions.server.ts diff --git a/app/features/tournament-bracket/loaders/to.$id.matches.$mid.server.ts b/app/features/tournament-bracket/loaders/to.$id.matches.$mid.server.ts new file mode 100644 index 000000000..83ebe68d4 --- /dev/null +++ b/app/features/tournament-bracket/loaders/to.$id.matches.$mid.server.ts @@ -0,0 +1,51 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { tournamentIdFromParams } from "~/features/tournament"; +import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; +import { notFoundIfFalsy, parseParams } from "~/utils/remix.server"; +import { resolveMapList } from "../core/mapList.server"; +import { findMatchById } from "../queries/findMatchById.server"; +import { findResultsByMatchId } from "../queries/findResultsByMatchId.server"; +import { matchPageParamsSchema } from "../tournament-bracket-schemas.server"; + +export type TournamentMatchLoaderData = typeof loader; + +export const loader = async ({ params }: LoaderFunctionArgs) => { + const tournamentId = tournamentIdFromParams(params); + const matchId = parseParams({ + params, + schema: matchPageParamsSchema, + }).mid; + + const match = notFoundIfFalsy(findMatchById(matchId)); + + const isBye = !match.opponentOne || !match.opponentTwo; + if (isBye) { + throw new Response(null, { status: 404 }); + } + + const pickBanEvents = match.roundMaps?.pickBan + ? await TournamentRepository.pickBanEventsByMatchId(match.id) + : []; + + const mapList = + match.opponentOne?.id && match.opponentTwo?.id + ? resolveMapList({ + bestOf: match.bestOf, + tournamentId, + matchId, + teams: [match.opponentOne.id, match.opponentTwo.id], + mapPickingStyle: match.mapPickingStyle, + maps: match.roundMaps, + pickBanEvents, + }) + : null; + + return { + match, + results: findResultsByMatchId(matchId), + mapList, + matchIsOver: + match.opponentOne?.result === "win" || + match.opponentTwo?.result === "win", + }; +}; diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.subscribe.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.subscribe.ts similarity index 100% rename from app/features/tournament-bracket/routes/to.$id.brackets.subscribe.tsx rename to app/features/tournament-bracket/routes/to.$id.brackets.subscribe.ts diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index 34a76fbfe..06b4f90c6 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -32,13 +32,14 @@ import { useTournament, useTournamentPreparedMaps, } from "../../tournament/routes/to.$id"; -import { action } from "../actions/to.$id.brackets.server"; import { Bracket } from "../components/Bracket"; import { BracketMapListDialog } from "../components/BracketMapListDialog"; import { TournamentTeamActions } from "../components/TournamentTeamActions"; import type { Bracket as BracketType } from "../core/Bracket"; import * as PreparedMaps from "../core/PreparedMaps"; import { bracketSubscriptionKey } from "../tournament-bracket-utils"; + +import { action } from "../actions/to.$id.brackets.server"; export { action }; import "../components/Bracket/bracket.css"; diff --git a/app/features/tournament-bracket/routes/to.$id.divisions.tsx b/app/features/tournament-bracket/routes/to.$id.divisions.tsx index 33ff6b8f2..a13cac203 100644 --- a/app/features/tournament-bracket/routes/to.$id.divisions.tsx +++ b/app/features/tournament-bracket/routes/to.$id.divisions.tsx @@ -4,7 +4,8 @@ import clsx from "clsx"; import { useTranslation } from "react-i18next"; import { UsersIcon } from "../../../components/icons/Users"; import { tournamentBracketsPage } from "../../../utils/urls"; -import { loader } from "../loader/to.$id.divisions.server"; + +import { loader } from "../loaders/to.$id.divisions.server"; export { loader }; export default function TournamentDivisionsPage() { diff --git a/app/features/tournament-bracket/routes/to.$id.matches.$mid.subscribe.tsx b/app/features/tournament-bracket/routes/to.$id.matches.$mid.subscribe.ts similarity index 100% rename from app/features/tournament-bracket/routes/to.$id.matches.$mid.subscribe.tsx rename to app/features/tournament-bracket/routes/to.$id.matches.$mid.subscribe.ts diff --git a/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx b/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx index 9db4a89b5..530f521f1 100644 --- a/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx +++ b/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx @@ -1,31 +1,16 @@ -import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node"; import { useLoaderData, useRevalidator } from "@remix-run/react"; import clsx from "clsx"; -import { nanoid } from "nanoid"; import * as React from "react"; import { useEventSource } from "remix-utils/sse/react"; import { LinkButton } from "~/components/Button"; import { containerClassName } from "~/components/Main"; import { ArrowLongLeftIcon } from "~/components/icons/ArrowLongLeft"; -import { sql } from "~/db/sql"; import { useUser } from "~/features/auth/core/user"; -import { requireUser } from "~/features/auth/core/user.server"; -import { TOURNAMENT, tournamentIdFromParams } from "~/features/tournament"; -import * as TournamentMatchRepository from "~/features/tournament-bracket/TournamentMatchRepository.server"; -import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; -import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; +import { TOURNAMENT } from "~/features/tournament"; import { useTournament } from "~/features/tournament/routes/to.$id"; import { useSearchParamState } from "~/hooks/useSearchParamState"; import { useVisibilityChange } from "~/hooks/useVisibilityChange"; -import { canReportTournamentScore } from "~/permissions"; import invariant from "~/utils/invariant"; -import { logger } from "~/utils/logger"; -import { - errorToastIfFalsy, - notFoundIfFalsy, - parseParams, - parseRequestPayload, -} from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; import { tournamentBracketsPage, @@ -35,617 +20,18 @@ import { CastInfo } from "../components/CastInfo"; import { MatchRosters } from "../components/MatchRosters"; import { OrganizerMatchMapListDialog } from "../components/OrganizerMatchMapListDialog"; import { StartedMatch } from "../components/StartedMatch"; -import * as PickBan from "../core/PickBan"; -import { - clearTournamentDataCache, - tournamentFromDB, -} from "../core/Tournament.server"; -import { getServerTournamentManager } from "../core/brackets-manager/manager.server"; -import { emitter } from "../core/emitters.server"; -import { resolveMapList } from "../core/mapList.server"; import { getRounds } from "../core/rounds"; -import { deleteMatchPickBanEvents } from "../queries/deleteMatchPickBanEvents.server"; -import { deleteParticipantsByMatchGameResultId } from "../queries/deleteParticipantsByMatchGameResultId.server"; -import { deletePickBanEvent } from "../queries/deletePickBanEvent.server"; -import { deleteTournamentMatchGameResultById } from "../queries/deleteTournamentMatchGameResultById.server"; -import { findMatchById } from "../queries/findMatchById.server"; -import { findResultsByMatchId } from "../queries/findResultsByMatchId.server"; -import { insertTournamentMatchGameResult } from "../queries/insertTournamentMatchGameResult.server"; -import { insertTournamentMatchGameResultParticipant } from "../queries/insertTournamentMatchGameResultParticipant.server"; -import { updateMatchGameResultPoints } from "../queries/updateMatchGameResultPoints.server"; import { - matchPageParamsSchema, - matchSchema, -} from "../tournament-bracket-schemas.server"; -import { - bracketSubscriptionKey, groupNumberToLetters, - isSetOverByScore, - matchIsLocked, matchSubscriptionKey, - tournamentTeamToActiveRosterUserIds, } from "../tournament-bracket-utils"; +import { action } from "../actions/to.$id.matches.$mid.server"; +import { loader } from "../loaders/to.$id.matches.$mid.server"; +export { action, loader }; + import "../tournament-bracket.css"; -export const action: ActionFunction = async ({ params, request }) => { - const user = await requireUser(request); - const matchId = parseParams({ - params, - schema: matchPageParamsSchema, - }).mid; - const match = notFoundIfFalsy(findMatchById(matchId)); - const data = await parseRequestPayload({ - request, - schema: matchSchema, - }); - - const tournamentId = tournamentIdFromParams(params); - const tournament = await tournamentFromDB({ tournamentId, user }); - - const validateCanReportScore = () => { - const isMemberOfATeamInTheMatch = match.players.some( - (p) => p.id === user?.id, - ); - - errorToastIfFalsy( - canReportTournamentScore({ - match, - isMemberOfATeamInTheMatch, - isOrganizer: tournament.isOrganizer(user), - }), - "Unauthorized", - ); - }; - - const manager = getServerTournamentManager(); - - const scores: [number, number] = [ - match.opponentOne?.score ?? 0, - match.opponentTwo?.score ?? 0, - ]; - - const pickBanEvents = match.roundMaps?.pickBan - ? await TournamentRepository.pickBanEventsByMatchId(match.id) - : []; - - const mapList = - match.opponentOne?.id && match.opponentTwo?.id - ? resolveMapList({ - bestOf: match.bestOf, - tournamentId, - matchId, - teams: [match.opponentOne.id, match.opponentTwo.id], - mapPickingStyle: match.mapPickingStyle, - maps: match.roundMaps, - pickBanEvents, - }) - : null; - - let emitMatchUpdate = false; - let emitBracketUpdate = false; - switch (data._action) { - case "REPORT_SCORE": { - // they are trying to report score that was already reported - // assume that it was already reported and make their page refresh - if (data.position !== scores[0] + scores[1]) { - return null; - } - - validateCanReportScore(); - errorToastIfFalsy( - match.opponentOne?.id === data.winnerTeamId || - match.opponentTwo?.id === data.winnerTeamId, - "Winner team id is invalid", - ); - errorToastIfFalsy( - match.opponentOne && match.opponentTwo, - "Teams are missing", - ); - errorToastIfFalsy( - !matchIsLocked({ matchId: match.id, tournament, scores }), - "Match is locked", - ); - - const currentMap = mapList?.filter((m) => !m.bannedByTournamentTeamId)[ - data.position - ]; - invariant(currentMap, "Can't resolve current map"); - - const scoreToIncrement = () => { - if (data.winnerTeamId === match.opponentOne?.id) return 0; - if (data.winnerTeamId === match.opponentTwo?.id) return 1; - - errorToastIfFalsy(false, "Winner team id is invalid"); - }; - - errorToastIfFalsy( - !data.points || - (scoreToIncrement() === 0 && data.points[0] > data.points[1]) || - (scoreToIncrement() === 1 && data.points[1] > data.points[0]), - "Points are invalid (winner must have more points than loser)", - ); - - // TODO: could also validate that if bracket demands it then points are defined - - scores[scoreToIncrement()]++; - - const setOver = isSetOverByScore({ - count: match.roundMaps?.count ?? match.bestOf, - countType: match.roundMaps?.type ?? "BEST_OF", - scores, - }); - - const teamOneRoster = tournamentTeamToActiveRosterUserIds( - tournament.teamById(match.opponentOne.id!)!, - tournament.minMembersPerTeam, - ); - const teamTwoRoster = tournamentTeamToActiveRosterUserIds( - tournament.teamById(match.opponentTwo.id!)!, - tournament.minMembersPerTeam, - ); - - errorToastIfFalsy(teamOneRoster, "Team one has no active roster"); - errorToastIfFalsy(teamTwoRoster, "Team two has no active roster"); - - errorToastIfFalsy( - new Set([...teamOneRoster, ...teamTwoRoster]).size === - tournament.minMembersPerTeam * 2, - "Duplicate user in rosters", - ); - - sql.transaction(() => { - manager.update.match({ - id: match.id, - opponent1: { - score: scores[0], - result: setOver && scores[0] > scores[1] ? "win" : undefined, - }, - opponent2: { - score: scores[1], - result: setOver && scores[1] > scores[0] ? "win" : undefined, - }, - }); - - const result = insertTournamentMatchGameResult({ - matchId: match.id, - mode: currentMap.mode, - stageId: currentMap.stageId, - reporterId: user.id, - winnerTeamId: data.winnerTeamId, - number: data.position + 1, - source: String(currentMap.source), - opponentOnePoints: data.points?.[0] ?? null, - opponentTwoPoints: data.points?.[1] ?? null, - }); - - for (const userId of teamOneRoster) { - insertTournamentMatchGameResultParticipant({ - matchGameResultId: result.id, - userId, - tournamentTeamId: match.opponentOne!.id!, - }); - } - for (const userId of teamTwoRoster) { - insertTournamentMatchGameResultParticipant({ - matchGameResultId: result.id, - userId, - tournamentTeamId: match.opponentTwo!.id!, - }); - } - })(); - - emitMatchUpdate = true; - emitBracketUpdate = true; - - break; - } - case "SET_ACTIVE_ROSTER": { - errorToastIfFalsy(!tournament.everyBracketOver, "Tournament is over"); - errorToastIfFalsy( - tournament.isOrganizer(user) || - tournament.teamMemberOfByUser(user)?.id === data.teamId, - "Unauthorized", - ); - errorToastIfFalsy( - data.roster.length === tournament.minMembersPerTeam, - "Invalid roster length", - ); - - const team = tournament.teamById(data.teamId)!; - errorToastIfFalsy( - data.roster.every((userId) => - team.members.some((m) => m.userId === userId), - ), - "Invalid roster", - ); - - await TournamentTeamRepository.setActiveRoster({ - teamId: data.teamId, - activeRosterUserIds: data.roster, - }); - - emitMatchUpdate = true; - - break; - } - case "UNDO_REPORT_SCORE": { - validateCanReportScore(); - // they are trying to remove score from the past - if (data.position !== scores[0] + scores[1] - 1) { - return null; - } - - const results = findResultsByMatchId(matchId); - const lastResult = results[results.length - 1]; - invariant(lastResult, "Last result is missing"); - - const shouldReset = results.length === 1; - - if (lastResult.winnerTeamId === match.opponentOne?.id) { - scores[0]--; - } else { - scores[1]--; - } - - logger.info( - `Undoing score: Position: ${data.position}; User ID: ${user.id}; Match ID: ${match.id}`, - ); - - const pickBanEventToDeleteNumber = await (async () => { - if (!match.roundMaps?.pickBan) return; - - const pickBanEvents = await TournamentRepository.pickBanEventsByMatchId( - match.id, - ); - - const unplayedPicks = pickBanEvents - .filter((e) => e.type === "PICK") - .filter( - (e) => - !results.some( - (r) => r.stageId === e.stageId && r.mode === e.mode, - ), - ); - invariant(unplayedPicks.length <= 1, "Too many unplayed picks"); - - return unplayedPicks[0]?.number; - })(); - - sql.transaction(() => { - deleteTournamentMatchGameResultById(lastResult.id); - - manager.update.match({ - id: match.id, - opponent1: { - score: shouldReset ? undefined : scores[0], - }, - opponent2: { - score: shouldReset ? undefined : scores[1], - }, - }); - - if (shouldReset) { - manager.reset.matchResults(match.id); - } - - if (typeof pickBanEventToDeleteNumber === "number") { - deletePickBanEvent({ matchId, number: pickBanEventToDeleteNumber }); - } - })(); - - emitMatchUpdate = true; - emitBracketUpdate = true; - - break; - } - case "UPDATE_REPORTED_SCORE": { - errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer"); - errorToastIfFalsy(!tournament.ctx.isFinalized, "Tournament is finalized"); - - const result = await TournamentMatchRepository.findResultById( - data.resultId, - ); - errorToastIfFalsy(result, "Result not found"); - errorToastIfFalsy( - data.rosters[0].length === tournament.minMembersPerTeam && - data.rosters[1].length === tournament.minMembersPerTeam, - "Invalid roster length", - ); - - const hadPoints = typeof result.opponentOnePoints === "number"; - const willHavePoints = typeof data.points?.[0] === "number"; - errorToastIfFalsy( - (hadPoints && willHavePoints) || (!hadPoints && !willHavePoints), - "Points mismatch", - ); - - if (data.points) { - if (data.points[0] !== result.opponentOnePoints) { - // changing points at this point could retroactively change who advanced from the group - errorToastIfFalsy( - tournament.matchCanBeReopened(match.id), - "Bracket has progressed", - ); - } - - if (result.opponentOnePoints! > result.opponentTwoPoints!) { - errorToastIfFalsy( - data.points[0] > data.points[1], - "Winner must have more points than loser", - ); - } else { - errorToastIfFalsy( - data.points[0] < data.points[1], - "Winner must have more points than loser", - ); - } - } - - sql.transaction(() => { - if (data.points) { - updateMatchGameResultPoints({ - matchGameResultId: result.id, - opponentOnePoints: data.points[0], - opponentTwoPoints: data.points[1], - }); - } - - deleteParticipantsByMatchGameResultId(result.id); - - for (const userId of data.rosters[0]) { - insertTournamentMatchGameResultParticipant({ - matchGameResultId: result.id, - userId, - tournamentTeamId: match.opponentOne!.id!, - }); - } - for (const userId of data.rosters[1]) { - insertTournamentMatchGameResultParticipant({ - matchGameResultId: result.id, - userId, - tournamentTeamId: match.opponentTwo!.id!, - }); - } - })(); - - emitMatchUpdate = true; - emitBracketUpdate = true; - - break; - } - case "BAN_PICK": { - const results = findResultsByMatchId(matchId); - - const teamOne = match.opponentOne?.id - ? tournament.teamById(match.opponentOne.id) - : undefined; - const teamTwo = match.opponentTwo?.id - ? tournament.teamById(match.opponentTwo.id) - : undefined; - invariant(teamOne && teamTwo, "Teams are missing"); - - invariant( - match.roundMaps && match.opponentOne?.id && match.opponentTwo?.id, - "Missing fields to pick/ban", - ); - const pickerTeamId = PickBan.turnOf({ - results, - maps: match.roundMaps, - teams: [match.opponentOne.id, match.opponentTwo.id], - mapList, - }); - errorToastIfFalsy(pickerTeamId, "Not time to pick/ban"); - errorToastIfFalsy( - tournament.isOrganizer(user) || - tournament.ownedTeamByUser(user)?.id === pickerTeamId, - "Unauthorized", - ); - - errorToastIfFalsy( - PickBan.isLegal({ - results, - map: data, - maps: match.roundMaps, - toSetMapPool: - tournament.ctx.mapPickingStyle === "TO" - ? await TournamentRepository.findTOSetMapPoolById(tournamentId) - : [], - mapList, - tieBreakerMapPool: tournament.ctx.tieBreakerMapPool, - teams: [teamOne, teamTwo], - pickerTeamId, - }), - "Illegal pick", - ); - - const pickBanEvents = await TournamentRepository.pickBanEventsByMatchId( - match.id, - ); - await TournamentRepository.addPickBanEvent({ - authorId: user.id, - matchId: match.id, - stageId: data.stageId, - mode: data.mode, - number: pickBanEvents.length + 1, - type: match.roundMaps.pickBan === "BAN_2" ? "BAN" : "PICK", - }); - - emitMatchUpdate = true; - - break; - } - case "REOPEN_MATCH": { - const scoreOne = match.opponentOne?.score ?? 0; - const scoreTwo = match.opponentTwo?.score ?? 0; - invariant(typeof scoreOne === "number", "Score one is missing"); - invariant(typeof scoreTwo === "number", "Score two is missing"); - invariant(scoreOne !== scoreTwo, "Scores are equal"); - - errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer"); - errorToastIfFalsy( - tournament.matchCanBeReopened(match.id), - "Match can't be reopened, bracket has progressed", - ); - - const results = findResultsByMatchId(matchId); - const lastResult = results[results.length - 1]; - invariant(lastResult, "Last result is missing"); - - if (scoreOne > scoreTwo) { - scores[0]--; - } else { - scores[1]--; - } - - logger.info( - `Reopening match: User ID: ${user.id}; Match ID: ${match.id}`, - ); - - const followingMatches = tournament.followingMatches(match.id); - sql.transaction(() => { - for (const match of followingMatches) { - deleteMatchPickBanEvents({ matchId: match.id }); - } - deleteTournamentMatchGameResultById(lastResult.id); - manager.update.match({ - id: match.id, - opponent1: { - score: scores[0], - result: undefined, - }, - opponent2: { - score: scores[1], - result: undefined, - }, - }); - })(); - - emitMatchUpdate = true; - emitBracketUpdate = true; - - break; - } - case "SET_AS_CASTED": { - errorToastIfFalsy( - tournament.isOrganizerOrStreamer(user), - "Not an organizer or streamer", - ); - - await TournamentRepository.setMatchAsCasted({ - matchId: match.id, - tournamentId: tournament.ctx.id, - twitchAccount: data.twitchAccount, - }); - - emitBracketUpdate = true; - - break; - } - case "LOCK": { - errorToastIfFalsy( - tournament.isOrganizerOrStreamer(user), - "Not an organizer or streamer", - ); - - // can't lock, let's update their view to reflect that - if (match.opponentOne?.id && match.opponentTwo?.id) { - return null; - } - - await TournamentRepository.lockMatch({ - matchId: match.id, - tournamentId: tournament.ctx.id, - }); - - emitMatchUpdate = true; - - break; - } - case "UNLOCK": { - errorToastIfFalsy( - tournament.isOrganizerOrStreamer(user), - "Not an organizer or streamer", - ); - - await TournamentRepository.unlockMatch({ - matchId: match.id, - tournamentId: tournament.ctx.id, - }); - - emitMatchUpdate = true; - - break; - } - default: { - assertUnreachable(data); - } - } - - if (emitMatchUpdate) { - emitter.emit(matchSubscriptionKey(match.id), { - eventId: nanoid(), - userId: user.id, - }); - } - if (emitBracketUpdate) { - emitter.emit(bracketSubscriptionKey(tournament.ctx.id), { - matchId: match.id, - scores, - isOver: - scores[0] === Math.ceil(match.bestOf / 2) || - scores[1] === Math.ceil(match.bestOf / 2), - }); - } - - clearTournamentDataCache(tournamentId); - - return null; -}; - -export type TournamentMatchLoaderData = typeof loader; - -export const loader = async ({ params }: LoaderFunctionArgs) => { - const tournamentId = tournamentIdFromParams(params); - const matchId = parseParams({ - params, - schema: matchPageParamsSchema, - }).mid; - - const match = notFoundIfFalsy(findMatchById(matchId)); - - const isBye = !match.opponentOne || !match.opponentTwo; - if (isBye) { - throw new Response(null, { status: 404 }); - } - - const pickBanEvents = match.roundMaps?.pickBan - ? await TournamentRepository.pickBanEventsByMatchId(match.id) - : []; - - const mapList = - match.opponentOne?.id && match.opponentTwo?.id - ? resolveMapList({ - bestOf: match.bestOf, - tournamentId, - matchId, - teams: [match.opponentOne.id, match.opponentTwo.id], - mapPickingStyle: match.mapPickingStyle, - maps: match.roundMaps, - pickBanEvents, - }) - : null; - - return { - match, - results: findResultsByMatchId(matchId), - mapList, - matchIsOver: - match.opponentOne?.result === "win" || - match.opponentTwo?.result === "win", - }; -}; - export default function TournamentMatchPage() { const user = useUser(); const visibility = useVisibilityChange(); diff --git a/app/features/tournament-bracket/tournament-bracket-utils.ts b/app/features/tournament-bracket/tournament-bracket-utils.ts index 1c73c563d..b0b507226 100644 --- a/app/features/tournament-bracket/tournament-bracket-utils.ts +++ b/app/features/tournament-bracket/tournament-bracket-utils.ts @@ -10,7 +10,7 @@ import { import { removeDuplicates } from "~/utils/arrays"; import { sumArray } from "~/utils/number"; import type { FindMatchById } from "../tournament-bracket/queries/findMatchById.server"; -import type { TournamentLoaderData } from "../tournament/routes/to.$id"; +import type { TournamentLoaderData } from "../tournament/loaders/to.$id.server"; import type { Standing } from "./core/Bracket"; import type { Tournament } from "./core/Tournament"; import type { TournamentDataTeam } from "./core/Tournament.server"; diff --git a/app/features/tournament-organization/routes/org.$slug.tsx b/app/features/tournament-organization/routes/org.$slug.tsx index 46e4d989c..b3a5f2b39 100644 --- a/app/features/tournament-organization/routes/org.$slug.tsx +++ b/app/features/tournament-organization/routes/org.$slug.tsx @@ -29,11 +29,11 @@ import { SocialLinksList } from "../components/SocialLinksList"; import { TOURNAMENT_SERIES_EVENTS_PER_PAGE } from "../tournament-organization-constants"; import { canEditTournamentOrganization } from "../tournament-organization-utils"; -import "../tournament-organization.css"; - import { loader } from "../loaders/org.$slug.server"; export { loader }; +import "../tournament-organization.css"; + export const meta: MetaFunction = (args) => { if (!args.data) return []; diff --git a/app/features/tournament-subs/actions/to.$id.subs.new.server.ts b/app/features/tournament-subs/actions/to.$id.subs.new.server.ts new file mode 100644 index 000000000..8b22732ae --- /dev/null +++ b/app/features/tournament-subs/actions/to.$id.subs.new.server.ts @@ -0,0 +1,45 @@ +import { type ActionFunction, redirect } from "@remix-run/node"; +import { requireUser } from "~/features/auth/core/user.server"; +import { tournamentIdFromParams } from "~/features/tournament"; +import { + clearTournamentDataCache, + tournamentFromDB, +} from "~/features/tournament-bracket/core/Tournament.server"; +import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server"; +import { tournamentSubsPage } from "~/utils/urls"; +import { upsertSub } from "../queries/upsertSub.server"; +import { subSchema } from "../tournament-subs-schemas.server"; + +export const action: ActionFunction = async ({ params, request }) => { + const user = await requireUser(request); + const data = await parseRequestPayload({ + request, + schema: subSchema, + }); + const tournamentId = tournamentIdFromParams(params); + const tournament = await tournamentFromDB({ tournamentId, user }); + + errorToastIfFalsy(!tournament.everyBracketOver, "Tournament is over"); + errorToastIfFalsy( + tournament.canAddNewSubPost, + "Registration is closed or subs feature disabled", + ); + errorToastIfFalsy( + !tournament.teamMemberOfByUser(user), + "Can't register as a sub and be in a team at the same time", + ); + + upsertSub({ + bestWeapons: data.bestWeapons.join(","), + okWeapons: data.okWeapons.join(","), + canVc: data.canVc, + visibility: data.visibility, + message: data.message ?? null, + tournamentId, + userId: user.id, + }); + + clearTournamentDataCache(tournamentId); + + throw redirect(tournamentSubsPage(tournamentId)); +}; diff --git a/app/features/tournament-subs/actions/to.$id.subs.server.ts b/app/features/tournament-subs/actions/to.$id.subs.server.ts new file mode 100644 index 000000000..93be08278 --- /dev/null +++ b/app/features/tournament-subs/actions/to.$id.subs.server.ts @@ -0,0 +1,34 @@ +import type { ActionFunction } from "@remix-run/node"; +import { requireUser } from "~/features/auth/core/user.server"; +import { tournamentIdFromParams } from "~/features/tournament"; +import { + clearTournamentDataCache, + tournamentFromDB, +} from "~/features/tournament-bracket/core/Tournament.server"; +import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server"; +import { deleteSub } from "../queries/deleteSub.server"; +import { deleteSubSchema } from "../tournament-subs-schemas.server"; + +export const action: ActionFunction = async ({ request, params }) => { + const user = await requireUser(request); + const tournamentId = tournamentIdFromParams(params); + const tournament = await tournamentFromDB({ tournamentId, user }); + const data = await parseRequestPayload({ + request, + schema: deleteSubSchema, + }); + + errorToastIfFalsy( + user.id === data.userId || tournament.isOrganizer(user), + "You can only delete your own sub post", + ); + + deleteSub({ + tournamentId, + userId: data.userId, + }); + + clearTournamentDataCache(tournamentId); + + return null; +}; diff --git a/app/features/tournament-subs/loaders/to.$id.subs.new.server.ts b/app/features/tournament-subs/loaders/to.$id.subs.new.server.ts new file mode 100644 index 000000000..7f4f82238 --- /dev/null +++ b/app/features/tournament-subs/loaders/to.$id.subs.new.server.ts @@ -0,0 +1,24 @@ +import { type LoaderFunctionArgs, redirect } from "@remix-run/node"; +import { requireUser } from "~/features/auth/core/user.server"; +import { tournamentIdFromParams } from "~/features/tournament"; +import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server"; +import { tournamentSubsPage } from "~/utils/urls"; +import { findSubsByTournamentId } from "../queries/findSubsByTournamentId.server"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const user = await requireUser(request); + const tournamentId = tournamentIdFromParams(params); + const tournament = await tournamentFromDB({ tournamentId, user }); + + if (!tournament.canAddNewSubPost) { + throw redirect(tournamentSubsPage(tournamentId)); + } + + const sub = findSubsByTournamentId({ tournamentId }).find( + (sub) => sub.userId === user.id, + ); + + return { + sub, + }; +}; diff --git a/app/features/tournament-subs/loaders/to.$id.subs.server.ts b/app/features/tournament-subs/loaders/to.$id.subs.server.ts new file mode 100644 index 000000000..403fb1788 --- /dev/null +++ b/app/features/tournament-subs/loaders/to.$id.subs.server.ts @@ -0,0 +1,46 @@ +import { type LoaderFunctionArgs, redirect } from "@remix-run/node"; +import { getUser } from "~/features/auth/core/user.server"; +import { tournamentIdFromParams } from "~/features/tournament"; +import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server"; +import { assertUnreachable } from "~/utils/types"; +import { tournamentRegisterPage } from "~/utils/urls"; +import { findSubsByTournamentId } from "../queries/findSubsByTournamentId.server"; + +export const loader = async ({ params, request }: LoaderFunctionArgs) => { + const user = await getUser(request); + const tournamentId = tournamentIdFromParams(params); + + const tournament = await tournamentFromDB({ tournamentId, user }); + if (!tournament.subsFeatureEnabled) { + throw redirect(tournamentRegisterPage(tournamentId)); + } + + const subs = findSubsByTournamentId({ + tournamentId, + userId: user?.id, + }).filter((sub) => { + if (sub.visibility === "ALL") return true; + + const userPlusTier = user?.plusTier ?? 4; + + switch (sub.visibility) { + case "+1": { + return userPlusTier === 1; + } + case "+2": { + return userPlusTier <= 2; + } + case "+3": { + return userPlusTier <= 3; + } + default: { + assertUnreachable(sub.visibility); + } + } + }); + + return { + subs, + hasOwnSubPost: subs.some((sub) => sub.userId === user?.id), + }; +}; diff --git a/app/features/tournament-subs/routes/to.$id.subs.new.tsx b/app/features/tournament-subs/routes/to.$id.subs.new.tsx index 98b450509..694f1cd81 100644 --- a/app/features/tournament-subs/routes/to.$id.subs.new.tsx +++ b/app/features/tournament-subs/routes/to.$id.subs.new.tsx @@ -1,8 +1,3 @@ -import { - type ActionFunction, - type LoaderFunctionArgs, - redirect, -} from "@remix-run/node"; import { Form, useLoaderData } from "@remix-run/react"; import React from "react"; import { useTranslation } from "react-i18next"; @@ -15,23 +10,13 @@ import { RequiredHiddenInput } from "~/components/RequiredHiddenInput"; import { SubmitButton } from "~/components/SubmitButton"; import { TrashIcon } from "~/components/icons/Trash"; import { useUser } from "~/features/auth/core/user"; -import { requireUser } from "~/features/auth/core/user.server"; -import { tournamentIdFromParams } from "~/features/tournament"; -import { - clearTournamentDataCache, - tournamentFromDB, -} from "~/features/tournament-bracket/core/Tournament.server"; import type { MainWeaponId } from "~/modules/in-game-lists"; -import { - type SendouRouteHandle, - errorToastIfFalsy, - parseRequestPayload, -} from "~/utils/remix.server"; -import { tournamentSubsPage } from "~/utils/urls"; -import { findSubsByTournamentId } from "../queries/findSubsByTournamentId.server"; -import { upsertSub } from "../queries/upsertSub.server"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import { TOURNAMENT_SUB } from "../tournament-subs-constants"; -import { subSchema } from "../tournament-subs-schemas.server"; + +import { action } from "../actions/to.$id.subs.new.server"; +import { loader } from "../loaders/to.$id.subs.new.server"; +export { action, loader }; import "../tournament-subs.css"; @@ -39,58 +24,6 @@ export const handle: SendouRouteHandle = { i18n: ["user"], }; -export const action: ActionFunction = async ({ params, request }) => { - const user = await requireUser(request); - const data = await parseRequestPayload({ - request, - schema: subSchema, - }); - const tournamentId = tournamentIdFromParams(params); - const tournament = await tournamentFromDB({ tournamentId, user }); - - errorToastIfFalsy(!tournament.everyBracketOver, "Tournament is over"); - errorToastIfFalsy( - tournament.canAddNewSubPost, - "Registration is closed or subs feature disabled", - ); - errorToastIfFalsy( - !tournament.teamMemberOfByUser(user), - "Can't register as a sub and be in a team at the same time", - ); - - upsertSub({ - bestWeapons: data.bestWeapons.join(","), - okWeapons: data.okWeapons.join(","), - canVc: data.canVc, - visibility: data.visibility, - message: data.message ?? null, - tournamentId, - userId: user.id, - }); - - clearTournamentDataCache(tournamentId); - - throw redirect(tournamentSubsPage(tournamentId)); -}; - -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const user = await requireUser(request); - const tournamentId = tournamentIdFromParams(params); - const tournament = await tournamentFromDB({ tournamentId, user }); - - if (!tournament.canAddNewSubPost) { - throw redirect(tournamentSubsPage(tournamentId)); - } - - const sub = findSubsByTournamentId({ tournamentId }).find( - (sub) => sub.userId === user.id, - ); - - return { - sub, - }; -}; - export default function NewTournamentSubPage() { const user = useUser(); const { t } = useTranslation(["common", "tournament"]); diff --git a/app/features/tournament-subs/routes/to.$id.subs.tsx b/app/features/tournament-subs/routes/to.$id.subs.tsx index 0ff038ce1..a030397e8 100644 --- a/app/features/tournament-subs/routes/to.$id.subs.tsx +++ b/app/features/tournament-subs/routes/to.$id.subs.tsx @@ -1,8 +1,3 @@ -import { - type ActionFunction, - type LoaderFunctionArgs, - redirect, -} from "@remix-run/node"; import { Link, useLoaderData } from "@remix-run/react"; import React from "react"; import { useTranslation } from "react-i18next"; @@ -17,88 +12,16 @@ import { SendouPopover } from "~/components/elements/Popover"; import { MicrophoneIcon } from "~/components/icons/Microphone"; import { TrashIcon } from "~/components/icons/Trash"; import { useUser } from "~/features/auth/core/user"; -import { getUser, requireUser } from "~/features/auth/core/user.server"; -import { tournamentIdFromParams } from "~/features/tournament"; -import { - clearTournamentDataCache, - tournamentFromDB, -} from "~/features/tournament-bracket/core/Tournament.server"; import { useTournament } from "~/features/tournament/routes/to.$id"; -import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server"; -import { assertUnreachable } from "~/utils/types"; import { tournamentRegisterPage, userPage } from "~/utils/urls"; -import { deleteSub } from "../queries/deleteSub.server"; -import { - type SubByTournamentId, - findSubsByTournamentId, -} from "../queries/findSubsByTournamentId.server"; -import { deleteSubSchema } from "../tournament-subs-schemas.server"; +import type { SubByTournamentId } from "../queries/findSubsByTournamentId.server"; + +import { action } from "../actions/to.$id.subs.server"; +import { loader } from "../loaders/to.$id.subs.server"; +export { action, loader }; import "../tournament-subs.css"; -export const action: ActionFunction = async ({ request, params }) => { - const user = await requireUser(request); - const tournamentId = tournamentIdFromParams(params); - const tournament = await tournamentFromDB({ tournamentId, user }); - const data = await parseRequestPayload({ - request, - schema: deleteSubSchema, - }); - - errorToastIfFalsy( - user.id === data.userId || tournament.isOrganizer(user), - "You can only delete your own sub post", - ); - - deleteSub({ - tournamentId, - userId: data.userId, - }); - - clearTournamentDataCache(tournamentId); - - return null; -}; - -export const loader = async ({ params, request }: LoaderFunctionArgs) => { - const user = await getUser(request); - const tournamentId = tournamentIdFromParams(params); - - const tournament = await tournamentFromDB({ tournamentId, user }); - if (!tournament.subsFeatureEnabled) { - throw redirect(tournamentRegisterPage(tournamentId)); - } - - const subs = findSubsByTournamentId({ - tournamentId, - userId: user?.id, - }).filter((sub) => { - if (sub.visibility === "ALL") return true; - - const userPlusTier = user?.plusTier ?? 4; - - switch (sub.visibility) { - case "+1": { - return userPlusTier === 1; - } - case "+2": { - return userPlusTier <= 2; - } - case "+3": { - return userPlusTier <= 3; - } - default: { - assertUnreachable(sub.visibility); - } - } - }); - - return { - subs, - hasOwnSubPost: subs.some((sub) => sub.userId === user?.id), - }; -}; - export default function TournamentSubsPage() { const user = useUser(); const data = useLoaderData(); diff --git a/app/features/tournament/actions/to.$id.join.server.ts b/app/features/tournament/actions/to.$id.join.server.ts new file mode 100644 index 000000000..a36eb67f3 --- /dev/null +++ b/app/features/tournament/actions/to.$id.join.server.ts @@ -0,0 +1,116 @@ +import type { ActionFunction } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { requireUserId } from "~/features/auth/core/user.server"; +import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; +import { + clearTournamentDataCache, + tournamentFromDB, +} from "~/features/tournament-bracket/core/Tournament.server"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import invariant from "~/utils/invariant"; +import { + errorToastIfFalsy, + notFoundIfFalsy, + parseRequestPayload, +} from "~/utils/remix.server"; +import { tournamentPage } from "~/utils/urls"; +import { findByInviteCode } from "../queries/findTeamByInviteCode.server"; +import { giveTrust } from "../queries/giveTrust.server"; +import { joinTeam } from "../queries/joinLeaveTeam.server"; +import { joinSchema } from "../tournament-schemas.server"; +import { + tournamentIdFromParams, + validateCanJoinTeam, +} from "../tournament-utils"; +import { inGameNameIfNeeded } from "../tournament-utils.server"; + +export const action: ActionFunction = async ({ request, params }) => { + const tournamentId = tournamentIdFromParams(params); + const user = await requireUserId(request); + const url = new URL(request.url); + const inviteCode = url.searchParams.get("code"); + const data = await parseRequestPayload({ request, schema: joinSchema }); + invariant(inviteCode, "code is missing"); + + const leanTeam = notFoundIfFalsy(findByInviteCode(inviteCode)); + + const tournament = await tournamentFromDB({ tournamentId, user }); + + const teamToJoin = tournament.ctx.teams.find( + (team) => team.id === leanTeam.id, + ); + const previousTeam = tournament.ctx.teams.find((team) => + team.members.some((member) => member.userId === user.id), + ); + + if (tournament.hasStarted) { + errorToastIfFalsy( + !previousTeam || previousTeam.checkIns.length === 0, + "Can't leave checked in team mid tournament", + ); + errorToastIfFalsy(tournament.autonomousSubs, "Subs are not allowed"); + } else { + errorToastIfFalsy(tournament.registrationOpen, "Registration is closed"); + } + errorToastIfFalsy(teamToJoin, "Not team of this tournament"); + errorToastIfFalsy( + validateCanJoinTeam({ + inviteCode, + teamToJoin, + userId: user.id, + maxTeamSize: tournament.maxTeamMemberCount, + }) === "VALID", + "Cannot join this team or invite code is invalid", + ); + errorToastIfFalsy( + (await UserRepository.findLeanById(user.id))?.friendCode, + "No friend code", + ); + + const whatToDoWithPreviousTeam = !previousTeam + ? undefined + : previousTeam.members.some( + (member) => member.userId === user.id && member.isOwner, + ) + ? "DELETE" + : "LEAVE"; + + joinTeam({ + userId: user.id, + newTeamId: teamToJoin.id, + previousTeamId: previousTeam?.id, + // making sure they aren't unfilling one checking in condition i.e. having full roster + // and then having members leave without it affecting the checking in status + checkOutTeam: + whatToDoWithPreviousTeam === "LEAVE" && + previousTeam && + previousTeam.members.length <= tournament.minMembersPerTeam, + whatToDoWithPreviousTeam, + tournamentId, + inGameName: await inGameNameIfNeeded({ + tournament, + userId: user.id, + }), + }); + + ShowcaseTournaments.addToParticipationInfoMap({ + tournamentId, + type: "participant", + userId: user.id, + }); + + if (data.trust) { + const inviterUserId = teamToJoin.members.find( + (member) => member.isOwner, + )?.userId; + invariant(inviterUserId, "Inviter user could not be resolved"); + giveTrust({ + trustGiverUserId: user.id, + trustReceiverUserId: inviterUserId, + }); + } + + clearTournamentDataCache(tournamentId); + + throw redirect(tournamentPage(leanTeam.tournamentId)); +}; diff --git a/app/features/tournament/actions/to.$id.seeds.server.ts b/app/features/tournament/actions/to.$id.seeds.server.ts new file mode 100644 index 000000000..e28b50fa2 --- /dev/null +++ b/app/features/tournament/actions/to.$id.seeds.server.ts @@ -0,0 +1,53 @@ +import type { ActionFunction } from "@remix-run/node"; +import { requireUser } from "~/features/auth/core/user.server"; +import { + clearTournamentDataCache, + tournamentFromDB, +} from "~/features/tournament-bracket/core/Tournament.server"; +import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server"; +import * as TournamentTeamRepository from "../TournamentTeamRepository.server"; +import { updateTeamSeeds } from "../queries/updateTeamSeeds.server"; +import { seedsActionSchema } from "../tournament-schemas.server"; +import { tournamentIdFromParams } from "../tournament-utils"; + +export const action: ActionFunction = async ({ request, params }) => { + const data = await parseRequestPayload({ + request, + schema: seedsActionSchema, + }); + const user = await requireUser(request); + const tournamentId = tournamentIdFromParams(params); + const tournament = await tournamentFromDB({ tournamentId, user }); + + errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer"); + errorToastIfFalsy(!tournament.hasStarted, "Tournament has started"); + + switch (data._action) { + case "UPDATE_SEEDS": { + updateTeamSeeds({ tournamentId, teamIds: data.seeds }); + break; + } + case "UPDATE_STARTING_BRACKETS": { + const validBracketIdxs = + tournament.ctx.settings.bracketProgression.flatMap( + (bracket, bracketIdx) => (!bracket.sources ? [bracketIdx] : []), + ); + + errorToastIfFalsy( + data.startingBrackets.every((t) => + validBracketIdxs.includes(t.startingBracketIdx), + ), + "Invalid starting bracket idx", + ); + + await TournamentTeamRepository.updateStartingBrackets( + data.startingBrackets, + ); + break; + } + } + + clearTournamentDataCache(tournamentId); + + return null; +}; diff --git a/app/features/tournament/components/TournamentStream.tsx b/app/features/tournament/components/TournamentStream.tsx index 003921a90..819aaf9fa 100644 --- a/app/features/tournament/components/TournamentStream.tsx +++ b/app/features/tournament/components/TournamentStream.tsx @@ -3,8 +3,8 @@ import { Avatar } from "~/components/Avatar"; import { UserIcon } from "~/components/icons/User"; import { twitchThumbnailUrlToSrc } from "~/modules/twitch/utils"; import { twitchUrl } from "~/utils/urls"; +import type { TournamentStreamsLoader } from "../loaders/to.$id.streams.server"; import { useTournament } from "../routes/to.$id"; -import type { TournamentStreamsLoader } from "../routes/to.$id.streams"; export function TournamentStream({ stream, diff --git a/app/features/tournament/loaders/to.$id.join.server.ts b/app/features/tournament/loaders/to.$id.join.server.ts new file mode 100644 index 000000000..e0f93017d --- /dev/null +++ b/app/features/tournament/loaders/to.$id.join.server.ts @@ -0,0 +1,12 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { findByInviteCode } from "../queries/findTeamByInviteCode.server"; + +export const loader = ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + const inviteCode = url.searchParams.get("code"); + + return { + teamId: inviteCode ? findByInviteCode(inviteCode)?.id : null, + inviteCode, + }; +}; diff --git a/app/features/tournament/loaders/to.$id.seeds.server.ts b/app/features/tournament/loaders/to.$id.seeds.server.ts new file mode 100644 index 000000000..b78b6656c --- /dev/null +++ b/app/features/tournament/loaders/to.$id.seeds.server.ts @@ -0,0 +1,18 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { requireUser } from "~/features/auth/core/user.server"; +import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server"; +import { tournamentBracketsPage } from "~/utils/urls"; +import { tournamentIdFromParams } from "../tournament-utils"; + +export const loader = async ({ params, request }: LoaderFunctionArgs) => { + const user = await requireUser(request); + const tournamentId = tournamentIdFromParams(params); + const tournament = await tournamentFromDB({ tournamentId, user }); + + if (!tournament.isOrganizer(user) || tournament.hasStarted) { + throw redirect(tournamentBracketsPage({ tournamentId })); + } + + return null; +}; diff --git a/app/features/tournament/loaders/to.$id.server.ts b/app/features/tournament/loaders/to.$id.server.ts new file mode 100644 index 000000000..6b8294f04 --- /dev/null +++ b/app/features/tournament/loaders/to.$id.server.ts @@ -0,0 +1,57 @@ +import type { LoaderFunctionArgs, SerializeFrom } from "@remix-run/node"; +import { getUser } from "~/features/auth/core/user.server"; +import { tournamentDataCached } from "~/features/tournament-bracket/core/Tournament.server"; +import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; +import { isAdmin } from "~/permissions"; +import { databaseTimestampToDate } from "~/utils/dates"; +import { streamsByTournamentId } from "../core/streams.server"; +import { tournamentIdFromParams } from "../tournament-utils"; + +export type TournamentLoaderData = SerializeFrom; + +export const loader = async ({ params, request }: LoaderFunctionArgs) => { + const user = await getUser(request); + const tournamentId = tournamentIdFromParams(params); + + const tournament = await tournamentDataCached({ tournamentId, user }); + + const streams = + tournament.data.stage.length > 0 && !tournament.ctx.isFinalized + ? await streamsByTournamentId(tournament.ctx) + : []; + + const tournamentStartedInTheLastMonth = + databaseTimestampToDate(tournament.ctx.startTime) > + new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const isTournamentAdmin = + tournament.ctx.author.id === user?.id || + tournament.ctx.staff.some( + (s) => s.role === "ORGANIZER" && s.id === user?.id, + ) || + isAdmin(user) || + tournament.ctx.organization?.members.some( + (m) => m.userId === user?.id && m.role === "ADMIN", + ); + const isTournamentOrganizer = + isTournamentAdmin || + tournament.ctx.staff.some( + (s) => s.role === "ORGANIZER" && s.id === user?.id, + ) || + tournament.ctx.organization?.members.some( + (m) => m.userId === user?.id && m.role === "ORGANIZER", + ); + const showFriendCodes = tournamentStartedInTheLastMonth && isTournamentAdmin; + + return { + tournament, + streamingParticipants: streams.flatMap((s) => (s.userId ? [s.userId] : [])), + streamsCount: streams.length, + friendCodes: showFriendCodes + ? await TournamentRepository.friendCodesByTournamentId(tournamentId) + : undefined, + preparedMaps: + isTournamentOrganizer && !tournament.ctx.isFinalized + ? await TournamentRepository.findPreparedMapsById(tournamentId) + : undefined, + }; +}; diff --git a/app/features/tournament/loaders/to.$id.streams.server.ts b/app/features/tournament/loaders/to.$id.streams.server.ts new file mode 100644 index 000000000..dd39de324 --- /dev/null +++ b/app/features/tournament/loaders/to.$id.streams.server.ts @@ -0,0 +1,16 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { tournamentData } from "~/features/tournament-bracket/core/Tournament.server"; +import { notFoundIfFalsy } from "~/utils/remix.server"; +import { streamsByTournamentId } from "../core/streams.server"; +import { tournamentIdFromParams } from "../tournament-utils"; + +export type TournamentStreamsLoader = typeof loader; + +export const loader = async ({ params }: LoaderFunctionArgs) => { + const tournamentId = tournamentIdFromParams(params); + const tournament = notFoundIfFalsy(await tournamentData({ tournamentId })); + + return { + streams: await streamsByTournamentId(tournament.ctx), + }; +}; diff --git a/app/features/tournament/loaders/to.$id.teams.$tid.server.ts b/app/features/tournament/loaders/to.$id.teams.$tid.server.ts new file mode 100644 index 000000000..f2d9c05b7 --- /dev/null +++ b/app/features/tournament/loaders/to.$id.teams.$tid.server.ts @@ -0,0 +1,31 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { tournamentDataCached } from "~/features/tournament-bracket/core/Tournament.server"; +import { tournamentTeamPageParamsSchema } from "~/features/tournament-bracket/tournament-bracket-schemas.server"; +import { parseParams } from "~/utils/remix.server"; +import { tournamentTeamSets, winCounts } from "../core/sets.server"; +import { tournamentIdFromParams } from "../tournament-utils"; + +export const loader = async ({ params }: LoaderFunctionArgs) => { + const tournamentId = tournamentIdFromParams(params); + const tournamentTeamId = parseParams({ + params, + schema: tournamentTeamPageParamsSchema, + }).tid; + + const tournament = await tournamentDataCached({ tournamentId }); + if ( + !tournament || + !tournament.ctx.teams.some((t) => t.id === tournamentTeamId) + ) { + throw new Response(null, { status: 404 }); + } + + // TODO: could be inferred from tournament data (winCounts too) + const sets = tournamentTeamSets({ tournamentTeamId, tournamentId }); + + return { + tournamentTeamId, + sets, + winCounts: winCounts(sets), + }; +}; diff --git a/app/features/tournament/routes/luti.tsx b/app/features/tournament/routes/luti.ts similarity index 100% rename from app/features/tournament/routes/luti.tsx rename to app/features/tournament/routes/luti.ts diff --git a/app/features/tournament/routes/to.$id.index.tsx b/app/features/tournament/routes/to.$id.index.ts similarity index 100% rename from app/features/tournament/routes/to.$id.index.tsx rename to app/features/tournament/routes/to.$id.index.ts diff --git a/app/features/tournament/routes/to.$id.join.tsx b/app/features/tournament/routes/to.$id.join.tsx index 9dff32ed3..2bff682cb 100644 --- a/app/features/tournament/routes/to.$id.join.tsx +++ b/app/features/tournament/routes/to.$id.join.tsx @@ -1,5 +1,3 @@ -import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node"; -import { redirect } from "@remix-run/node"; import { Form, useLoaderData } from "@remix-run/react"; import React from "react"; import { useTranslation } from "react-i18next"; @@ -7,131 +5,16 @@ import { Alert } from "~/components/Alert"; import { LinkButton } from "~/components/Button"; import { FriendCodeInput } from "~/components/FriendCodeInput"; import { SubmitButton } from "~/components/SubmitButton"; -import { INVITE_CODE_LENGTH } from "~/constants"; import { useUser } from "~/features/auth/core/user"; -import { requireUserId } from "~/features/auth/core/user.server"; -import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; -import { - clearTournamentDataCache, - tournamentFromDB, -} from "~/features/tournament-bracket/core/Tournament.server"; -import * as UserRepository from "~/features/user-page/UserRepository.server"; import invariant from "~/utils/invariant"; -import { - errorToastIfFalsy, - notFoundIfFalsy, - parseRequestPayload, -} from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; -import { tournamentPage, userEditProfilePage } from "~/utils/urls"; -import { findByInviteCode } from "../queries/findTeamByInviteCode.server"; -import { giveTrust } from "../queries/giveTrust.server"; -import { joinTeam } from "../queries/joinLeaveTeam.server"; -import { joinSchema } from "../tournament-schemas.server"; -import { tournamentIdFromParams } from "../tournament-utils"; -import { inGameNameIfNeeded } from "../tournament-utils.server"; +import { userEditProfilePage } from "~/utils/urls"; +import { validateCanJoinTeam } from "../tournament-utils"; import { useTournament } from "./to.$id"; -export const action: ActionFunction = async ({ request, params }) => { - const tournamentId = tournamentIdFromParams(params); - const user = await requireUserId(request); - const url = new URL(request.url); - const inviteCode = url.searchParams.get("code"); - const data = await parseRequestPayload({ request, schema: joinSchema }); - invariant(inviteCode, "code is missing"); - - const leanTeam = notFoundIfFalsy(findByInviteCode(inviteCode)); - - const tournament = await tournamentFromDB({ tournamentId, user }); - - const teamToJoin = tournament.ctx.teams.find( - (team) => team.id === leanTeam.id, - ); - const previousTeam = tournament.ctx.teams.find((team) => - team.members.some((member) => member.userId === user.id), - ); - - if (tournament.hasStarted) { - errorToastIfFalsy( - !previousTeam || previousTeam.checkIns.length === 0, - "Can't leave checked in team mid tournament", - ); - errorToastIfFalsy(tournament.autonomousSubs, "Subs are not allowed"); - } else { - errorToastIfFalsy(tournament.registrationOpen, "Registration is closed"); - } - errorToastIfFalsy(teamToJoin, "Not team of this tournament"); - errorToastIfFalsy( - validateCanJoin({ - inviteCode, - teamToJoin, - userId: user.id, - maxTeamSize: tournament.maxTeamMemberCount, - }) === "VALID", - "Cannot join this team or invite code is invalid", - ); - errorToastIfFalsy( - (await UserRepository.findLeanById(user.id))?.friendCode, - "No friend code", - ); - - const whatToDoWithPreviousTeam = !previousTeam - ? undefined - : previousTeam.members.some( - (member) => member.userId === user.id && member.isOwner, - ) - ? "DELETE" - : "LEAVE"; - - joinTeam({ - userId: user.id, - newTeamId: teamToJoin.id, - previousTeamId: previousTeam?.id, - // making sure they aren't unfilling one checking in condition i.e. having full roster - // and then having members leave without it affecting the checking in status - checkOutTeam: - whatToDoWithPreviousTeam === "LEAVE" && - previousTeam && - previousTeam.members.length <= tournament.minMembersPerTeam, - whatToDoWithPreviousTeam, - tournamentId, - inGameName: await inGameNameIfNeeded({ - tournament, - userId: user.id, - }), - }); - - ShowcaseTournaments.addToParticipationInfoMap({ - tournamentId, - type: "participant", - userId: user.id, - }); - - if (data.trust) { - const inviterUserId = teamToJoin.members.find( - (member) => member.isOwner, - )?.userId; - invariant(inviterUserId, "Inviter user could not be resolved"); - giveTrust({ - trustGiverUserId: user.id, - trustReceiverUserId: inviterUserId, - }); - } - - clearTournamentDataCache(tournamentId); - - throw redirect(tournamentPage(leanTeam.tournamentId)); -}; - -export const loader = ({ request }: LoaderFunctionArgs) => { - const url = new URL(request.url); - const inviteCode = url.searchParams.get("code"); - - return { - teamId: inviteCode ? findByInviteCode(inviteCode)?.id : null, - inviteCode, - }; -}; +import { action } from "../actions/to.$id.join.server"; +import { loader } from "../loaders/to.$id.join.server"; +export { action, loader }; export default function JoinTeamPage() { const { t } = useTranslation(["tournament", "common"]); @@ -142,7 +25,7 @@ export default function JoinTeamPage() { const teamToJoin = data.teamId ? tournament.teamById(data.teamId) : undefined; const captain = teamToJoin?.members.find((member) => member.isOwner); - const validationStatus = validateCanJoin({ + const validationStatus = validateCanJoinTeam({ inviteCode: data.inviteCode, teamToJoin, userId: user?.id, @@ -224,36 +107,3 @@ export default function JoinTeamPage() { ); } - -function validateCanJoin({ - inviteCode, - teamToJoin, - userId, - maxTeamSize, -}: { - inviteCode?: string | null; - teamToJoin?: { members: { userId: number }[] }; - userId?: number; - maxTeamSize: number; -}) { - if (typeof inviteCode !== "string") { - return "MISSING_CODE"; - } - if (typeof userId !== "number") { - return "NOT_LOGGED_IN"; - } - if (!teamToJoin && inviteCode.length !== INVITE_CODE_LENGTH) { - return "SHORT_CODE"; - } - if (!teamToJoin) { - return "NO_TEAM_MATCHING_CODE"; - } - if (teamToJoin.members.length >= maxTeamSize) { - return "TEAM_FULL"; - } - if (teamToJoin.members.some((member) => member.userId === userId)) { - return "ALREADY_JOINED"; - } - - return "VALID"; -} diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx index f00f38161..7d17d4881 100644 --- a/app/features/tournament/routes/to.$id.register.tsx +++ b/app/features/tournament/routes/to.$id.register.tsx @@ -62,7 +62,6 @@ import { useTournament } from "./to.$id"; import { action } from "../actions/to.$id.register.server"; import { loader } from "../loaders/to.$id.register.server"; - export { loader, action }; export default function TournamentRegisterPage() { diff --git a/app/features/tournament/routes/to.$id.seeds.tsx b/app/features/tournament/routes/to.$id.seeds.tsx index f52d8e954..e188ef9b3 100644 --- a/app/features/tournament/routes/to.$id.seeds.tsx +++ b/app/features/tournament/routes/to.$id.seeds.tsx @@ -13,8 +13,6 @@ import { sortableKeyboardCoordinates, verticalListSortingStrategy, } from "@dnd-kit/sortable"; -import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node"; -import { redirect } from "@remix-run/node"; import { Link, useFetcher, useNavigation } from "@remix-run/react"; import clsx from "clsx"; import * as React from "react"; @@ -25,78 +23,18 @@ import { Dialog } from "~/components/Dialog"; import { Draggable } from "~/components/Draggable"; import { SubmitButton } from "~/components/SubmitButton"; import { Table } from "~/components/Table"; -import { requireUser } from "~/features/auth/core/user.server"; -import { - type TournamentDataTeam, - clearTournamentDataCache, - tournamentFromDB, -} from "~/features/tournament-bracket/core/Tournament.server"; +import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server"; import { useTimeoutState } from "~/hooks/useTimeoutState"; import invariant from "~/utils/invariant"; -import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server"; -import { tournamentBracketsPage, userResultsPage } from "~/utils/urls"; +import { userResultsPage } from "~/utils/urls"; import { Avatar } from "../../../components/Avatar"; import { InfoPopover } from "../../../components/InfoPopover"; import { ordinalToRoundedSp } from "../../mmr/mmr-utils"; -import * as TournamentTeamRepository from "../TournamentTeamRepository.server"; -import { updateTeamSeeds } from "../queries/updateTeamSeeds.server"; -import { seedsActionSchema } from "../tournament-schemas.server"; -import { tournamentIdFromParams } from "../tournament-utils"; import { useTournament } from "./to.$id"; -export const action: ActionFunction = async ({ request, params }) => { - const data = await parseRequestPayload({ - request, - schema: seedsActionSchema, - }); - const user = await requireUser(request); - const tournamentId = tournamentIdFromParams(params); - const tournament = await tournamentFromDB({ tournamentId, user }); - - errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer"); - errorToastIfFalsy(!tournament.hasStarted, "Tournament has started"); - - switch (data._action) { - case "UPDATE_SEEDS": { - updateTeamSeeds({ tournamentId, teamIds: data.seeds }); - break; - } - case "UPDATE_STARTING_BRACKETS": { - const validBracketIdxs = - tournament.ctx.settings.bracketProgression.flatMap( - (bracket, bracketIdx) => (!bracket.sources ? [bracketIdx] : []), - ); - - errorToastIfFalsy( - data.startingBrackets.every((t) => - validBracketIdxs.includes(t.startingBracketIdx), - ), - "Invalid starting bracket idx", - ); - - await TournamentTeamRepository.updateStartingBrackets( - data.startingBrackets, - ); - break; - } - } - - clearTournamentDataCache(tournamentId); - - return null; -}; - -export const loader = async ({ params, request }: LoaderFunctionArgs) => { - const user = await requireUser(request); - const tournamentId = tournamentIdFromParams(params); - const tournament = await tournamentFromDB({ tournamentId, user }); - - if (!tournament.isOrganizer(user) || tournament.hasStarted) { - throw redirect(tournamentBracketsPage({ tournamentId })); - } - - return null; -}; +import { action } from "../actions/to.$id.seeds.server"; +import { loader } from "../loaders/to.$id.seeds.server"; +export { loader, action }; export default function TournamentSeedsPage() { const tournament = useTournament(); diff --git a/app/features/tournament/routes/to.$id.streams.tsx b/app/features/tournament/routes/to.$id.streams.tsx index 66b1b5448..754040eb1 100644 --- a/app/features/tournament/routes/to.$id.streams.tsx +++ b/app/features/tournament/routes/to.$id.streams.tsx @@ -1,25 +1,12 @@ -import type { LoaderFunctionArgs } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; import { useTranslation } from "react-i18next"; import { Redirect } from "~/components/Redirect"; -import { tournamentData } from "~/features/tournament-bracket/core/Tournament.server"; -import { notFoundIfFalsy } from "~/utils/remix.server"; import { tournamentRegisterPage } from "~/utils/urls"; import { TournamentStream } from "../components/TournamentStream"; -import { streamsByTournamentId } from "../core/streams.server"; -import { tournamentIdFromParams } from "../tournament-utils"; import { useTournament } from "./to.$id"; -export type TournamentStreamsLoader = typeof loader; - -export const loader = async ({ params }: LoaderFunctionArgs) => { - const tournamentId = tournamentIdFromParams(params); - const tournament = notFoundIfFalsy(await tournamentData({ tournamentId })); - - return { - streams: await streamsByTournamentId(tournament.ctx), - }; -}; +import { loader } from "../loaders/to.$id.streams.server"; +export { loader }; export default function TournamentStreamsPage() { const { t } = useTranslation(["tournament"]); diff --git a/app/features/tournament/routes/to.$id.teams.$tid.tsx b/app/features/tournament/routes/to.$id.teams.$tid.tsx index 355ba4903..173e4035e 100644 --- a/app/features/tournament/routes/to.$id.teams.$tid.tsx +++ b/app/features/tournament/routes/to.$id.teams.$tid.tsx @@ -1,4 +1,4 @@ -import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; +import type { MetaFunction } from "@remix-run/node"; import { Link, useLoaderData } from "@remix-run/react"; import clsx from "clsx"; import { useTranslation } from "react-i18next"; @@ -7,15 +7,12 @@ import { ModeImage, StageImage } from "~/components/Image"; import { Placement } from "~/components/Placement"; import { SendouButton } from "~/components/elements/Button"; import { SendouPopover } from "~/components/elements/Popover"; -import { - type TournamentData, - type TournamentDataTeam, - tournamentDataCached, +import type { + TournamentData, + TournamentDataTeam, } from "~/features/tournament-bracket/core/Tournament.server"; -import { tournamentTeamPageParamsSchema } from "~/features/tournament-bracket/tournament-bracket-schemas.server"; import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator"; import { metaTags } from "~/utils/remix"; -import { parseParams } from "~/utils/remix.server"; import { teamPage, tournamentMatchPage, @@ -24,14 +21,12 @@ import { userSubmittedImage, } from "~/utils/urls"; import { TeamWithRoster } from "../components/TeamWithRoster"; -import { - type PlayedSet, - tournamentTeamSets, - winCounts, -} from "../core/sets.server"; -import { tournamentIdFromParams } from "../tournament-utils"; +import type { PlayedSet } from "../core/sets.server"; import { useTournament } from "./to.$id"; +import { loader } from "../loaders/to.$id.teams.$tid.server"; +export { loader }; + export const meta: MetaFunction = (args) => { const tournamentData = (args.matches[1].data as any) ?.tournament as TournamentData; @@ -55,31 +50,6 @@ export const meta: MetaFunction = (args) => { }); }; -export const loader = async ({ params }: LoaderFunctionArgs) => { - const tournamentId = tournamentIdFromParams(params); - const tournamentTeamId = parseParams({ - params, - schema: tournamentTeamPageParamsSchema, - }).tid; - - const tournament = await tournamentDataCached({ tournamentId }); - if ( - !tournament || - !tournament.ctx.teams.some((t) => t.id === tournamentTeamId) - ) { - throw new Response(null, { status: 404 }); - } - - // TODO: could be inferred from tournament data (winCounts too) - const sets = tournamentTeamSets({ tournamentTeamId, tournamentId }); - - return { - tournamentTeamId, - sets, - winCounts: winCounts(sets), - }; -}; - export default function TournamentTeamPage() { const data = useLoaderData(); const tournament = useTournament(); diff --git a/app/features/tournament/routes/to.$id.tsx b/app/features/tournament/routes/to.$id.tsx index b3adc7d6b..8e539f050 100644 --- a/app/features/tournament/routes/to.$id.tsx +++ b/app/features/tournament/routes/to.$id.tsx @@ -1,8 +1,4 @@ -import type { - LoaderFunctionArgs, - MetaFunction, - SerializeFrom, -} from "@remix-run/node"; +import type { MetaFunction, SerializeFrom } from "@remix-run/node"; import { Outlet, type ShouldRevalidateFunction, @@ -14,13 +10,8 @@ import { useTranslation } from "react-i18next"; import { Main } from "~/components/Main"; import { SubNav, SubNavLink } from "~/components/SubNav"; import { useUser } from "~/features/auth/core/user"; -import { getUser } from "~/features/auth/core/user.server"; import { Tournament } from "~/features/tournament-bracket/core/Tournament"; -import { tournamentDataCached } from "~/features/tournament-bracket/core/Tournament.server"; -import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import { useIsMounted } from "~/hooks/useIsMounted"; -import { isAdmin } from "~/permissions"; -import { databaseTimestampToDate } from "~/utils/dates"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { removeMarkdown } from "~/utils/strings"; import { assertUnreachable } from "~/utils/types"; @@ -32,12 +23,13 @@ import { userSubmittedImage, } from "~/utils/urls"; import { metaTags } from "../../../utils/remix"; -import { streamsByTournamentId } from "../core/streams.server"; -import { tournamentIdFromParams } from "../tournament-utils"; -import "../tournament.css"; -import "~/styles/maps.css"; +import { type TournamentLoaderData, loader } from "../loaders/to.$id.server"; +export { loader }; + import "~/styles/calendar-event.css"; +import "~/styles/maps.css"; +import "../tournament.css"; export const shouldRevalidate: ShouldRevalidateFunction = (args) => { const navigatedToMatchPage = @@ -99,55 +91,6 @@ export const handle: SendouRouteHandle = { }, }; -export type TournamentLoaderData = SerializeFrom; - -export const loader = async ({ params, request }: LoaderFunctionArgs) => { - const user = await getUser(request); - const tournamentId = tournamentIdFromParams(params); - - const tournament = await tournamentDataCached({ tournamentId, user }); - - const streams = - tournament.data.stage.length > 0 && !tournament.ctx.isFinalized - ? await streamsByTournamentId(tournament.ctx) - : []; - - const tournamentStartedInTheLastMonth = - databaseTimestampToDate(tournament.ctx.startTime) > - new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - const isTournamentAdmin = - tournament.ctx.author.id === user?.id || - tournament.ctx.staff.some( - (s) => s.role === "ORGANIZER" && s.id === user?.id, - ) || - isAdmin(user) || - tournament.ctx.organization?.members.some( - (m) => m.userId === user?.id && m.role === "ADMIN", - ); - const isTournamentOrganizer = - isTournamentAdmin || - tournament.ctx.staff.some( - (s) => s.role === "ORGANIZER" && s.id === user?.id, - ) || - tournament.ctx.organization?.members.some( - (m) => m.userId === user?.id && m.role === "ORGANIZER", - ); - const showFriendCodes = tournamentStartedInTheLastMonth && isTournamentAdmin; - - return { - tournament, - streamingParticipants: streams.flatMap((s) => (s.userId ? [s.userId] : [])), - streamsCount: streams.length, - friendCodes: showFriendCodes - ? await TournamentRepository.friendCodesByTournamentId(tournamentId) - : undefined, - preparedMaps: - isTournamentOrganizer && !tournament.ctx.isFinalized - ? await TournamentRepository.findPreparedMapsById(tournamentId) - : undefined, - }; -}; - const TournamentContext = React.createContext(null!); export default function TournamentLayoutShell() { diff --git a/app/features/tournament/tournament-utils.ts b/app/features/tournament/tournament-utils.ts index 3586a2873..2a3fd096c 100644 --- a/app/features/tournament/tournament-utils.ts +++ b/app/features/tournament/tournament-utils.ts @@ -1,4 +1,5 @@ import type { Params } from "@remix-run/react"; +import { INVITE_CODE_LENGTH } from "~/constants"; import type { ModeShort, StageId } from "~/modules/in-game-lists"; import { rankedModesShort } from "~/modules/in-game-lists/modes"; import { weekNumberToDate } from "~/utils/dates"; @@ -263,7 +264,11 @@ export function tournamentIsRanked({ isSetAsRanked, startTime, minMembersPerTeam, -}: { isSetAsRanked?: boolean; startTime: Date; minMembersPerTeam: number }) { +}: { + isSetAsRanked?: boolean; + startTime: Date; + minMembersPerTeam: number; +}) { const seasonIsActive = Boolean(currentSeason(startTime)); if (!seasonIsActive) return false; @@ -337,3 +342,36 @@ export function defaultBracketSettings( } } } + +export function validateCanJoinTeam({ + inviteCode, + teamToJoin, + userId, + maxTeamSize, +}: { + inviteCode?: string | null; + teamToJoin?: { members: { userId: number }[] }; + userId?: number; + maxTeamSize: number; +}) { + if (typeof inviteCode !== "string") { + return "MISSING_CODE"; + } + if (typeof userId !== "number") { + return "NOT_LOGGED_IN"; + } + if (!teamToJoin && inviteCode.length !== INVITE_CODE_LENGTH) { + return "SHORT_CODE"; + } + if (!teamToJoin) { + return "NO_TEAM_MATCHING_CODE"; + } + if (teamToJoin.members.length >= maxTeamSize) { + return "TEAM_FULL"; + } + if (teamToJoin.members.some((member) => member.userId === userId)) { + return "ALREADY_JOINED"; + } + + return "VALID"; +} diff --git a/app/features/user-page/actions/u.$identifier.art.server.ts b/app/features/user-page/actions/u.$identifier.art.server.ts new file mode 100644 index 000000000..50ea32e54 --- /dev/null +++ b/app/features/user-page/actions/u.$identifier.art.server.ts @@ -0,0 +1,27 @@ +import type { ActionFunction } from "@remix-run/node"; +import { deleteArtSchema } from "~/features/art/art-schemas.server"; +import { deleteArt } from "~/features/art/queries/deleteArt.server"; +import { findArtById } from "~/features/art/queries/findArtById.server"; +import { requireUserId } from "~/features/auth/core/user.server"; +import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server"; + +export const action: ActionFunction = async ({ request }) => { + const user = await requireUserId(request); + const data = await parseRequestPayload({ + request, + schema: deleteArtSchema, + }); + + // this actually doesn't delete the image itself from the static hosting + // but the idea is that storage is cheap anyway and if needed later + // then we can have a routine that checks all the images still current and nukes the rest + const artToDelete = findArtById(data.id); + errorToastIfFalsy( + artToDelete?.authorId === user.id, + "Insufficient permissions", + ); + + deleteArt(data.id); + + return null; +}; diff --git a/app/features/user-page/actions/u.$identifier.edit.server.ts b/app/features/user-page/actions/u.$identifier.edit.server.ts new file mode 100644 index 000000000..3990416e9 --- /dev/null +++ b/app/features/user-page/actions/u.$identifier.edit.server.ts @@ -0,0 +1,61 @@ +import { type ActionFunction, redirect } from "@remix-run/node"; +import { requireUserId } from "~/features/auth/core/user.server"; +import { clearTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server"; +import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { safeParseRequestFormData } from "~/utils/remix.server"; +import { errorIsSqliteUniqueConstraintFailure } from "~/utils/sql"; +import { userPage } from "~/utils/urls"; +import { userEditActionSchema } from "../user-page-schemas.server"; + +export const action: ActionFunction = async ({ request }) => { + const parsedInput = await safeParseRequestFormData({ + request, + schema: userEditActionSchema, + }); + + if (!parsedInput.success) { + return { + errors: parsedInput.errors, + }; + } + + const { inGameNameText, inGameNameDiscriminator, ...data } = parsedInput.data; + + const user = await requireUserId(request); + const inGameName = + inGameNameText && inGameNameDiscriminator + ? `${inGameNameText}#${inGameNameDiscriminator}` + : null; + + try { + const editedUser = await UserRepository.updateProfile({ + ...data, + inGameName, + userId: user.id, + }); + + // TODO: to transaction + if (inGameName) { + const tournamentIdsAffected = + await TournamentTeamRepository.updateMemberInGameNameForNonStarted({ + inGameName, + userId: user.id, + }); + + for (const tournamentId of tournamentIdsAffected) { + clearTournamentDataCache(tournamentId); + } + } + + throw redirect(userPage(editedUser)); + } catch (e) { + if (!errorIsSqliteUniqueConstraintFailure(e)) { + throw e; + } + + return { + errors: ["forms.errors.invalidCustomUrl.duplicate"], + }; + } +}; diff --git a/app/features/user-page/actions/u.$identifier.results.highlights.server.ts b/app/features/user-page/actions/u.$identifier.results.highlights.server.ts new file mode 100644 index 000000000..999353dd1 --- /dev/null +++ b/app/features/user-page/actions/u.$identifier.results.highlights.server.ts @@ -0,0 +1,34 @@ +import { type ActionFunction, redirect } from "@remix-run/node"; +import { requireUser } from "~/features/auth/core/user.server"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { + HIGHLIGHT_CHECKBOX_NAME, + HIGHLIGHT_TOURNAMENT_CHECKBOX_NAME, +} from "~/features/user-page/components/UserResultsTable"; +import { normalizeFormFieldArray } from "~/utils/arrays"; +import { parseRequestPayload } from "~/utils/remix.server"; +import { userResultsPage } from "~/utils/urls"; +import { editHighlightsActionSchema } from "../user-page-schemas.server"; + +export const action: ActionFunction = async ({ request }) => { + const user = await requireUser(request); + const data = await parseRequestPayload({ + request, + schema: editHighlightsActionSchema, + }); + + const resultTeamIds = normalizeFormFieldArray( + data[HIGHLIGHT_CHECKBOX_NAME], + ).map((id) => Number.parseInt(id, 10)); + const resultTournamentTeamIds = normalizeFormFieldArray( + data[HIGHLIGHT_TOURNAMENT_CHECKBOX_NAME], + ).map((id) => Number.parseInt(id, 10)); + + await UserRepository.updateResultHighlights({ + userId: user.id, + resultTeamIds, + resultTournamentTeamIds, + }); + + throw redirect(userResultsPage(user)); +}; diff --git a/app/features/user-page/loaders/u.$identifier.art.server.ts b/app/features/user-page/loaders/u.$identifier.art.server.ts new file mode 100644 index 000000000..341e6cec1 --- /dev/null +++ b/app/features/user-page/loaders/u.$identifier.art.server.ts @@ -0,0 +1,41 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { artsByUserId } from "~/features/art/queries/artsByUserId.server"; +import { getUserId } from "~/features/auth/core/user.server"; +import { countUnvalidatedArt } from "~/features/img-upload"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { notFoundIfFalsy } from "~/utils/remix.server"; +import { userParamsSchema } from "../user-page-schemas.server"; + +export const loader = async ({ params, request }: LoaderFunctionArgs) => { + const loggedInUser = await getUserId(request); + + const { identifier } = userParamsSchema.parse(params); + const user = notFoundIfFalsy( + await UserRepository.identifierToUserId(identifier), + ); + + const arts = artsByUserId(user.id); + + const tagCounts = arts.reduce( + (acc, art) => { + if (!art.tags) return acc; + + for (const tag of art.tags) { + acc[tag] = (acc[tag] ?? 0) + 1; + } + return acc; + }, + {} as Record, + ); + + const tagCountsSortedArr = Object.entries(tagCounts).sort( + (a, b) => b[1] - a[1], + ); + + return { + arts, + tagCounts: tagCountsSortedArr.length > 0 ? tagCountsSortedArr : null, + unvalidatedArtCount: + user.id === loggedInUser?.id ? countUnvalidatedArt(user.id) : 0, + }; +}; diff --git a/app/features/user-page/loaders/u.$identifier.edit.server.ts b/app/features/user-page/loaders/u.$identifier.edit.server.ts new file mode 100644 index 000000000..db6feec70 --- /dev/null +++ b/app/features/user-page/loaders/u.$identifier.edit.server.ts @@ -0,0 +1,45 @@ +import { type LoaderFunctionArgs, redirect } from "@remix-run/node"; +import type { TCountryCode } from "countries-list"; +import { countries, getEmojiFlag } from "countries-list"; +import { requireUser } from "~/features/auth/core/user.server"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { i18next } from "~/modules/i18n/i18next.server"; +import { translatedCountry } from "~/utils/i18n.server"; +import { notFoundIfFalsy } from "~/utils/remix.server"; +import { userPage } from "~/utils/urls"; +import { userParamsSchema } from "../user-page-schemas.server"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const locale = await i18next.getLocale(request); + + const user = await requireUser(request); + const { identifier } = userParamsSchema.parse(params); + const userToBeEdited = notFoundIfFalsy( + await UserRepository.findLayoutDataByIdentifier(identifier), + ); + if (user.id !== userToBeEdited.id) { + throw redirect(userPage(userToBeEdited)); + } + + const userProfile = (await UserRepository.findProfileByIdentifier( + identifier, + true, + ))!; + + return { + user: userProfile, + favoriteBadgeId: user.favoriteBadgeId, + discordUniqueName: userProfile.discordUniqueName, + countries: Object.entries(countries) + .map(([code, country]) => ({ + code, + emoji: getEmojiFlag(code as TCountryCode), + name: + translatedCountry({ + countryCode: code, + language: locale, + }) ?? country.name, + })) + .sort((a, b) => a.name.localeCompare(b.name)), + }; +}; diff --git a/app/features/user-page/loaders/u.$identifier.seasons.server.ts b/app/features/user-page/loaders/u.$identifier.seasons.server.ts new file mode 100644 index 000000000..30432aeb2 --- /dev/null +++ b/app/features/user-page/loaders/u.$identifier.seasons.server.ts @@ -0,0 +1,78 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { seasonAllMMRByUserId } from "~/features/mmr/queries/seasonAllMMRByUserId.server"; +import { currentOrPreviousSeason } from "~/features/mmr/season"; +import { userSkills as _userSkills } from "~/features/mmr/tiered.server"; +import { seasonMapWinrateByUserId } from "~/features/sendouq/queries/seasonMapWinrateByUserId.server"; +import { + seasonMatchesByUserId, + seasonMatchesByUserIdPagesCount, +} from "~/features/sendouq/queries/seasonMatchesByUserId.server"; +import { seasonReportedWeaponsByUserId } from "~/features/sendouq/queries/seasonReportedWeaponsByUserId.server"; +import { seasonSetWinrateByUserId } from "~/features/sendouq/queries/seasonSetWinrateByUserId.server"; +import { seasonStagesByUserId } from "~/features/sendouq/queries/seasonStagesByUserId.server"; +import { seasonsMatesEnemiesByUserId } from "~/features/sendouq/queries/seasonsMatesEnemiesByUserId.server"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { notFoundIfFalsy } from "~/utils/remix.server"; +import { + seasonsSearchParamsSchema, + userParamsSchema, +} from "../user-page-schemas.server"; + +export const loader = async ({ params, request }: LoaderFunctionArgs) => { + const { identifier } = userParamsSchema.parse(params); + const parsedSearchParams = seasonsSearchParamsSchema.safeParse( + Object.fromEntries(new URL(request.url).searchParams), + ); + const { + info = "weapons", + page = 1, + season = currentOrPreviousSeason(new Date())!.nth, + } = parsedSearchParams.success ? parsedSearchParams.data : {}; + + const user = notFoundIfFalsy( + await UserRepository.identifierToUserId(identifier), + ); + + const { isAccurateTiers, userSkills } = _userSkills(season); + const { tier, ordinal, approximate } = userSkills[user.id] ?? { + approximate: false, + ordinal: 0, + tier: { isPlus: false, name: "IRON" }, + }; + + return { + currentOrdinal: !approximate ? ordinal : undefined, + winrates: { + maps: seasonMapWinrateByUserId({ season, userId: user.id }), + sets: seasonSetWinrateByUserId({ season, userId: user.id }), + }, + skills: seasonAllMMRByUserId({ season, userId: user.id }), + tier, + isAccurateTiers, + matches: { + value: seasonMatchesByUserId({ season, userId: user.id, page }), + currentPage: page, + pages: seasonMatchesByUserIdPagesCount({ season, userId: user.id }), + }, + season, + info: { + currentTab: info, + stages: + info === "stages" + ? seasonStagesByUserId({ season, userId: user.id }) + : null, + weapons: + info === "weapons" + ? seasonReportedWeaponsByUserId({ season, userId: user.id }) + : null, + players: + info === "enemies" || info === "mates" + ? seasonsMatesEnemiesByUserId({ + season, + userId: user.id, + type: info === "enemies" ? "ENEMY" : "MATE", + }) + : null, + }, + }; +}; diff --git a/app/features/user-page/loaders/u.$identifier.server.ts b/app/features/user-page/loaders/u.$identifier.server.ts new file mode 100644 index 000000000..ab9ba762a --- /dev/null +++ b/app/features/user-page/loaders/u.$identifier.server.ts @@ -0,0 +1,25 @@ +import type { LoaderFunctionArgs, SerializeFrom } from "@remix-run/node"; +import { getUserId } from "~/features/auth/core/user.server"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { notFoundIfFalsy } from "~/utils/remix.server"; + +export type UserPageLoaderData = SerializeFrom; + +export const loader = async ({ params, request }: LoaderFunctionArgs) => { + const loggedInUser = await getUserId(request); + + const user = notFoundIfFalsy( + await UserRepository.findLayoutDataByIdentifier( + params.identifier!, + loggedInUser?.id, + ), + ); + + return { + user: { + ...user, + css: undefined, + }, + css: user.css, + }; +}; diff --git a/app/features/user-page/routes/short.$customUrl.tsx b/app/features/user-page/routes/short.$customUrl.ts similarity index 100% rename from app/features/user-page/routes/short.$customUrl.tsx rename to app/features/user-page/routes/short.$customUrl.ts diff --git a/app/features/user-page/routes/u.$identifier.art.tsx b/app/features/user-page/routes/u.$identifier.art.tsx index 8c273f8db..8d947744c 100644 --- a/app/features/user-page/routes/u.$identifier.art.tsx +++ b/app/features/user-page/routes/u.$identifier.art.tsx @@ -1,86 +1,21 @@ -import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node"; import { useLoaderData, useMatches } from "@remix-run/react"; import { useTranslation } from "react-i18next"; -import { deleteArtSchema } from "~/features/art/art-schemas.server"; import { ART_SOURCES, type ArtSource } from "~/features/art/art-types"; import { ArtGrid } from "~/features/art/components/ArtGrid"; -import { artsByUserId } from "~/features/art/queries/artsByUserId.server"; -import { deleteArt } from "~/features/art/queries/deleteArt.server"; -import { findArtById } from "~/features/art/queries/findArtById.server"; import { useUser } from "~/features/auth/core/user"; -import { getUserId, requireUserId } from "~/features/auth/core/user.server"; -import { countUnvalidatedArt } from "~/features/img-upload"; -import * as UserRepository from "~/features/user-page/UserRepository.server"; import { useSearchParamState } from "~/hooks/useSearchParamState"; import invariant from "~/utils/invariant"; -import { - type SendouRouteHandle, - errorToastIfFalsy, - notFoundIfFalsy, - parseRequestPayload, -} from "~/utils/remix.server"; -import { userParamsSchema } from "../user-page-schemas.server"; -import type { UserPageLoaderData } from "./u.$identifier"; +import type { SendouRouteHandle } from "~/utils/remix.server"; +import type { UserPageLoaderData } from "../loaders/u.$identifier.server"; + +import { action } from "../actions/u.$identifier.art.server"; +import { loader } from "../loaders/u.$identifier.art.server"; +export { action, loader }; export const handle: SendouRouteHandle = { i18n: ["art"], }; -export const action: ActionFunction = async ({ request }) => { - const user = await requireUserId(request); - const data = await parseRequestPayload({ - request, - schema: deleteArtSchema, - }); - - // this actually doesn't delete the image itself from the static hosting - // but the idea is that storage is cheap anyway and if needed later - // then we can have a routine that checks all the images still current and nukes the rest - const artToDelete = findArtById(data.id); - errorToastIfFalsy( - artToDelete?.authorId === user.id, - "Insufficient permissions", - ); - - deleteArt(data.id); - - return null; -}; - -export const loader = async ({ params, request }: LoaderFunctionArgs) => { - const loggedInUser = await getUserId(request); - - const { identifier } = userParamsSchema.parse(params); - const user = notFoundIfFalsy( - await UserRepository.identifierToUserId(identifier), - ); - - const arts = artsByUserId(user.id); - - const tagCounts = arts.reduce( - (acc, art) => { - if (!art.tags) return acc; - - for (const tag of art.tags) { - acc[tag] = (acc[tag] ?? 0) + 1; - } - return acc; - }, - {} as Record, - ); - - const tagCountsSortedArr = Object.entries(tagCounts).sort( - (a, b) => b[1] - a[1], - ); - - return { - arts, - tagCounts: tagCountsSortedArr.length > 0 ? tagCountsSortedArr : null, - unvalidatedArtCount: - user.id === loggedInUser?.id ? countUnvalidatedArt(user.id) : 0, - }; -}; - const ALL_TAGS_KEY = "ALL"; export default function UserArtPage() { const { t } = useTranslation(["art"]); diff --git a/app/features/user-page/routes/u.$identifier.builds.new.tsx b/app/features/user-page/routes/u.$identifier.builds.new.tsx index a0a577594..120c58cc3 100644 --- a/app/features/user-page/routes/u.$identifier.builds.new.tsx +++ b/app/features/user-page/routes/u.$identifier.builds.new.tsx @@ -19,6 +19,7 @@ import { SubmitButton } from "~/components/SubmitButton"; import { CrossIcon } from "~/components/icons/Cross"; import { PlusIcon } from "~/components/icons/Plus"; import { BUILD } from "~/constants"; +import type { GearType } from "~/db/tables"; import { validatedBuildFromSearchParams, validatedWeaponIdFromSearchParams, @@ -32,9 +33,8 @@ import type { import invariant from "~/utils/invariant"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { modeImageUrl } from "~/utils/urls"; -import type { UserPageLoaderData } from "./u.$identifier"; +import type { UserPageLoaderData } from "../loaders/u.$identifier.server"; -import type { GearType } from "~/db/tables"; import { action } from "../actions/u.$identifier.builds.new.server"; import { loader } from "../loaders/u.$identifier.builds.new.server"; export { loader, action }; diff --git a/app/features/user-page/routes/u.$identifier.builds.tsx b/app/features/user-page/routes/u.$identifier.builds.tsx index 6c62dccaf..a2f49bce8 100644 --- a/app/features/user-page/routes/u.$identifier.builds.tsx +++ b/app/features/user-page/routes/u.$identifier.builds.tsx @@ -21,8 +21,8 @@ import { atOrError } from "~/utils/arrays"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { weaponCategoryUrl } from "~/utils/urls"; import { SendouButton } from "../../../components/elements/Button"; +import type { UserPageLoaderData } from "../loaders/u.$identifier.server"; import { DEFAULT_BUILD_SORT } from "../user-page-constants"; -import type { UserPageLoaderData } from "./u.$identifier"; import { action } from "../actions/u.$identifier.builds.server"; import { loader } from "../loaders/u.$identifier.builds.server"; diff --git a/app/features/user-page/routes/u.$identifier.edit.test.ts b/app/features/user-page/routes/u.$identifier.edit.test.ts index dcd97a0a6..9e92cfc8f 100644 --- a/app/features/user-page/routes/u.$identifier.edit.test.ts +++ b/app/features/user-page/routes/u.$identifier.edit.test.ts @@ -1,10 +1,8 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { MainWeaponId } from "~/modules/in-game-lists"; import { dbInsertUsers, dbReset, wrappedAction } from "~/utils/Test"; -import { - action as editUserProfileAction, - type userEditActionSchema, -} from "./u.$identifier.edit"; +import type { userEditActionSchema } from "../user-page-schemas.server"; +import { action as editUserProfileAction } from "./u.$identifier.edit"; const action = wrappedAction({ action: editUserProfileAction, diff --git a/app/features/user-page/routes/u.$identifier.edit.tsx b/app/features/user-page/routes/u.$identifier.edit.tsx index 4eae7f3b4..1b160204a 100644 --- a/app/features/user-page/routes/u.$identifier.edit.tsx +++ b/app/features/user-page/routes/u.$identifier.edit.tsx @@ -1,14 +1,6 @@ -import { - type ActionFunction, - type LoaderFunctionArgs, - redirect, -} from "@remix-run/node"; import { Form, Link, useLoaderData, useMatches } from "@remix-run/react"; -import type { TCountryCode } from "countries-list"; -import { countries, getEmojiFlag } from "countries-list"; import * as React from "react"; import { Trans, useTranslation } from "react-i18next"; -import { z } from "zod"; import { Button } from "~/components/Button"; import { WeaponCombobox } from "~/components/Combobox"; import { CustomizedColorsInput } from "~/components/CustomizedColorsInput"; @@ -18,234 +10,25 @@ import { WeaponImage } from "~/components/Image"; import { Input } from "~/components/Input"; import { Label } from "~/components/Label"; import { SubmitButton } from "~/components/SubmitButton"; +import { SendouSwitch } from "~/components/elements/Switch"; import { StarIcon } from "~/components/icons/Star"; import { StarFilledIcon } from "~/components/icons/StarFilled"; import { TrashIcon } from "~/components/icons/Trash"; import { USER } from "~/constants"; +import type { Tables } from "~/db/tables"; import { useUser } from "~/features/auth/core/user"; -import { requireUser, requireUserId } from "~/features/auth/core/user.server"; -import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; -import * as UserRepository from "~/features/user-page/UserRepository.server"; -import { i18next } from "~/modules/i18n/i18next.server"; import type { MainWeaponId } from "~/modules/in-game-lists"; import { canAddCustomizedColorsToUserProfile } from "~/permissions"; -import { translatedCountry } from "~/utils/i18n.server"; import invariant from "~/utils/invariant"; -import { - notFoundIfFalsy, - safeParseRequestFormData, -} from "~/utils/remix.server"; -import { errorIsSqliteUniqueConstraintFailure } from "~/utils/sql"; import { rawSensToString } from "~/utils/strings"; -import { FAQ_PAGE, isCustomUrl, userPage } from "~/utils/urls"; -import { - actualNumber, - actuallyNonEmptyStringOrNull, - checkboxValueToDbBoolean, - customCssVarObject, - dbBoolean, - falsyToNull, - id, - processMany, - safeJSONParse, - undefinedToNull, - weaponSplId, -} from "~/utils/zod"; -import { userParamsSchema } from "../user-page-schemas.server"; -import type { UserPageLoaderData } from "./u.$identifier"; +import { FAQ_PAGE } from "~/utils/urls"; +import type { UserPageLoaderData } from "../loaders/u.$identifier.server"; + +import { action } from "../actions/u.$identifier.edit.server"; +import { loader } from "../loaders/u.$identifier.edit.server"; +export { loader, action }; + import "~/styles/u-edit.css"; -import { SendouSwitch } from "~/components/elements/Switch"; -import type { Tables } from "~/db/tables"; -import { clearTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server"; - -export const userEditActionSchema = z - .object({ - country: z.preprocess( - falsyToNull, - z - .string() - .refine( - (val) => !val || Object.keys(countries).some((code) => val === code), - ) - .nullable(), - ), - bio: z.preprocess( - falsyToNull, - z.string().max(USER.BIO_MAX_LENGTH).nullable(), - ), - customUrl: z.preprocess( - falsyToNull, - z - .string() - .max(USER.CUSTOM_URL_MAX_LENGTH) - .refine((val) => val === null || isCustomUrl(val), { - message: "forms.errors.invalidCustomUrl.numbers", - }) - .refine((val) => val === null || /^[a-zA-Z0-9-_]+$/.test(val), { - message: "forms.errors.invalidCustomUrl.strangeCharacter", - }) - .transform((val) => val?.toLowerCase()) - .nullable(), - ), - customName: z.preprocess( - actuallyNonEmptyStringOrNull, - z.string().max(USER.CUSTOM_NAME_MAX_LENGTH).nullable(), - ), - battlefy: z.preprocess( - falsyToNull, - z.string().max(USER.BATTLEFY_MAX_LENGTH).nullable(), - ), - stickSens: z.preprocess( - processMany(actualNumber, undefinedToNull), - z - .number() - .min(-50) - .max(50) - .refine((val) => val % 5 === 0) - .nullable(), - ), - motionSens: z.preprocess( - processMany(actualNumber, undefinedToNull), - z - .number() - .min(-50) - .max(50) - .refine((val) => val % 5 === 0) - .nullable(), - ), - inGameNameText: z.preprocess( - falsyToNull, - z.string().max(USER.IN_GAME_NAME_TEXT_MAX_LENGTH).nullable(), - ), - inGameNameDiscriminator: z.preprocess( - falsyToNull, - z - .string() - .refine((val) => /^[0-9a-z]{4,5}$/.test(val)) - .nullable(), - ), - css: customCssVarObject, - weapons: z.preprocess( - safeJSONParse, - z - .array( - z.object({ - weaponSplId, - isFavorite: dbBoolean, - }), - ) - .max(USER.WEAPON_POOL_MAX_SIZE), - ), - favoriteBadgeId: z.preprocess( - processMany(actualNumber, undefinedToNull), - id.nullable(), - ), - showDiscordUniqueName: z.preprocess(checkboxValueToDbBoolean, dbBoolean), - commissionsOpen: z.preprocess(checkboxValueToDbBoolean, dbBoolean), - commissionText: z.preprocess( - falsyToNull, - z.string().max(USER.COMMISSION_TEXT_MAX_LENGTH).nullable(), - ), - }) - .refine( - (val) => { - if (val.motionSens !== null && val.stickSens === null) { - return false; - } - - return true; - }, - { - message: "forms.errors.invalidSens", - }, - ); - -export const action: ActionFunction = async ({ request }) => { - const parsedInput = await safeParseRequestFormData({ - request, - schema: userEditActionSchema, - }); - - if (!parsedInput.success) { - return { - errors: parsedInput.errors, - }; - } - - const { inGameNameText, inGameNameDiscriminator, ...data } = parsedInput.data; - - const user = await requireUserId(request); - const inGameName = - inGameNameText && inGameNameDiscriminator - ? `${inGameNameText}#${inGameNameDiscriminator}` - : null; - - try { - const editedUser = await UserRepository.updateProfile({ - ...data, - inGameName, - userId: user.id, - }); - - // TODO: to transaction - if (inGameName) { - const tournamentIdsAffected = - await TournamentTeamRepository.updateMemberInGameNameForNonStarted({ - inGameName, - userId: user.id, - }); - - for (const tournamentId of tournamentIdsAffected) { - clearTournamentDataCache(tournamentId); - } - } - - throw redirect(userPage(editedUser)); - } catch (e) { - if (!errorIsSqliteUniqueConstraintFailure(e)) { - throw e; - } - - return { - errors: ["forms.errors.invalidCustomUrl.duplicate"], - }; - } -}; - -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const locale = await i18next.getLocale(request); - - const user = await requireUser(request); - const { identifier } = userParamsSchema.parse(params); - const userToBeEdited = notFoundIfFalsy( - await UserRepository.findLayoutDataByIdentifier(identifier), - ); - if (user.id !== userToBeEdited.id) { - throw redirect(userPage(userToBeEdited)); - } - - const userProfile = (await UserRepository.findProfileByIdentifier( - identifier, - true, - ))!; - - return { - user: userProfile, - favoriteBadgeId: user.favoriteBadgeId, - discordUniqueName: userProfile.discordUniqueName, - countries: Object.entries(countries) - .map(([code, country]) => ({ - code, - emoji: getEmojiFlag(code as TCountryCode), - name: - translatedCountry({ - countryCode: code, - language: locale, - }) ?? country.name, - })) - .sort((a, b) => a.name.localeCompare(b.name)), - }; -}; export default function UserEditPage() { const user = useUser(); diff --git a/app/features/user-page/routes/u.$identifier.index.tsx b/app/features/user-page/routes/u.$identifier.index.tsx index 5abbe165f..e87fc5d4a 100644 --- a/app/features/user-page/routes/u.$identifier.index.tsx +++ b/app/features/user-page/routes/u.$identifier.index.tsx @@ -26,7 +26,7 @@ import { topSearchPlayerPage, userSubmittedImage, } from "~/utils/urls"; -import type { UserPageLoaderData } from "./u.$identifier"; +import type { UserPageLoaderData } from "../loaders/u.$identifier.server"; import { loader } from "../loaders/u.$identifier.index.server"; export { loader }; diff --git a/app/features/user-page/routes/u.$identifier.results.highlights.tsx b/app/features/user-page/routes/u.$identifier.results.highlights.tsx index 53697cbb8..b2540704c 100644 --- a/app/features/user-page/routes/u.$identifier.results.highlights.tsx +++ b/app/features/user-page/routes/u.$identifier.results.highlights.tsx @@ -1,54 +1,12 @@ -import { type ActionFunction, redirect } from "@remix-run/node"; import { Form, useLoaderData } from "@remix-run/react"; import { useTranslation } from "react-i18next"; -import { z } from "zod"; import { FormErrors } from "~/components/FormErrors"; import { SubmitButton } from "~/components/SubmitButton"; -import { requireUser } from "~/features/auth/core/user.server"; -import * as UserRepository from "~/features/user-page/UserRepository.server"; -import { - HIGHLIGHT_CHECKBOX_NAME, - HIGHLIGHT_TOURNAMENT_CHECKBOX_NAME, - UserResultsTable, -} from "~/features/user-page/components/UserResultsTable"; -import { normalizeFormFieldArray } from "~/utils/arrays"; -import { parseRequestPayload } from "~/utils/remix.server"; -import { userResultsPage } from "~/utils/urls"; +import { UserResultsTable } from "~/features/user-page/components/UserResultsTable"; +import { action } from "../actions/u.$identifier.results.highlights.server"; import { loader } from "../loaders/u.$identifier.results.server"; -export { loader }; - -const editHighlightsActionSchema = z.object({ - [HIGHLIGHT_CHECKBOX_NAME]: z.optional( - z.union([z.array(z.string()), z.string()]), - ), - [HIGHLIGHT_TOURNAMENT_CHECKBOX_NAME]: z.optional( - z.union([z.array(z.string()), z.string()]), - ), -}); - -export const action: ActionFunction = async ({ request }) => { - const user = await requireUser(request); - const data = await parseRequestPayload({ - request, - schema: editHighlightsActionSchema, - }); - - const resultTeamIds = normalizeFormFieldArray( - data[HIGHLIGHT_CHECKBOX_NAME], - ).map((id) => Number.parseInt(id, 10)); - const resultTournamentTeamIds = normalizeFormFieldArray( - data[HIGHLIGHT_TOURNAMENT_CHECKBOX_NAME], - ).map((id) => Number.parseInt(id, 10)); - - await UserRepository.updateResultHighlights({ - userId: user.id, - resultTeamIds, - resultTournamentTeamIds, - }); - - throw redirect(userResultsPage(user)); -}; +export { loader, action }; export default function ResultHighlightsEditPage() { const { t } = useTranslation(["common", "user"]); diff --git a/app/features/user-page/routes/u.$identifier.results.tsx b/app/features/user-page/routes/u.$identifier.results.tsx index 29241a777..9ac8e5642 100644 --- a/app/features/user-page/routes/u.$identifier.results.tsx +++ b/app/features/user-page/routes/u.$identifier.results.tsx @@ -6,7 +6,7 @@ import { UserResultsTable } from "~/features/user-page/components/UserResultsTab import { useSearchParamState } from "~/hooks/useSearchParamState"; import invariant from "~/utils/invariant"; import { userResultsEditHighlightsPage } from "~/utils/urls"; -import type { UserPageLoaderData } from "../../../features/user-page/routes/u.$identifier"; +import type { UserPageLoaderData } from "../loaders/u.$identifier.server"; import { loader } from "../loaders/u.$identifier.results.server"; export { loader }; diff --git a/app/features/user-page/routes/u.$identifier.seasons.tsx b/app/features/user-page/routes/u.$identifier.seasons.tsx index 4448e57ca..55dff6c40 100644 --- a/app/features/user-page/routes/u.$identifier.seasons.tsx +++ b/app/features/user-page/routes/u.$identifier.seasons.tsx @@ -1,4 +1,4 @@ -import type { LoaderFunctionArgs, SerializeFrom } from "@remix-run/node"; +import type { SerializeFrom } from "@remix-run/node"; import { Link, useLoaderData, @@ -25,23 +25,7 @@ import { AlertIcon } from "~/components/icons/Alert"; import { TopTenPlayer } from "~/features/leaderboards/components/TopTenPlayer"; import { playerTopTenPlacement } from "~/features/leaderboards/leaderboards-utils"; import { ordinalToSp } from "~/features/mmr/mmr-utils"; -import { seasonAllMMRByUserId } from "~/features/mmr/queries/seasonAllMMRByUserId.server"; -import { - allSeasons, - currentOrPreviousSeason, - seasonObject, -} from "~/features/mmr/season"; -import { userSkills as _userSkills } from "~/features/mmr/tiered.server"; -import { seasonMapWinrateByUserId } from "~/features/sendouq/queries/seasonMapWinrateByUserId.server"; -import { - seasonMatchesByUserId, - seasonMatchesByUserIdPagesCount, -} from "~/features/sendouq/queries/seasonMatchesByUserId.server"; -import { seasonReportedWeaponsByUserId } from "~/features/sendouq/queries/seasonReportedWeaponsByUserId.server"; -import { seasonSetWinrateByUserId } from "~/features/sendouq/queries/seasonSetWinrateByUserId.server"; -import { seasonStagesByUserId } from "~/features/sendouq/queries/seasonStagesByUserId.server"; -import { seasonsMatesEnemiesByUserId } from "~/features/sendouq/queries/seasonsMatesEnemiesByUserId.server"; -import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { allSeasons, seasonObject } from "~/features/mmr/season"; import { useWeaponUsage } from "~/hooks/swr"; import { useIsMounted } from "~/hooks/useIsMounted"; import { @@ -54,77 +38,17 @@ import { atOrError } from "~/utils/arrays"; import { databaseTimestampToDate } from "~/utils/dates"; import invariant from "~/utils/invariant"; import { cutToNDecimalPlaces, roundToNDecimalPlaces } from "~/utils/number"; -import { type SendouRouteHandle, notFoundIfFalsy } from "~/utils/remix.server"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import { TIERS_PAGE, sendouQMatchPage, userSeasonsPage } from "~/utils/urls"; -import { - seasonsSearchParamsSchema, - userParamsSchema, -} from "../user-page-schemas.server"; -import type { UserPageLoaderData } from "./u.$identifier"; + +import { loader } from "../loaders/u.$identifier.seasons.server"; +import type { UserPageLoaderData } from "../loaders/u.$identifier.server"; +export { loader }; export const handle: SendouRouteHandle = { i18n: ["user"], }; -export const loader = async ({ params, request }: LoaderFunctionArgs) => { - const { identifier } = userParamsSchema.parse(params); - const parsedSearchParams = seasonsSearchParamsSchema.safeParse( - Object.fromEntries(new URL(request.url).searchParams), - ); - const { - info = "weapons", - page = 1, - season = currentOrPreviousSeason(new Date())!.nth, - } = parsedSearchParams.success ? parsedSearchParams.data : {}; - - const user = notFoundIfFalsy( - await UserRepository.identifierToUserId(identifier), - ); - - const { isAccurateTiers, userSkills } = _userSkills(season); - const { tier, ordinal, approximate } = userSkills[user.id] ?? { - approximate: false, - ordinal: 0, - tier: { isPlus: false, name: "IRON" }, - }; - - return { - currentOrdinal: !approximate ? ordinal : undefined, - winrates: { - maps: seasonMapWinrateByUserId({ season, userId: user.id }), - sets: seasonSetWinrateByUserId({ season, userId: user.id }), - }, - skills: seasonAllMMRByUserId({ season, userId: user.id }), - tier, - isAccurateTiers, - matches: { - value: seasonMatchesByUserId({ season, userId: user.id, page }), - currentPage: page, - pages: seasonMatchesByUserIdPagesCount({ season, userId: user.id }), - }, - season, - info: { - currentTab: info, - stages: - info === "stages" - ? seasonStagesByUserId({ season, userId: user.id }) - : null, - weapons: - info === "weapons" - ? seasonReportedWeaponsByUserId({ season, userId: user.id }) - : null, - players: - info === "enemies" || info === "mates" - ? seasonsMatesEnemiesByUserId({ - season, - userId: user.id, - type: info === "enemies" ? "ENEMY" : "MATE", - }) - : null, - }, - }; -}; - const DAYS_WITH_SKILL_NEEDED_TO_SHOW_POWER_CHART = 2; export default function UserSeasonsPage() { const { t } = useTranslation(["user"]); diff --git a/app/features/user-page/routes/u.$identifier.tsx b/app/features/user-page/routes/u.$identifier.tsx index d78288a5d..37e04350e 100644 --- a/app/features/user-page/routes/u.$identifier.tsx +++ b/app/features/user-page/routes/u.$identifier.tsx @@ -1,17 +1,11 @@ -import type { - LoaderFunctionArgs, - MetaFunction, - SerializeFrom, -} from "@remix-run/node"; +import type { MetaFunction } from "@remix-run/node"; import { Outlet, useLoaderData, useLocation } from "@remix-run/react"; import { useTranslation } from "react-i18next"; import { Main } from "~/components/Main"; import { SubNav, SubNavLink } from "~/components/SubNav"; import { useUser } from "~/features/auth/core/user"; -import { getUserId } from "~/features/auth/core/user.server"; -import * as UserRepository from "~/features/user-page/UserRepository.server"; import { metaTags } from "~/utils/remix"; -import { type SendouRouteHandle, notFoundIfFalsy } from "~/utils/remix.server"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import { USER_SEARCH_PAGE, navIconUrl, @@ -24,6 +18,12 @@ import { userVodsPage, } from "~/utils/urls"; +import { + type UserPageLoaderData, + loader, +} from "../loaders/u.$identifier.server"; +export { loader }; + import "~/styles/u.css"; export const meta: MetaFunction = (args) => { @@ -58,27 +58,6 @@ export const handle: SendouRouteHandle = { }, }; -export type UserPageLoaderData = SerializeFrom; - -export const loader = async ({ params, request }: LoaderFunctionArgs) => { - const loggedInUser = await getUserId(request); - - const user = notFoundIfFalsy( - await UserRepository.findLayoutDataByIdentifier( - params.identifier!, - loggedInUser?.id, - ), - ); - - return { - user: { - ...user, - css: undefined, - }, - css: user.css, - }; -}; - export default function UserPageLayout() { const data = useLoaderData(); const user = useUser(); diff --git a/app/features/user-page/user-page-schemas.server.ts b/app/features/user-page/user-page-schemas.server.ts index d1c22df61..31f59aed4 100644 --- a/app/features/user-page/user-page-schemas.server.ts +++ b/app/features/user-page/user-page-schemas.server.ts @@ -1,5 +1,26 @@ +import { countries } from "countries-list"; import { z } from "zod"; +import { USER } from "~/constants"; +import "~/styles/u-edit.css"; +import { isCustomUrl } from "~/utils/urls"; +import { + actualNumber, + actuallyNonEmptyStringOrNull, + checkboxValueToDbBoolean, + customCssVarObject, + dbBoolean, + falsyToNull, + id, + processMany, + safeJSONParse, + undefinedToNull, + weaponSplId, +} from "~/utils/zod"; import { allSeasons } from "../mmr/season"; +import { + HIGHLIGHT_CHECKBOX_NAME, + HIGHLIGHT_TOURNAMENT_CHECKBOX_NAME, +} from "./components/UserResultsTable"; export const userParamsSchema = z.object({ identifier: z.string() }); @@ -11,3 +32,114 @@ export const seasonsSearchParamsSchema = z.object({ .optional() .refine((nth) => !nth || allSeasons(new Date()).includes(nth)), }); + +export const userEditActionSchema = z + .object({ + country: z.preprocess( + falsyToNull, + z + .string() + .refine( + (val) => !val || Object.keys(countries).some((code) => val === code), + ) + .nullable(), + ), + bio: z.preprocess( + falsyToNull, + z.string().max(USER.BIO_MAX_LENGTH).nullable(), + ), + customUrl: z.preprocess( + falsyToNull, + z + .string() + .max(USER.CUSTOM_URL_MAX_LENGTH) + .refine((val) => val === null || isCustomUrl(val), { + message: "forms.errors.invalidCustomUrl.numbers", + }) + .refine((val) => val === null || /^[a-zA-Z0-9-_]+$/.test(val), { + message: "forms.errors.invalidCustomUrl.strangeCharacter", + }) + .transform((val) => val?.toLowerCase()) + .nullable(), + ), + customName: z.preprocess( + actuallyNonEmptyStringOrNull, + z.string().max(USER.CUSTOM_NAME_MAX_LENGTH).nullable(), + ), + battlefy: z.preprocess( + falsyToNull, + z.string().max(USER.BATTLEFY_MAX_LENGTH).nullable(), + ), + stickSens: z.preprocess( + processMany(actualNumber, undefinedToNull), + z + .number() + .min(-50) + .max(50) + .refine((val) => val % 5 === 0) + .nullable(), + ), + motionSens: z.preprocess( + processMany(actualNumber, undefinedToNull), + z + .number() + .min(-50) + .max(50) + .refine((val) => val % 5 === 0) + .nullable(), + ), + inGameNameText: z.preprocess( + falsyToNull, + z.string().max(USER.IN_GAME_NAME_TEXT_MAX_LENGTH).nullable(), + ), + inGameNameDiscriminator: z.preprocess( + falsyToNull, + z + .string() + .refine((val) => /^[0-9a-z]{4,5}$/.test(val)) + .nullable(), + ), + css: customCssVarObject, + weapons: z.preprocess( + safeJSONParse, + z + .array( + z.object({ + weaponSplId, + isFavorite: dbBoolean, + }), + ) + .max(USER.WEAPON_POOL_MAX_SIZE), + ), + favoriteBadgeId: z.preprocess( + processMany(actualNumber, undefinedToNull), + id.nullable(), + ), + showDiscordUniqueName: z.preprocess(checkboxValueToDbBoolean, dbBoolean), + commissionsOpen: z.preprocess(checkboxValueToDbBoolean, dbBoolean), + commissionText: z.preprocess( + falsyToNull, + z.string().max(USER.COMMISSION_TEXT_MAX_LENGTH).nullable(), + ), + }) + .refine( + (val) => { + if (val.motionSens !== null && val.stickSens === null) { + return false; + } + + return true; + }, + { + message: "forms.errors.invalidSens", + }, + ); + +export const editHighlightsActionSchema = z.object({ + [HIGHLIGHT_CHECKBOX_NAME]: z.optional( + z.union([z.array(z.string()), z.string()]), + ), + [HIGHLIGHT_TOURNAMENT_CHECKBOX_NAME]: z.optional( + z.union([z.array(z.string()), z.string()]), + ), +}); diff --git a/app/features/user-search/loaders/u.server.ts b/app/features/user-search/loaders/u.server.ts new file mode 100644 index 000000000..8625d56f6 --- /dev/null +++ b/app/features/user-search/loaders/u.server.ts @@ -0,0 +1,30 @@ +import type { LoaderFunctionArgs, SerializeFrom } from "@remix-run/node"; +import { z } from "zod"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { parseSearchParams } from "~/utils/remix.server"; +import { queryToUserIdentifier } from "~/utils/users"; + +export type UserSearchLoaderData = SerializeFrom; + +const searchParamsSchema = z.object({ + q: z.string().max(100).catch(""), + limit: z.coerce.number().int().min(1).max(25).catch(25), +}); + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const { q: query, limit } = parseSearchParams({ + request, + schema: searchParamsSchema, + }); + + if (!query) return null; + + const identifier = queryToUserIdentifier(query); + + return { + users: identifier + ? await UserRepository.searchExact(identifier) + : await UserRepository.search({ query, limit }), + query, + }; +}; diff --git a/app/features/user-search/routes/u.tsx b/app/features/user-search/routes/u.tsx index bbfb1312c..0814b8e40 100644 --- a/app/features/user-search/routes/u.tsx +++ b/app/features/user-search/routes/u.tsx @@ -1,25 +1,18 @@ -import type { - LoaderFunctionArgs, - MetaFunction, - SerializeFrom, -} from "@remix-run/node"; +import type { MetaFunction } from "@remix-run/node"; import { Link, useLoaderData, useSearchParams } from "@remix-run/react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { useDebounce } from "react-use"; -import { z } from "zod"; import { Avatar } from "~/components/Avatar"; import { Input } from "~/components/Input"; import { Main } from "~/components/Main"; import { SearchIcon } from "~/components/icons/Search"; -import * as UserRepository from "~/features/user-page/UserRepository.server"; import { metaTags } from "~/utils/remix"; -import { - type SendouRouteHandle, - parseSearchParams, -} from "~/utils/remix.server"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import { USER_SEARCH_PAGE, navIconUrl, userPage } from "~/utils/urls"; -import { queryToUserIdentifier } from "~/utils/users"; + +import { loader } from "../loaders/u.server"; +export { loader }; import "~/styles/u.css"; @@ -40,31 +33,6 @@ export const meta: MetaFunction = (args) => { }); }; -export type UserSearchLoaderData = SerializeFrom; - -const searchParamsSchema = z.object({ - q: z.string().max(100).catch(""), - limit: z.coerce.number().int().min(1).max(25).catch(25), -}); - -export const loader = async ({ request }: LoaderFunctionArgs) => { - const { q: query, limit } = parseSearchParams({ - request, - schema: searchParamsSchema, - }); - - if (!query) return null; - - const identifier = queryToUserIdentifier(query); - - return { - users: identifier - ? await UserRepository.searchExact(identifier) - : await UserRepository.search({ query, limit }), - query, - }; -}; - export default function UserSearchPage() { const [searchParams, setSearchParams] = useSearchParams(); const [inputValue, setInputValue] = React.useState( diff --git a/app/features/vods/actions/vods.new.server.ts b/app/features/vods/actions/vods.new.server.ts new file mode 100644 index 000000000..85e939c9a --- /dev/null +++ b/app/features/vods/actions/vods.new.server.ts @@ -0,0 +1,51 @@ +import { type ActionFunction, redirect } from "@remix-run/node"; +import type { Tables } from "~/db/tables"; +import { requireUser } from "~/features/auth/core/user.server"; +import { notFoundIfFalsy, parseRequestPayload } from "~/utils/remix.server"; +import { vodVideoPage } from "~/utils/urls"; +import { createVod, updateVodByReplacing } from "../queries/createVod.server"; +import { findVodById } from "../queries/findVodById.server"; +import { videoInputSchema } from "../vods-schemas"; +import { canAddVideo, canEditVideo } from "../vods-utils"; + +export const action: ActionFunction = async ({ request }) => { + const user = await requireUser(request); + const data = await parseRequestPayload({ + request, + schema: videoInputSchema, + }); + + if (!canAddVideo(user)) { + throw new Response(null, { status: 401 }); + } + + let video: Tables["Video"]; + if (data.vodToEditId) { + const vod = notFoundIfFalsy(findVodById(data.vodToEditId)); + + if ( + !canEditVideo({ + userId: user.id, + submitterUserId: vod.submitterUserId, + povUserId: typeof vod.pov === "string" ? undefined : vod.pov?.id, + }) + ) { + throw new Response("no permissions to edit this vod", { status: 401 }); + } + + video = updateVodByReplacing({ + ...data.video, + submitterUserId: user.id, + isValidated: true, + id: data.vodToEditId, + }); + } else { + video = createVod({ + ...data.video, + submitterUserId: user.id, + isValidated: true, + }); + } + + throw redirect(vodVideoPage(video.id)); +}; diff --git a/app/features/vods/loaders/vods.$id.server.ts b/app/features/vods/loaders/vods.$id.server.ts new file mode 100644 index 000000000..8f49c69c1 --- /dev/null +++ b/app/features/vods/loaders/vods.$id.server.ts @@ -0,0 +1,9 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { notFoundIfFalsy } from "~/utils/remix.server"; +import { findVodById } from "../queries/findVodById.server"; + +export const loader = ({ params }: LoaderFunctionArgs) => { + const vod = notFoundIfFalsy(findVodById(Number(params.id))); + + return { vod }; +}; diff --git a/app/features/vods/loaders/vods.new.server.ts b/app/features/vods/loaders/vods.new.server.ts new file mode 100644 index 000000000..79e8cfc00 --- /dev/null +++ b/app/features/vods/loaders/vods.new.server.ts @@ -0,0 +1,40 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { z } from "zod"; +import { requireUser } from "~/features/auth/core/user.server"; +import { notFoundIfFalsy } from "~/utils/remix.server"; +import { actualNumber, id } from "~/utils/zod"; +import { findVodById } from "../queries/findVodById.server"; +import { canEditVideo, vodToVideoBeingAdded } from "../vods-utils"; + +const newVodLoaderParamsSchema = z.object({ + vod: z.preprocess(actualNumber, id), +}); + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const user = await requireUser(request); + + const url = new URL(request.url); + const params = newVodLoaderParamsSchema.safeParse( + Object.fromEntries(url.searchParams), + ); + + if (!params.success) { + return { vodToEdit: null }; + } + + const vod = notFoundIfFalsy(findVodById(params.data.vod)); + const vodToEdit = vodToVideoBeingAdded(vod); + + if ( + !canEditVideo({ + submitterUserId: vod.submitterUserId, + userId: user.id, + povUserId: + vodToEdit.pov?.type === "USER" ? vodToEdit.pov.userId : undefined, + }) + ) { + return { vodToEdit: null }; + } + + return { vodToEdit: { ...vodToEdit, id: vod.id } }; +}; diff --git a/app/features/vods/loaders/vods.server.ts b/app/features/vods/loaders/vods.server.ts new file mode 100644 index 000000000..a879a09b3 --- /dev/null +++ b/app/features/vods/loaders/vods.server.ts @@ -0,0 +1,28 @@ +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { findVods } from "../queries/findVods.server"; +import { VODS_PAGE_BATCH_SIZE } from "../vods-constants"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const url = new URL(request.url); + + const limit = Number(url.searchParams.get("limit") ?? VODS_PAGE_BATCH_SIZE); + + const vods = findVods({ + ...Object.fromEntries( + Array.from(url.searchParams.entries()).filter(([, value]) => value), + ), + limit: limit + 1, + }); + + let hasMoreVods = false; + if (vods.length > limit) { + vods.pop(); + hasMoreVods = true; + } + + return { + vods, + limit, + hasMoreVods, + }; +}; diff --git a/app/features/vods/routes/vods.$id.tsx b/app/features/vods/routes/vods.$id.tsx index 9517884b6..b261e02cd 100644 --- a/app/features/vods/routes/vods.$id.tsx +++ b/app/features/vods/routes/vods.$id.tsx @@ -1,8 +1,4 @@ -import type { - LoaderFunctionArgs, - MetaFunction, - SerializeFrom, -} from "@remix-run/node"; +import type { MetaFunction, SerializeFrom } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; import clsx from "clsx"; import * as React from "react"; @@ -19,7 +15,7 @@ import { useIsMounted } from "~/hooks/useIsMounted"; import { useSearchParamState } from "~/hooks/useSearchParamState"; import { databaseTimestampToDate } from "~/utils/dates"; import { metaTags } from "~/utils/remix"; -import { type SendouRouteHandle, notFoundIfFalsy } from "~/utils/remix.server"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import type { Unpacked } from "~/utils/types"; import { VODS_PAGE, @@ -30,14 +26,14 @@ import { vodVideoPage, } from "~/utils/urls"; import { PovUser } from "../components/VodPov"; -import { findVodById } from "../queries/findVodById.server"; import type { Vod } from "../vods-types"; import { canEditVideo, secondsToHoursMinutesSecondString } from "../vods-utils"; -import "../vods.css"; - import { action } from "../actions/vods.$id.server"; -export { action }; +import { loader } from "../loaders/vods.$id.server"; +export { loader, action }; + +import "../vods.css"; export const handle: SendouRouteHandle = { breadcrumb: ({ match }) => { @@ -71,12 +67,6 @@ export const meta: MetaFunction = (args) => { }); }; -export const loader = ({ params }: LoaderFunctionArgs) => { - const vod = notFoundIfFalsy(findVodById(Number(params.id))); - - return { vod }; -}; - export default function VodPage() { const [start, setStart] = useSearchParamState({ name: "start", diff --git a/app/features/vods/routes/vods.new.tsx b/app/features/vods/routes/vods.new.tsx index 1edd12241..345160f5e 100644 --- a/app/features/vods/routes/vods.new.tsx +++ b/app/features/vods/routes/vods.new.tsx @@ -1,8 +1,3 @@ -import { - type ActionFunction, - type LoaderFunctionArgs, - redirect, -} from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; import * as React from "react"; import { @@ -12,7 +7,7 @@ import { useFormContext, } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { z } from "zod"; +import type { z } from "zod"; import { Button } from "~/components/Button"; import { WeaponCombobox } from "~/components/Combobox"; import { FormMessage } from "~/components/FormMessage"; @@ -22,110 +17,29 @@ import { UserSearch } from "~/components/UserSearch"; import { AddFieldButton } from "~/components/form/AddFieldButton"; import { RemoveFieldButton } from "~/components/form/RemoveFieldButton"; import type { Tables } from "~/db/tables"; -import { requireUser } from "~/features/auth/core/user.server"; import { type MainWeaponId, modesShort, stageIds, } from "~/modules/in-game-lists"; -import { - type SendouRouteHandle, - notFoundIfFalsy, - parseRequestPayload, -} from "~/utils/remix.server"; -import { vodVideoPage } from "~/utils/urls"; -import { actualNumber, id } from "~/utils/zod"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import { Alert } from "../../../components/Alert"; import { DateFormField } from "../../../components/form/DateFormField"; import { MyForm } from "../../../components/form/MyForm"; import { SelectFormField } from "../../../components/form/SelectFormField"; import { TextFormField } from "../../../components/form/TextFormField"; import { useUser } from "../../auth/core/user"; -import { createVod, updateVodByReplacing } from "../queries/createVod.server"; -import { findVodById } from "../queries/findVodById.server"; import { videoMatchTypes } from "../vods-constants"; import { videoInputSchema } from "../vods-schemas"; -import { canAddVideo, canEditVideo, vodToVideoBeingAdded } from "../vods-utils"; + +import { action } from "../actions/vods.new.server"; +import { loader } from "../loaders/vods.new.server"; +export { action, loader }; export const handle: SendouRouteHandle = { i18n: ["vods", "calendar"], }; -export const action: ActionFunction = async ({ request }) => { - const user = await requireUser(request); - const data = await parseRequestPayload({ - request, - schema: videoInputSchema, - }); - - if (!canAddVideo(user)) { - throw new Response(null, { status: 401 }); - } - - let video: Tables["Video"]; - if (data.vodToEditId) { - const vod = notFoundIfFalsy(findVodById(data.vodToEditId)); - - if ( - !canEditVideo({ - userId: user.id, - submitterUserId: vod.submitterUserId, - povUserId: typeof vod.pov === "string" ? undefined : vod.pov?.id, - }) - ) { - throw new Response("no permissions to edit this vod", { status: 401 }); - } - - video = updateVodByReplacing({ - ...data.video, - submitterUserId: user.id, - isValidated: true, - id: data.vodToEditId, - }); - } else { - video = createVod({ - ...data.video, - submitterUserId: user.id, - isValidated: true, - }); - } - - throw redirect(vodVideoPage(video.id)); -}; - -const newVodLoaderParamsSchema = z.object({ - vod: z.preprocess(actualNumber, id), -}); - -export const loader = async ({ request }: LoaderFunctionArgs) => { - const user = await requireUser(request); - - const url = new URL(request.url); - const params = newVodLoaderParamsSchema.safeParse( - Object.fromEntries(url.searchParams), - ); - - if (!params.success) { - return { vodToEdit: null }; - } - - const vod = notFoundIfFalsy(findVodById(params.data.vod)); - const vodToEdit = vodToVideoBeingAdded(vod); - - if ( - !canEditVideo({ - submitterUserId: vod.submitterUserId, - userId: user.id, - povUserId: - vodToEdit.pov?.type === "USER" ? vodToEdit.pov.userId : undefined, - }) - ) { - return { vodToEdit: null }; - } - - return { vodToEdit: { ...vodToEdit, id: vod.id } }; -}; - export type VodFormFields = z.infer; export default function NewVodPage() { diff --git a/app/features/vods/routes/vods.tsx b/app/features/vods/routes/vods.tsx index ba1786911..c997b45e8 100644 --- a/app/features/vods/routes/vods.tsx +++ b/app/features/vods/routes/vods.tsx @@ -1,4 +1,4 @@ -import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; +import type { MetaFunction } from "@remix-run/node"; import { useLoaderData, useSearchParams } from "@remix-run/react"; import { useTranslation } from "react-i18next"; import { Button } from "~/components/Button"; @@ -10,9 +10,11 @@ import { metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { VODS_PAGE, navIconUrl } from "~/utils/urls"; import { VodListing } from "../components/VodListing"; -import { findVods } from "../queries/findVods.server"; import { VODS_PAGE_BATCH_SIZE, videoMatchTypes } from "../vods-constants"; +import { loader } from "../loaders/vods.server"; +export { loader }; + import "../vods.css"; export const handle: SendouRouteHandle = { @@ -34,31 +36,6 @@ export const meta: MetaFunction = (args) => { }); }; -export const loader = async ({ request }: LoaderFunctionArgs) => { - const url = new URL(request.url); - - const limit = Number(url.searchParams.get("limit") ?? VODS_PAGE_BATCH_SIZE); - - const vods = findVods({ - ...Object.fromEntries( - Array.from(url.searchParams.entries()).filter(([, value]) => value), - ), - limit: limit + 1, - }); - - let hasMoreVods = false; - if (vods.length > limit) { - vods.pop(); - hasMoreVods = true; - } - - return { - vods, - limit, - hasMoreVods, - }; -}; - export default function VodsSearchPage() { const { t } = useTranslation(["vods", "common"]); const data = useLoaderData(); diff --git a/app/routes.ts b/app/routes.ts index 03ae56a3d..ed1971c49 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -79,7 +79,7 @@ export default [ ), route("/to/:id", "features/tournament/routes/to.$id.tsx", [ - index("features/tournament/routes/to.$id.index.tsx"), + index("features/tournament/routes/to.$id.index.ts"), route("register", "features/tournament/routes/to.$id.register.tsx"), route("teams", "features/tournament/routes/to.$id.teams.tsx"), route("teams/:tid", "features/tournament/routes/to.$id.teams.$tid.tsx"), @@ -99,7 +99,7 @@ export default [ ), route( "brackets/subscribe", - "features/tournament-bracket/routes/to.$id.brackets.subscribe.tsx", + "features/tournament-bracket/routes/to.$id.brackets.subscribe.ts", ), route( "matches/:mid", @@ -107,10 +107,10 @@ export default [ ), route( "matches/:mid/subscribe", - "features/tournament-bracket/routes/to.$id.matches.$mid.subscribe.tsx", + "features/tournament-bracket/routes/to.$id.matches.$mid.subscribe.ts", ), ]), - route("luti", "features/tournament/routes/luti.tsx"), + route("luti", "features/tournament/routes/luti.ts"), ...prefix("/org/:slug", [ index("features/tournament-organization/routes/org.$slug.tsx"), @@ -169,11 +169,11 @@ export default [ route("settings", "features/sendouq-settings/routes/q.settings.tsx"), route("streams", "features/sendouq-streams/routes/q.streams.tsx"), ]), - route("/play", "features/sendouq/routes/play.tsx"), + route("/play", "features/sendouq/routes/play.ts"), route("/trusters", "features/sendouq/routes/trusters.ts"), - route("/weapon-usage", "features/sendouq/routes/weapon-usage.tsx"), + route("/weapon-usage", "features/sendouq/routes/weapon-usage.ts"), route("/tiers", "features/sendouq/routes/tiers.tsx"), @@ -190,7 +190,7 @@ export default [ ]), route("/plus", "features/plus-suggestions/routes/plus.tsx", [ - index("features/plus-suggestions/routes/plus.index.tsx"), + index("features/plus-suggestions/routes/plus.index.ts"), route( "suggestions", "features/plus-suggestions/routes/plus.suggestions.tsx", @@ -213,9 +213,9 @@ export default [ ), ]), - route("/patrons", "features/api-private/routes/patrons.tsx"), - route("/seed", "features/api-private/routes/seed.tsx"), - route("/users", "features/api-private/routes/users.tsx"), + route("/patrons", "features/api-private/routes/patrons.ts"), + route("/seed", "features/api-private/routes/seed.ts"), + route("/users", "features/api-private/routes/users.ts"), ...prefix("/api", [ route( @@ -250,17 +250,17 @@ export default [ route("/org/:id", "features/api-public/routes/org.$id.ts"), ]), - route("/short/:customUrl", "features/user-page/routes/short.$customUrl.tsx"), + route("/short/:customUrl", "features/user-page/routes/short.$customUrl.ts"), route("/theme", "features/theme/routes/theme.ts"), ...prefix("/auth", [ - index("features/auth/routes/auth.tsx"), - route("callback", "features/auth/routes/auth.callback.tsx"), - route("create-link", "features/auth/routes/auth.create-link.tsx"), - route("login", "features/auth/routes/auth.login.tsx"), - route("logout", "features/auth/routes/auth.logout.tsx"), - route("impersonate", "features/auth/routes/auth.impersonate.tsx"), - route("impersonate/stop", "features/auth/routes/auth.impersonate.stop.tsx"), + index("features/auth/routes/auth.ts"), + route("callback", "features/auth/routes/auth.callback.ts"), + route("create-link", "features/auth/routes/auth.create-link.ts"), + route("login", "features/auth/routes/auth.login.ts"), + route("logout", "features/auth/routes/auth.logout.ts"), + route("impersonate", "features/auth/routes/auth.impersonate.ts"), + route("impersonate/stop", "features/auth/routes/auth.impersonate.stop.ts"), ]), ] satisfies RouteConfig; diff --git a/e2e/tournament.spec.ts b/e2e/tournament.spec.ts index d0b87fdd4..6f77b507e 100644 --- a/e2e/tournament.spec.ts +++ b/e2e/tournament.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"; import { ADMIN_ID } from "~/constants"; import { NZAP_TEST_ID } from "~/db/seed/constants"; import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps"; -import type { TournamentLoaderData } from "~/features/tournament/routes/to.$id"; +import type { TournamentLoaderData } from "~/features/tournament/loaders/to.$id.server"; import type { StageId } from "~/modules/in-game-lists"; import { rankedModesShort } from "~/modules/in-game-lists/modes"; import invariant from "~/utils/invariant";