diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 29b17874b..b06d1e416 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -33,6 +33,8 @@ jobs: run: npm run typecheck - name: Unit tests run: npm run test:unit + - name: Knip unused check + run: npm run knip - name: Check translations jsons run: npm run check-translation-jsons:no-write - name: Check homemade badges diff --git a/AGENTS.md b/AGENTS.md index 2d6b86eb7..73072bd9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,7 @@ - library used for E2E testing is Playwright - `page.goto` is forbidden, use the `navigate` function to do a page navigation - to submit a form you use the `submit` function +- `page.waitForTimeout` should be avoided ## Unit testing diff --git a/app/components/BuildCard.tsx b/app/components/BuildCard.tsx index 6d36aeae2..c1a176d69 100644 --- a/app/components/BuildCard.tsx +++ b/app/components/BuildCard.tsx @@ -1,6 +1,6 @@ +import { Link } from "@remix-run/react"; import clsx from "clsx"; import { useTranslation } from "react-i18next"; -import { Link } from "react-router-dom"; import type { GearType, Tables, UserWithPlusTier } from "~/db/tables"; import { useUser } from "~/features/auth/core/user"; import type { BuildWeaponWithTop500Info } from "~/features/builds/builds-types"; diff --git a/app/components/DetailsSummary.tsx b/app/components/DetailsSummary.tsx deleted file mode 100644 index 5ff7ddafb..000000000 --- a/app/components/DetailsSummary.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import clsx from "clsx"; -import type * as React from "react"; - -export function Details({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return
{children}
; -} - -export function Summary({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return {children}; -} diff --git a/app/components/FormWithConfirm.tsx b/app/components/FormWithConfirm.tsx index bbd13ae43..162935d2c 100644 --- a/app/components/FormWithConfirm.tsx +++ b/app/components/FormWithConfirm.tsx @@ -89,7 +89,6 @@ export function FormWithConfirm({ {React.cloneElement(children, { - // @ts-expect-error broke with @types/react upgrade. TODO: figure out narrower type than React.ReactNode onPress: openDialog, type: "button", })} diff --git a/app/components/WeaponSelect.tsx b/app/components/WeaponSelect.tsx index 62463b4f5..3d501f2fa 100644 --- a/app/components/WeaponSelect.tsx +++ b/app/components/WeaponSelect.tsx @@ -7,7 +7,7 @@ import { SendouSelectItemSection, } from "~/components/elements/Select"; import { Image, WeaponImage } from "~/components/Image"; -import type { AnyWeapon } from "~/features/build-analyzer"; +import type { AnyWeapon } from "~/features/build-analyzer/analyzer-types"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import { filterWeapon } from "~/modules/in-game-lists/utils"; import { diff --git a/app/components/icons/Admin.tsx b/app/components/icons/Admin.tsx deleted file mode 100644 index 0a22b8917..000000000 --- a/app/components/icons/Admin.tsx +++ /dev/null @@ -1,16 +0,0 @@ -export function AdminIcon({ className }: { className?: string }) { - return ( - - - - ); -} diff --git a/app/components/icons/ArrowsPointingIn.tsx b/app/components/icons/ArrowsPointingIn.tsx deleted file mode 100644 index e0e6c957c..000000000 --- a/app/components/icons/ArrowsPointingIn.tsx +++ /dev/null @@ -1,18 +0,0 @@ -export function ArrowsPointingInIcon({ className }: { className?: string }) { - return ( - - - - ); -} diff --git a/app/components/icons/Chat.tsx b/app/components/icons/Chat.tsx deleted file mode 100644 index 387c7ddcc..000000000 --- a/app/components/icons/Chat.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { CSSProperties } from "react"; - -export function ChatIcon({ - className, - style, -}: { - className?: string; - style?: CSSProperties; -}) { - return ( - - - - ); -} diff --git a/app/components/icons/CheckIn.tsx b/app/components/icons/CheckIn.tsx deleted file mode 100644 index ab6646926..000000000 --- a/app/components/icons/CheckIn.tsx +++ /dev/null @@ -1,13 +0,0 @@ -export function CheckInIcon({ className }: { className?: string }) { - return ( - - - - - ); -} diff --git a/app/components/icons/Success.tsx b/app/components/icons/Success.tsx deleted file mode 100644 index eb8aa7dbf..000000000 --- a/app/components/icons/Success.tsx +++ /dev/null @@ -1,16 +0,0 @@ -export function SuccessIcon({ className }: { className?: string }) { - return ( - - - - ); -} diff --git a/app/components/icons/Undo.tsx b/app/components/icons/Undo.tsx deleted file mode 100644 index 4ee8b77b5..000000000 --- a/app/components/icons/Undo.tsx +++ /dev/null @@ -1,16 +0,0 @@ -export default function UndoIcon({ className }: { className?: string }) { - return ( - - - - ); -} diff --git a/app/components/icons/Upload.tsx b/app/components/icons/Upload.tsx deleted file mode 100644 index 7fdef2942..000000000 --- a/app/components/icons/Upload.tsx +++ /dev/null @@ -1,18 +0,0 @@ -export function UploadIcon({ className }: { className?: string }) { - return ( - - - - ); -} diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index e15362a5e..39c78f593 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -21,23 +21,21 @@ import { } from "~/features/plus-voting/core"; import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server"; import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server"; -import * as QRepository from "~/features/sendouq/QRepository.server"; -import { addMember } from "~/features/sendouq/queries/addMember.server"; -import { createMatch } from "~/features/sendouq/queries/createMatch.server"; +import { SendouQ } from "~/features/sendouq/core/SendouQ.server"; +import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; import { calculateMatchSkills } from "~/features/sendouq-match/core/skills.server"; import { summarizeMaps, summarizePlayerResults, } from "~/features/sendouq-match/core/summarizer.server"; -import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server"; import { winnersArrayToWinner } from "~/features/sendouq-match/q-match-utils"; import { addMapResults } from "~/features/sendouq-match/queries/addMapResults.server"; import { addPlayerResults } from "~/features/sendouq-match/queries/addPlayerResults.server"; import { addReportedWeapons } from "~/features/sendouq-match/queries/addReportedWeapons.server"; import { addSkills } from "~/features/sendouq-match/queries/addSkills.server"; -import { findMatchById } from "~/features/sendouq-match/queries/findMatchById.server"; import { reportScore } from "~/features/sendouq-match/queries/reportScore.server"; import { setGroupAsInactive } from "~/features/sendouq-match/queries/setGroupAsInactive.server"; +import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server"; import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps"; import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server"; import { AMOUNT_OF_MAPS_IN_POOL_PER_MODE } from "~/features/sendouq-settings/q-settings-constants"; @@ -230,7 +228,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [ arts, commissionsOpen, playedMatches, - groups, + variation === "NO_SQ_GROUPS" ? undefined : groups, friendCodes, lfgPosts, variation === "NO_SCRIMS" ? undefined : scrimPosts, @@ -2088,7 +2086,7 @@ async function groups() { users.push(NZAP_TEST_ID); for (let i = 0; i < 25; i++) { - const group = await QRepository.createGroup({ + const group = await SQGroupRepository.createGroup({ status: "ACTIVE", userId: users.pop()!, }); @@ -2198,15 +2196,14 @@ async function playedMatches() { // -> create groups for (let i = 0; i < 2; i++) { const users = i === 0 ? [...groupAlphaMembers] : [...groupBravoMembers]; - const group = await QRepository.createGroup({ + const group = await SQGroupRepository.createGroup({ status: "ACTIVE", userId: users.pop()!, }); // -> add regular members of groups for (let i = 0; i < 3; i++) { - addMember({ - groupId: group.id, + await SQGroupRepository.addMember(group.id, { userId: users.pop()!, }); } @@ -2220,7 +2217,7 @@ async function playedMatches() { invariant(groupAlpha !== 0 && groupBravo !== 0, "groups not created"); - const match = createMatch({ + const match = await SQMatchRepository.create({ alphaGroupId: groupAlpha, bravoGroupId: groupBravo, mapList: randomMapList(groupAlpha, groupBravo), @@ -2258,7 +2255,9 @@ async function playedMatches() { ["ALPHA", "BRAVO", "ALPHA", "BRAVO", "BRAVO", "BRAVO"], ]) as ("ALPHA" | "BRAVO")[]; const winner = winnersArrayToWinner(winners); - const finishedMatch = findMatchById(match.id)!; + const finishedMatch = SendouQ.mapMatch( + (await SQMatchRepository.findById(match.id))!, + ); const { newSkills, differences } = calculateMatchSkills({ groupMatchId: match.id, @@ -2267,16 +2266,13 @@ async function playedMatches() { loserGroupId: winner === "ALPHA" ? groupBravo : groupAlpha, winnerGroupId: winner === "ALPHA" ? groupAlpha : groupBravo, }); + const members = [ - ...(await QMatchRepository.findGroupById({ - groupId: match.alphaGroupId, - }))!.members.map((m) => ({ + ...finishedMatch.groupAlpha.members.map((m) => ({ ...m, groupId: match.alphaGroupId, })), - ...(await QMatchRepository.findGroupById({ - groupId: match.alphaGroupId, - }))!.members.map((m) => ({ + ...finishedMatch.groupBravo.members.map((m) => ({ ...m, groupId: match.bravoGroupId, })), diff --git a/app/entry.server.tsx b/app/entry.server.tsx index 9d694c5f3..cc2a86dae 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -10,7 +10,7 @@ import cron from "node-cron"; import { renderToPipeableStream } from "react-dom/server"; import { I18nextProvider, initReactI18next } from "react-i18next"; import { config } from "~/modules/i18n/config"; // your i18n configuration file -import i18next from "~/modules/i18n/i18next.server"; +import { i18next } from "~/modules/i18n/i18next.server"; import { resources } from "./modules/i18n/resources.server"; import { daily, everyHourAt00, everyHourAt30 } from "./routines/list.server"; import { logger } from "./utils/logger"; diff --git a/app/features/api-private/constants.ts b/app/features/api-private/constants.ts index f60a96fd2..54a82da03 100644 --- a/app/features/api-private/constants.ts +++ b/app/features/api-private/constants.ts @@ -5,4 +5,5 @@ export const SEED_VARIATIONS = [ "SMALL_SOS", "NZAP_IN_TEAM", "NO_SCRIMS", + "NO_SQ_GROUPS", ] as const; diff --git a/app/features/api-private/routes/seed.ts b/app/features/api-private/routes/seed.ts index 5929273bc..3b54e64e5 100644 --- a/app/features/api-private/routes/seed.ts +++ b/app/features/api-private/routes/seed.ts @@ -3,6 +3,7 @@ import { z } from "zod/v4"; import { seed } from "~/db/seed"; import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls"; import { SEED_VARIATIONS } from "~/features/api-private/constants"; +import { refreshSendouQInstance } from "~/features/sendouq/core/SendouQ.server"; import { parseRequestPayload } from "~/utils/remix.server"; const seedSchema = z.object({ @@ -25,5 +26,7 @@ export const action: ActionFunction = async ({ request }) => { await seed(variation); + await refreshSendouQInstance(); + return null; }; diff --git a/app/features/api-public/api-public-utils.server.ts b/app/features/api-public/api-public-utils.server.ts index b3fdefd01..cb8252456 100644 --- a/app/features/api-public/api-public-utils.server.ts +++ b/app/features/api-public/api-public-utils.server.ts @@ -1,7 +1,7 @@ import { cors } from "remix-utils/cors"; import * as ApiRepository from "~/features/api/ApiRepository.server"; -export async function loadApiTokensCache() { +async function loadApiTokensCache() { const envTokens = process.env.PUBLIC_API_TOKENS?.split(",") ?? []; const dbTokens = await ApiRepository.allApiTokens(); return new Set([...envTokens, ...dbTokens]); diff --git a/app/features/api-public/routes/sendouq.active-match.$userId.ts b/app/features/api-public/routes/sendouq.active-match.$userId.ts index bf5d4dcb2..280137180 100644 --- a/app/features/api-public/routes/sendouq.active-match.$userId.ts +++ b/app/features/api-public/routes/sendouq.active-match.$userId.ts @@ -1,7 +1,7 @@ import { json, type LoaderFunctionArgs } from "@remix-run/node"; import { cors } from "remix-utils/cors"; import { z } from "zod/v4"; -import { findCurrentGroupByUserId } from "~/features/sendouq/queries/findCurrentGroupByUserId.server"; +import { SendouQ } from "~/features/sendouq/core/SendouQ.server"; import { parseParams } from "~/utils/remix.server"; import { id } from "~/utils/zod"; import { @@ -23,7 +23,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { schema: paramsSchema, }); - const current = findCurrentGroupByUserId(userId); + const current = SendouQ.findOwnGroup(userId); const result: GetUsersActiveSendouqMatchResponse = { matchId: current?.matchId ?? null, diff --git a/app/features/api-public/routes/sendouq.match.$matchId.ts b/app/features/api-public/routes/sendouq.match.$matchId.ts index 7b3e08d00..77b5477b4 100644 --- a/app/features/api-public/routes/sendouq.match.$matchId.ts +++ b/app/features/api-public/routes/sendouq.match.$matchId.ts @@ -1,9 +1,8 @@ import { json, type LoaderFunctionArgs } from "@remix-run/node"; import { cors } from "remix-utils/cors"; import { z } from "zod/v4"; -import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server"; -import i18next from "~/modules/i18n/i18next.server"; -import invariant from "~/utils/invariant"; +import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server"; +import { i18next } from "~/modules/i18n/i18next.server"; import { notFoundIfFalsy, parseParams } from "~/utils/remix.server"; import { id } from "~/utils/zod"; import { @@ -25,19 +24,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { schema: paramsSchema, }); - const match = notFoundIfFalsy(await QMatchRepository.findById(matchId)); - - const [groupAlpha, groupBravo] = await Promise.all([ - QMatchRepository.findGroupById({ - groupId: match.alphaGroupId, - }), - QMatchRepository.findGroupById({ - groupId: match.bravoGroupId, - }), - ]); - - invariant(groupAlpha, "Group alpha not found"); - invariant(groupBravo, "Group bravo not found"); + const match = notFoundIfFalsy(await SQMatchRepository.findById(matchId)); const t = await i18next.getFixedT("en", ["game-misc"]); @@ -56,7 +43,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { (acc, cur) => { if (!cur.winnerGroupId) return acc; - if (cur.winnerGroupId === match.alphaGroupId) { + if (cur.winnerGroupId === match.groupAlpha.id) { return [acc[0] + 1, acc[1]]; } @@ -83,14 +70,14 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { })), teamAlpha: { score: score[0], - players: groupAlpha.members.map((member) => ({ + players: match.groupAlpha.members.map((member) => ({ userId: member.id, rank: userIdToRank(member.id), })), }, teamBravo: { score: score[1], - players: groupBravo.members.map((member) => ({ + players: match.groupBravo.members.map((member) => ({ userId: member.id, rank: userIdToRank(member.id), })), diff --git a/app/features/api-public/routes/tournament-match.$id.ts b/app/features/api-public/routes/tournament-match.$id.ts index d3b5ffabd..27fa9a019 100644 --- a/app/features/api-public/routes/tournament-match.$id.ts +++ b/app/features/api-public/routes/tournament-match.$id.ts @@ -7,7 +7,7 @@ import * as TournamentRepository from "~/features/tournament/TournamentRepositor import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; import { resolveMapList } from "~/features/tournament-bracket/core/mapList.server"; import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server"; -import i18next from "~/modules/i18n/i18next.server"; +import { i18next } from "~/modules/i18n/i18next.server"; import { logger } from "~/utils/logger"; import { notFoundIfFalsy, parseParams } from "~/utils/remix.server"; import { id } from "~/utils/zod"; diff --git a/app/features/api-public/routes/tournament.$id.teams.ts b/app/features/api-public/routes/tournament.$id.teams.ts index fdfb25529..a359f43f2 100644 --- a/app/features/api-public/routes/tournament.$id.teams.ts +++ b/app/features/api-public/routes/tournament.$id.teams.ts @@ -5,7 +5,7 @@ import { z } from "zod/v4"; import { db } from "~/db/sql"; import { ordinalToSp } from "~/features/mmr/mmr-utils"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; -import i18next from "~/modules/i18n/i18next.server"; +import { i18next } from "~/modules/i18n/i18next.server"; import { nullifyingAvg } from "~/utils/arrays"; import { databaseTimestampToDate } from "~/utils/dates"; import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server"; diff --git a/app/features/art/art-schemas.server.ts b/app/features/art/art-schemas.server.ts index f988be7e0..d53352cff 100644 --- a/app/features/art/art-schemas.server.ts +++ b/app/features/art/art-schemas.server.ts @@ -43,12 +43,12 @@ export const editArtSchema = z.object({ isShowcase: z.preprocess(checkboxValueToDbBoolean, dbBoolean), }); -export const deleteArtSchema = z.object({ +const deleteArtSchema = z.object({ _action: _action("DELETE_ART"), id, }); -export const unlinkArtSchema = z.object({ +const unlinkArtSchema = z.object({ _action: _action("UNLINK_ART"), id, }); diff --git a/app/features/art/routes/art.new.tsx b/app/features/art/routes/art.new.tsx index 825f7b753..3acfd7a14 100644 --- a/app/features/art/routes/art.new.tsx +++ b/app/features/art/routes/art.new.tsx @@ -1,10 +1,9 @@ import type { MetaFunction } from "@remix-run/node"; -import { Form, useLoaderData } from "@remix-run/react"; +import { Form, useFetcher, useLoaderData } from "@remix-run/react"; import Compressor from "compressorjs"; import { nanoid } from "nanoid"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import { useFetcher } from "react-router-dom"; import { Alert } from "~/components/Alert"; import { SendouButton } from "~/components/elements/Button"; import { SendouSwitch } from "~/components/elements/Switch"; diff --git a/app/features/associations/AssociationRepository.server.ts b/app/features/associations/AssociationRepository.server.ts index 752f057d3..1252b472c 100644 --- a/app/features/associations/AssociationRepository.server.ts +++ b/app/features/associations/AssociationRepository.server.ts @@ -1,6 +1,6 @@ import { jsonArrayFrom } from "kysely/helpers/sqlite"; import { db } from "~/db/sql"; -import type { TablesInsertable, TablesUpdatable } from "~/db/tables"; +import type { TablesInsertable } from "~/db/tables"; import type { AssociationVirtualIdentifier } from "~/features/associations/associations-constants"; import { shortNanoid } from "~/utils/id"; import { COMMON_USER_FIELDS } from "~/utils/kysely.server"; @@ -140,17 +140,6 @@ export function insert({ userId, ...associationArgs }: InsertArgs) { }); } -export function update( - associationId: number, - args: Partial, -) { - return db - .updateTable("Association") - .set(args) - .where("id", "=", associationId) - .execute(); -} - export function refreshInviteCode(associationId: number) { return db .updateTable("Association") diff --git a/app/features/build-analyzer/analyzer-types.ts b/app/features/build-analyzer/analyzer-types.ts index 8d3b54e98..3938bb08f 100644 --- a/app/features/build-analyzer/analyzer-types.ts +++ b/app/features/build-analyzer/analyzer-types.ts @@ -7,7 +7,6 @@ import type { } from "~/modules/in-game-lists/types"; import type { DAMAGE_TYPE } from "./analyzer-constants"; import type { SPECIAL_EFFECTS } from "./core/specialEffects"; -import type { abilityValues } from "./core/utils"; import type { weaponParams } from "./core/weapon-params"; type Overwrites = Record< @@ -288,8 +287,6 @@ export interface AnalyzedBuild { export type SpecialEffectType = (typeof SPECIAL_EFFECTS)[number]["type"]; -export type AbilityValuesKeys = keyof typeof abilityValues; - export type AnyWeapon = | { type: "MAIN"; id: MainWeaponId } | { type: "SUB"; id: SubWeaponId } diff --git a/app/features/build-analyzer/index.ts b/app/features/build-analyzer/index.ts deleted file mode 100644 index 0d1d6c758..000000000 --- a/app/features/build-analyzer/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -export { DAMAGE_TYPE, damageTypeToWeaponType } from "./analyzer-constants"; -export type { - AbilityPoints, - AnalyzedBuild, - AnyWeapon, - DamageType, - SpecialWeaponParams, - SubWeaponParams, -} from "./analyzer-types"; -export { - buildStats, - specialDeviceHp, - specialFieldHp, - subStats, -} from "./core/stats"; -export { - buildToAbilityPoints, - hpDivided, - possibleApValues, - serializeBuild, - validatedAnyWeaponFromSearchParams, - validatedBuildFromSearchParams, - validatedWeaponIdFromSearchParams, -} from "./core/utils"; diff --git a/app/features/builds/BuildRepository.server.ts b/app/features/builds/BuildRepository.server.ts index 3b24b9fc5..961e5965c 100644 --- a/app/features/builds/BuildRepository.server.ts +++ b/app/features/builds/BuildRepository.server.ts @@ -89,23 +89,6 @@ function dbAbilitiesToArrayOfArrays( ]; } -export async function countByUserId({ - userId, - showPrivate, -}: { - userId: number; - showPrivate: boolean; -}) { - return ( - await db - .selectFrom("Build") - .select(({ fn }) => fn.countAll().as("count")) - .where("ownerId", "=", userId) - .$if(!showPrivate, (qb) => qb.where("Build.private", "=", 0)) - .executeTakeFirstOrThrow() - ).count; -} - interface CreateArgs { ownerId: TablesInsertable["Build"]["ownerId"]; title: TablesInsertable["Build"]["title"]; @@ -119,7 +102,7 @@ interface CreateArgs { private: TablesInsertable["Build"]["private"]; } -export async function createInTrx({ +async function createInTrx({ args, trx, }: { diff --git a/app/features/builds/components/FilterSection.tsx b/app/features/builds/components/FilterSection.tsx index 8a5838f16..24ef425f1 100644 --- a/app/features/builds/components/FilterSection.tsx +++ b/app/features/builds/components/FilterSection.tsx @@ -4,7 +4,7 @@ import { Ability } from "~/components/Ability"; import { SendouButton } from "~/components/elements/Button"; import { ModeImage } from "~/components/Image"; import { CrossIcon } from "~/components/icons/Cross"; -import { possibleApValues } from "~/features/build-analyzer"; +import { possibleApValues } from "~/features/build-analyzer/core/utils"; import { useTimeFormat } from "~/hooks/useTimeFormat"; import { abilities } from "~/modules/in-game-lists/abilities"; import { modesShort } from "~/modules/in-game-lists/modes"; diff --git a/app/features/builds/core/filter.server.ts b/app/features/builds/core/filter.server.ts index 1504477d2..d1c0e5aa2 100644 --- a/app/features/builds/core/filter.server.ts +++ b/app/features/builds/core/filter.server.ts @@ -1,5 +1,5 @@ import type { Tables } from "~/db/tables"; -import { buildToAbilityPoints } from "~/features/build-analyzer"; +import { buildToAbilityPoints } from "~/features/build-analyzer/core/utils"; import type { BuildAbilitiesTuple, ModeShort, diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index 7ed059020..c387bc69e 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -25,11 +25,9 @@ import { } from "~/utils/dates"; import invariant from "~/utils/invariant"; import { - COMMON_USER_FIELDS, concatUserSubmittedImagePrefix, tournamentLogoWithDefault, } from "~/utils/kysely.server"; -import type { Unwrapped } from "~/utils/types"; import { calendarEventPage, tournamentPage } from "~/utils/urls"; import { modesIncluded, @@ -278,76 +276,6 @@ function findAllBetweenTwoTimestampsMapped( return dates; } -export type ForShowcase = Unwrapped; - -export function forShowcase() { - return db - .selectFrom("Tournament") - .innerJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId") - .innerJoin( - "CalendarEventDate", - "CalendarEvent.id", - "CalendarEventDate.eventId", - ) - .select((eb) => [ - "Tournament.id", - "Tournament.settings", - "CalendarEvent.authorId", - "CalendarEvent.name", - "CalendarEventDate.startTime", - withTeamsCount(eb).as("teamsCount"), - tournamentLogoWithDefault(eb).as("logoUrl"), - withOrganization(eb).as("organization"), - jsonArrayFrom( - eb - .selectFrom("TournamentResult") - .innerJoin("User", "TournamentResult.userId", "User.id") - .innerJoin( - "TournamentTeam", - "TournamentResult.tournamentTeamId", - "TournamentTeam.id", - ) - .leftJoin("AllTeam", "TournamentTeam.teamId", "AllTeam.id") - .leftJoin( - "UserSubmittedImage as TeamAvatar", - "AllTeam.avatarImgId", - "TeamAvatar.id", - ) - .leftJoin( - "UserSubmittedImage as TournamentTeamAvatar", - "TournamentTeam.avatarImgId", - "TournamentTeamAvatar.id", - ) - .whereRef("TournamentResult.tournamentId", "=", "Tournament.id") - .where("TournamentResult.placement", "=", 1) - .select((eb) => [ - ...COMMON_USER_FIELDS, - "User.country", - "TournamentTeam.name as teamName", - concatUserSubmittedImagePrefix(eb.ref("TeamAvatar.url")).as( - "teamLogoUrl", - ), - concatUserSubmittedImagePrefix( - eb.ref("TournamentTeamAvatar.url"), - ).as("pickupAvatarUrl"), - ]), - ).as("firstPlacers"), - ]) - .where("CalendarEvent.hidden", "=", 0) - .where("CalendarEventDate.startTime", ">", databaseTimestampWeekAgo()) - .orderBy("CalendarEventDate.startTime", "asc") - .$narrowType<{ teamsCount: NotNull }>() - .execute(); -} - -function databaseTimestampWeekAgo() { - const now = new Date(); - - now.setDate(now.getDate() - 7); - - return dateToDatabaseTimestamp(now); -} - export async function findById( id: number, { diff --git a/app/features/calendar/components/TagsFormField.tsx b/app/features/calendar/components/TagsFormField.tsx index fcbe07a7c..6045bf388 100644 --- a/app/features/calendar/components/TagsFormField.tsx +++ b/app/features/calendar/components/TagsFormField.tsx @@ -57,7 +57,7 @@ export function TagsFormField({ ); } -export const SelectableTags = React.forwardRef< +const SelectableTags = React.forwardRef< HTMLDivElement, { selectedTags: Array; diff --git a/app/features/front-page/core/ShowcaseTournaments.server.ts b/app/features/front-page/core/ShowcaseTournaments.server.ts index 69ae0c62e..84c582953 100644 --- a/app/features/front-page/core/ShowcaseTournaments.server.ts +++ b/app/features/front-page/core/ShowcaseTournaments.server.ts @@ -157,7 +157,7 @@ async function cachedParticipationInfo( return participation.get(userId) ?? emptyParticipationInfo(); } -export const SHOWCASE_TOURNAMENTS_CACHE_KEY = "front-tournaments-list"; +const SHOWCASE_TOURNAMENTS_CACHE_KEY = "front-tournaments-list"; export const clearCachedTournaments = () => cache.delete(SHOWCASE_TOURNAMENTS_CACHE_KEY); diff --git a/app/features/leaderboards/core/leaderboards.server.ts b/app/features/leaderboards/core/leaderboards.server.ts index 98f5e1dda..e160e76c5 100644 --- a/app/features/leaderboards/core/leaderboards.server.ts +++ b/app/features/leaderboards/core/leaderboards.server.ts @@ -136,13 +136,6 @@ export function filterByWeaponCategory< ); } -export function addPlacementRank(entries: T[]) { - return entries.map((entry, index) => ({ - ...entry, - placementRank: index + 1, - })); -} - export function ownEntryPeek({ leaderboard, userId, diff --git a/app/features/leaderboards/leaderboards-utils.ts b/app/features/leaderboards/leaderboards-utils.ts index f52f12238..c24ab4abe 100644 --- a/app/features/leaderboards/leaderboards-utils.ts +++ b/app/features/leaderboards/leaderboards-utils.ts @@ -4,22 +4,6 @@ export function seasonHasTopTen(season: number) { return !!playerData[season]; } -export function playerTopTenData({ - season, - userId, -}: { - season: number; - userId: number; -}) { - for (const player of playerData[season] ?? []) { - if (player.id === userId) { - return player; - } - } - - return null; -} - export function playerTopTenPlacement({ season, userId, diff --git a/app/features/mmr/mmr-constants.ts b/app/features/mmr/mmr-constants.ts index a653c9416..c28683074 100644 --- a/app/features/mmr/mmr-constants.ts +++ b/app/features/mmr/mmr-constants.ts @@ -58,23 +58,5 @@ export const TIERS_BEFORE_LEVIATHAN = [ export type TierName = (typeof TIERS)[number]["name"]; -// won 4 in row vs. equally skilled opponents, about 1200SP -export const DEFAULT_SKILL_HIGH = { - mu: 34.970668845350744, - sigma: 7.362186212527989, -} as const; - -// lost 4 in row vs. equally skilled opponents, about 900SP -export const DEFAULT_SKILL_LOW = { - mu: 15.02933115464926, - sigma: 7.362186212527989, -} as const; - -// won 2, lost 2 vs. equally skilled opponents, about 1050SP -export const DEFAULT_SKILL_MID = { - mu: 25.189621801205735, - sigma: 7.362186212527989, -} as const; - export const USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN = 200; export const TEAM_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN = 100; diff --git a/app/features/mmr/mmr-utils.ts b/app/features/mmr/mmr-utils.ts index 86b4b4796..1678cd07d 100644 --- a/app/features/mmr/mmr-utils.ts +++ b/app/features/mmr/mmr-utils.ts @@ -10,10 +10,6 @@ export function ordinalToSp(ordinal: number) { return toTwoDecimals(ordinal * 15 + 1000); } -export function spToOrdinal(sp: number) { - return (sp - 1000) / 15; -} - export function ordinalToRoundedSp(ordinal: number) { return Math.round(ordinalToSp(ordinal)); } diff --git a/app/features/notifications/core/notify.server.ts b/app/features/notifications/core/notify.server.ts index b3899a1f2..d77204af3 100644 --- a/app/features/notifications/core/notify.server.ts +++ b/app/features/notifications/core/notify.server.ts @@ -3,7 +3,7 @@ import pLimit from "p-limit"; import { WebPushError } from "web-push"; import { IS_E2E_TEST_RUN } from "~/utils/e2e"; import type { NotificationSubscription } from "../../../db/tables"; -import i18next from "../../../modules/i18n/i18next.server"; +import { i18next } from "../../../modules/i18n/i18next.server"; import { logger } from "../../../utils/logger"; import * as NotificationRepository from "../NotificationRepository.server"; import type { Notification } from "../notifications-types"; diff --git a/app/features/object-damage-calculator/calculator-constants.ts b/app/features/object-damage-calculator/calculator-constants.ts index bc962e755..49242d8c9 100644 --- a/app/features/object-damage-calculator/calculator-constants.ts +++ b/app/features/object-damage-calculator/calculator-constants.ts @@ -1,5 +1,8 @@ +import type { + AnyWeapon, + DamageType, +} from "~/features/build-analyzer/analyzer-types"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; -import type { AnyWeapon, DamageType } from "../build-analyzer"; import type { CombineWith } from "./calculator-types"; import type objectDamages from "./core/object-dmg.json"; diff --git a/app/features/object-damage-calculator/calculator-hooks.ts b/app/features/object-damage-calculator/calculator-hooks.ts index 313a9df4d..07a70b8fa 100644 --- a/app/features/object-damage-calculator/calculator-hooks.ts +++ b/app/features/object-damage-calculator/calculator-hooks.ts @@ -1,15 +1,17 @@ import { useSearchParams } from "@remix-run/react"; -import { - type AnalyzedBuild, - buildStats, - type DAMAGE_TYPE, - type DamageType, - possibleApValues, - validatedAnyWeaponFromSearchParams, -} from "~/features/build-analyzer"; import { exampleMainWeaponIdWithSpecialWeaponId } from "~/modules/in-game-lists/weapon-ids"; import { assertType } from "~/utils/types"; -import type { AnyWeapon } from "../build-analyzer/analyzer-types"; +import type { DAMAGE_TYPE } from "../build-analyzer/analyzer-constants"; +import type { + AnalyzedBuild, + AnyWeapon, + DamageType, +} from "../build-analyzer/analyzer-types"; +import { buildStats } from "../build-analyzer/core/stats"; +import { + possibleApValues, + validatedAnyWeaponFromSearchParams, +} from "../build-analyzer/core/utils"; import { calculateDamage, resolveAllUniqueDamageTypes, @@ -99,7 +101,7 @@ function validatedMultiShotFromSearchParams(searchParams: URLSearchParams) { return searchParams.get(MULTI_SHOT_SP_KEY) !== "false"; } -export const damageTypePriorityList = [ +const damageTypePriorityList = [ "TURRET_MAX", "TURRET_MIN", "DIRECT_MAX", diff --git a/app/features/object-damage-calculator/calculator-types.ts b/app/features/object-damage-calculator/calculator-types.ts index 1afd0dc0f..8f993fe61 100644 --- a/app/features/object-damage-calculator/calculator-types.ts +++ b/app/features/object-damage-calculator/calculator-types.ts @@ -1,4 +1,4 @@ -import type { DamageType } from "../build-analyzer"; +import type { DamageType } from "~/features/build-analyzer/analyzer-types"; import type { DAMAGE_RECEIVERS } from "./calculator-constants"; export type DamageReceiver = (typeof DAMAGE_RECEIVERS)[number]; diff --git a/app/features/object-damage-calculator/core/objectDamage.test.ts b/app/features/object-damage-calculator/core/objectDamage.test.ts index 2162d36b8..e340ec58c 100644 --- a/app/features/object-damage-calculator/core/objectDamage.test.ts +++ b/app/features/object-damage-calculator/core/objectDamage.test.ts @@ -3,8 +3,8 @@ import type { AbilityPoints, AnalyzedBuild, DamageType, -} from "~/features/build-analyzer"; -import { buildStats } from "~/features/build-analyzer"; +} from "~/features/build-analyzer/analyzer-types"; +import { buildStats } from "~/features/build-analyzer/core/stats"; import type { MainWeaponId, SpecialWeaponId, diff --git a/app/features/object-damage-calculator/core/objectDamage.ts b/app/features/object-damage-calculator/core/objectDamage.ts index 91edfd417..441619bb3 100644 --- a/app/features/object-damage-calculator/core/objectDamage.ts +++ b/app/features/object-damage-calculator/core/objectDamage.ts @@ -3,9 +3,9 @@ import type { AbilityPoints, AnalyzedBuild, AnyWeapon, + Damage, DamageType, -} from "~/features/build-analyzer"; -import type { Damage } from "~/features/build-analyzer/analyzer-types"; +} from "~/features/build-analyzer/analyzer-types"; import type { MainWeaponId, SpecialWeaponId, @@ -23,7 +23,7 @@ import type { CombineWith, DamageReceiver } from "../calculator-types"; import objectDamages from "./object-dmg.json"; import { objectHitPoints } from "./objectHitPoints"; -export function damageTypeToMultipliers({ +function damageTypeToMultipliers({ type, weapon, }: { @@ -104,7 +104,7 @@ function resolveRelevantKey({ ); } -export function multipliersToRecordWithFallbacks( +function multipliersToRecordWithFallbacks( multipliers: ReturnType, ) { return Object.fromEntries( diff --git a/app/features/object-damage-calculator/core/objectHitPoints.ts b/app/features/object-damage-calculator/core/objectHitPoints.ts index 733570e09..c6cceeb65 100644 --- a/app/features/object-damage-calculator/core/objectHitPoints.ts +++ b/app/features/object-damage-calculator/core/objectHitPoints.ts @@ -1,12 +1,14 @@ +import type { + AbilityPoints, + SpecialWeaponParams, + SubWeaponParams, +} from "~/features/build-analyzer/analyzer-types"; import { - type AbilityPoints, - hpDivided, - type SpecialWeaponParams, - type SubWeaponParams, specialDeviceHp, specialFieldHp, subStats, -} from "~/features/build-analyzer"; +} from "~/features/build-analyzer/core/stats"; +import { hpDivided } from "~/features/build-analyzer/core/utils"; import { weaponParams } from "~/features/build-analyzer/core/weapon-params"; import { BIG_BUBBLER_ID, diff --git a/app/features/object-damage-calculator/routes/object-damage-calculator.tsx b/app/features/object-damage-calculator/routes/object-damage-calculator.tsx index ac002311a..4cbaaf448 100644 --- a/app/features/object-damage-calculator/routes/object-damage-calculator.tsx +++ b/app/features/object-damage-calculator/routes/object-damage-calculator.tsx @@ -6,8 +6,6 @@ import { Ability } from "~/components/Ability"; import { Image, WeaponImage } from "~/components/Image"; import { Label } from "~/components/Label"; import { Main } from "~/components/Main"; -import type { DamageType } from "~/features/build-analyzer"; -import { possibleApValues } from "~/features/build-analyzer"; import { BIG_BUBBLER_ID, BOOYAH_BOMB_ID, @@ -38,6 +36,8 @@ import "../calculator.css"; import type { MetaFunction } from "@remix-run/node"; import { SendouSwitch } from "~/components/elements/Switch"; import { WeaponSelect } from "~/components/WeaponSelect"; +import type { DamageType } from "~/features/build-analyzer/analyzer-types"; +import { possibleApValues } from "~/features/build-analyzer/core/utils"; import { roundToNDecimalPlaces } from "~/utils/number"; import { metaTags } from "~/utils/remix"; diff --git a/app/features/plus-voting/plus-voting-schemas.ts b/app/features/plus-voting/plus-voting-schemas.ts index 5aebd0dbd..7fc358452 100644 --- a/app/features/plus-voting/plus-voting-schemas.ts +++ b/app/features/plus-voting/plus-voting-schemas.ts @@ -4,7 +4,7 @@ import { assertType } from "~/utils/types"; import { safeJSONParse } from "~/utils/zod"; import { PLUS_DOWNVOTE, PLUS_UPVOTE } from "./plus-voting-constants"; -export const voteSchema = z.object({ +const voteSchema = z.object({ votedId: z.number(), score: z.number().refine((val) => [PLUS_DOWNVOTE, PLUS_UPVOTE].includes(val)), }); diff --git a/app/features/scrims/actions/scrims.new.server.ts b/app/features/scrims/actions/scrims.new.server.ts index b52ad4432..4b5114799 100644 --- a/app/features/scrims/actions/scrims.new.server.ts +++ b/app/features/scrims/actions/scrims.new.server.ts @@ -15,7 +15,7 @@ import { } from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; import { scrimsPage } from "~/utils/urls"; -import * as QRepository from "../../sendouq/QRepository.server"; +import * as SQGroupRepository from "../../sendouq/SQGroupRepository.server"; import * as TeamRepository from "../../team/TeamRepository.server"; import * as ScrimPostRepository from "../ScrimPostRepository.server"; import { SCRIM } from "../scrims-constants"; @@ -161,7 +161,7 @@ async function validatePickup(userIds: number[], authorId: number) { async function validatePickupTrust(userIds: number[], authorId: number) { const unconsentingUsers: string[] = []; - const trustedBy = await QRepository.usersThatTrusted(authorId); + const trustedBy = await SQGroupRepository.usersThatTrusted(authorId); for (const userId of userIds) { const user = await UserRepository.findLeanById(userId); diff --git a/app/features/scrims/scrims-schemas.ts b/app/features/scrims/scrims-schemas.ts index 4c6e4bf97..1596a81ec 100644 --- a/app/features/scrims/scrims-schemas.ts +++ b/app/features/scrims/scrims-schemas.ts @@ -13,7 +13,7 @@ import { import { associationIdentifierSchema } from "../associations/associations-schemas"; import { LUTI_DIVS, SCRIM } from "./scrims-constants"; -export const deletePostSchema = z.object({ +const deletePostSchema = z.object({ _action: _action("DELETE_POST"), scrimPostId: id, }); @@ -47,12 +47,12 @@ export const newRequestSchema = z.object({ at: z.preprocess(date, z.date()).nullish(), }); -export const acceptRequestSchema = z.object({ +const acceptRequestSchema = z.object({ _action: _action("ACCEPT_REQUEST"), scrimPostRequestId: id, }); -export const cancelRequestSchema = z.object({ +const cancelRequestSchema = z.object({ _action: _action("CANCEL_REQUEST"), scrimPostRequestId: id, }); @@ -109,7 +109,7 @@ export const scrimsFiltersSearchParamsObject = z.object({ .catch({ weekdayTimes: null, weekendTimes: null, divs: null }), }); -export const persistScrimFiltersSchema = z.object({ +const persistScrimFiltersSchema = z.object({ _action: _action("PERSIST_SCRIM_FILTERS"), filters: scrimsFiltersSchema, }); diff --git a/app/features/sendouq-match/QMatchRepository.server.ts b/app/features/sendouq-match/SQMatchRepository.server.ts similarity index 63% rename from app/features/sendouq-match/QMatchRepository.server.ts rename to app/features/sendouq-match/SQMatchRepository.server.ts index c6dc1ddd9..5a8836cd7 100644 --- a/app/features/sendouq-match/QMatchRepository.server.ts +++ b/app/features/sendouq-match/SQMatchRepository.server.ts @@ -1,18 +1,15 @@ import { add } from "date-fns"; -import type { ExpressionBuilder } from "kysely"; +import type { ExpressionBuilder, NotNull, Transaction } from "kysely"; import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite"; import * as R from "remeda"; import { db } from "~/db/sql"; -import type { - DB, - ParsedMemento, - QWeaponPool, - Tables, - UserSkillDifference, -} from "~/db/tables"; +import type { DB, ParsedMemento } from "~/db/tables"; import * as Seasons from "~/features/mmr/core/Seasons"; +import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator/types"; import { mostPopularArrayElement } from "~/utils/arrays"; import { dateToDatabaseTimestamp } from "~/utils/dates"; +import { shortNanoid } from "~/utils/id"; +import invariant from "~/utils/invariant"; import { COMMON_USER_FIELDS, concatUserSubmittedImagePrefix, @@ -20,15 +17,14 @@ import { userChatNameColor, } from "~/utils/kysely.server"; import type { Unpacked } from "~/utils/types"; +import { FULL_GROUP_SIZE } from "../sendouq/q-constants"; import { MATCHES_PER_SEASONS_PAGE } from "../user-page/user-page-constants"; -export function findById(id: number) { - return db +export async function findById(id: number) { + const result = await db .selectFrom("GroupMatch") .select(({ exists, selectFrom, eb }) => [ "GroupMatch.id", - "GroupMatch.alphaGroupId", - "GroupMatch.bravoGroupId", "GroupMatch.createdAt", "GroupMatch.reportedAt", "GroupMatch.reportedByUserId", @@ -52,146 +48,81 @@ export function findById(id: number) { .where("GroupMatchMap.matchId", "=", id) .orderBy("GroupMatchMap.index", "asc"), ).as("mapList"), + groupWithTeamAndMembers(eb, "GroupMatch.alphaGroupId").as("groupAlpha"), + groupWithTeamAndMembers(eb, "GroupMatch.bravoGroupId").as("groupBravo"), ]) .where("GroupMatch.id", "=", id) - .executeTakeFirst(); -} - -export interface GroupForMatch { - id: Tables["Group"]["id"]; - chatCode: Tables["Group"]["chatCode"]; - tier?: ParsedMemento["groups"][number]["tier"]; - skillDifference?: ParsedMemento["groups"][number]["skillDifference"]; - team?: { - name: string; - avatarUrl: string | null; - customUrl: string; - }; - members: Array<{ - id: Tables["GroupMember"]["userId"]; - discordId: Tables["User"]["discordId"]; - username: Tables["User"]["username"]; - discordAvatar: Tables["User"]["discordAvatar"]; - role: Tables["GroupMember"]["role"]; - customUrl: Tables["User"]["customUrl"]; - inGameName: Tables["User"]["inGameName"]; - weapons: Array; - chatNameColor: string | null; - vc: Tables["User"]["vc"]; - languages: string[]; - skillDifference?: UserSkillDifference; - friendCode?: string; - privateNote: Pick< - Tables["PrivateUserNote"], - "sentiment" | "text" | "updatedAt" - > | null; - }>; -} - -export async function findGroupById({ - loggedInUserId, - groupId, -}: { - groupId: number; - loggedInUserId?: number; -}) { - const row = await db - .selectFrom("Group") - .leftJoin("GroupMatch", (join) => - join.on((eb) => - eb.or([ - eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")), - eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")), - ]), - ), - ) - .select(({ eb }) => [ - "Group.id", - "Group.chatCode", - "GroupMatch.memento", - jsonObjectFrom( - eb - .selectFrom("AllTeam") - .leftJoin( - "UserSubmittedImage", - "AllTeam.avatarImgId", - "UserSubmittedImage.id", - ) - .select((eb) => [ - "AllTeam.name", - "AllTeam.customUrl", - concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as( - "avatarUrl", - ), - ]) - .where("AllTeam.id", "=", eb.ref("Group.teamId")), - ).as("team"), - jsonArrayFrom( - eb - .selectFrom("GroupMember") - .innerJoin("User", "User.id", "GroupMember.userId") - .select((arrayEb) => [ - ...COMMON_USER_FIELDS, - "GroupMember.role", - "User.inGameName", - "User.vc", - "User.languages", - "User.qWeaponPool as weapons", - arrayEb - .selectFrom("UserFriendCode") - .select("UserFriendCode.friendCode") - .whereRef("UserFriendCode.userId", "=", "User.id") - .orderBy("UserFriendCode.createdAt", "desc") - .limit(1) - .as("friendCode"), - jsonObjectFrom( - eb - .selectFrom("PrivateUserNote") - .select([ - "PrivateUserNote.sentiment", - "PrivateUserNote.text", - "PrivateUserNote.updatedAt", - ]) - .where("authorId", "=", loggedInUserId ?? -1) - .where("targetId", "=", arrayEb.ref("User.id")), - ).as("privateNote"), - userChatNameColor, - ]) - .where("GroupMember.groupId", "=", groupId) - .orderBy("GroupMember.userId", "asc"), - ).as("members"), - ]) - .where("Group.id", "=", groupId) + .$narrowType<{ + groupAlpha: NotNull; + groupBravo: NotNull; + }>() .executeTakeFirst(); - if (!row) return null; + if (!result) return null; - return { - id: row.id, - chatCode: row.chatCode, - tier: row.memento?.groups[row.id]?.tier, - skillDifference: row.memento?.groups[row.id]?.skillDifference, - team: row.team, - members: row.members.map((m) => ({ - ...m, - languages: m.languages ? m.languages.split(",") : [], - plusTier: row.memento?.users[m.id]?.plusTier, - skill: row.memento?.users[m.id]?.skill, - skillDifference: row.memento?.users[m.id]?.skillDifference, - })), - } as GroupForMatch; + invariant(result.groupAlpha, `Group alpha not found for match ${id}`); + invariant(result.groupBravo, `Group bravo not found for match ${id}`); + + return result; } -export function groupMembersNoScreenSettings(groups: GroupForMatch[]) { - return db - .selectFrom("User") - .select("User.noScreen") - .where( - "User.id", - "in", - groups.flatMap((group) => group.members.map((member) => member.id)), - ) - .execute(); +function groupWithTeamAndMembers( + eb: ExpressionBuilder, + groupIdRef: "GroupMatch.alphaGroupId" | "GroupMatch.bravoGroupId", +) { + return jsonObjectFrom( + eb + .selectFrom("Group") + .select(({ eb }) => [ + "Group.id", + "Group.chatCode", + jsonObjectFrom( + eb + .selectFrom("AllTeam") + .leftJoin( + "UserSubmittedImage", + "AllTeam.avatarImgId", + "UserSubmittedImage.id", + ) + .select((eb) => [ + "AllTeam.name", + "AllTeam.customUrl", + concatUserSubmittedImagePrefix( + eb.ref("UserSubmittedImage.url"), + ).as("avatarUrl"), + ]) + .where("AllTeam.id", "=", eb.ref("Group.teamId")), + ).as("team"), + jsonArrayFrom( + eb + .selectFrom("GroupMember") + .innerJoin("User", "User.id", "GroupMember.userId") + .leftJoin("PlusTier", "User.id", "PlusTier.userId") + .select((arrayEb) => [ + ...COMMON_USER_FIELDS, + "GroupMember.role", + "User.inGameName", + "User.vc", + "User.languages", + "User.noScreen", + "User.qWeaponPool as weapons", + "User.mapModePreferences", + "PlusTier.tier as plusTier", + arrayEb + .selectFrom("UserFriendCode") + .select("UserFriendCode.friendCode") + .whereRef("UserFriendCode.userId", "=", "User.id") + .orderBy("UserFriendCode.createdAt", "desc") + .limit(1) + .as("friendCode"), + userChatNameColor, + ]) + .whereRef("GroupMember.groupId", "=", groupIdRef) + .orderBy("GroupMember.userId", "asc"), + ).as("members"), + ]) + .where("Group.id", "=", eb.ref(groupIdRef)), + ); } /** @@ -440,3 +371,118 @@ export async function seasonCanceledMatchesByUserId({ .orderBy("GroupMatch.createdAt", "desc") .execute(); } + +export function create({ + alphaGroupId, + bravoGroupId, + mapList, + memento, +}: { + alphaGroupId: number; + bravoGroupId: number; + mapList: TournamentMapListMap[]; + memento: ParsedMemento; +}) { + return db.transaction().execute(async (trx) => { + const match = await trx + .insertInto("GroupMatch") + .values({ + alphaGroupId, + bravoGroupId, + chatCode: shortNanoid(), + memento: JSON.stringify(memento), + }) + .returningAll() + .executeTakeFirstOrThrow(); + + await trx + .insertInto("GroupMatchMap") + .values( + mapList.map((map, i) => ({ + matchId: match.id, + index: i, + mode: map.mode, + stageId: map.stageId, + source: String(map.source), + })), + ) + .execute(); + + await syncGroupTeamId(alphaGroupId, trx); + await syncGroupTeamId(bravoGroupId, trx); + + await validateCreatedMatch(trx, alphaGroupId, bravoGroupId); + + return match; + }); +} + +async function syncGroupTeamId(groupId: number, trx: Transaction) { + const members = await trx + .selectFrom("GroupMember") + .leftJoin( + "TeamMemberWithSecondary", + "TeamMemberWithSecondary.userId", + "GroupMember.userId", + ) + .select(["TeamMemberWithSecondary.teamId"]) + .where("GroupMember.groupId", "=", groupId) + .execute(); + + const teamIds = members.map((m) => m.teamId).filter((id) => id !== null); + + const counts = new Map(); + + for (const teamId of teamIds) { + const newCount = (counts.get(teamId) ?? 0) + 1; + if (newCount === 4) { + await trx + .updateTable("Group") + .set({ teamId }) + .where("id", "=", groupId) + .execute(); + return; + } + + counts.set(teamId, newCount); + } + + await trx + .updateTable("Group") + .set({ teamId: null }) + .where("id", "=", groupId) + .execute(); +} + +async function validateCreatedMatch( + trx: Transaction, + alphaGroupId: number, + bravoGroupId: number, +) { + for (const groupId of [alphaGroupId, bravoGroupId]) { + const members = await trx + .selectFrom("GroupMember") + .select("GroupMember.userId") + .where("GroupMember.groupId", "=", groupId) + .execute(); + + if (members.length !== FULL_GROUP_SIZE) { + throw new Error(`Group ${groupId} does not have full group members`); + } + + const matches = await trx + .selectFrom("GroupMatch") + .select("GroupMatch.id") + .where((eb) => + eb.or([ + eb("GroupMatch.alphaGroupId", "=", groupId), + eb("GroupMatch.bravoGroupId", "=", groupId), + ]), + ) + .execute(); + + if (matches.length !== 1) { + throw new Error(`Group ${groupId} is already in a match`); + } + } +} diff --git a/app/features/sendouq-match/actions/q.match.$id.server.ts b/app/features/sendouq-match/actions/q.match.$id.server.ts index 3afe5b7d7..fd33d998e 100644 --- a/app/features/sendouq-match/actions/q.match.$id.server.ts +++ b/app/features/sendouq-match/actions/q.match.$id.server.ts @@ -7,9 +7,13 @@ import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import type { ChatMessage } from "~/features/chat/chat-types"; import * as Seasons from "~/features/mmr/core/Seasons"; import { refreshUserSkills } from "~/features/mmr/tiered.server"; -import * as QRepository from "~/features/sendouq/QRepository.server"; -import { findCurrentGroupByUserId } from "~/features/sendouq/queries/findCurrentGroupByUserId.server"; -import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server"; +import { + refreshSendouQInstance, + SendouQ, +} from "~/features/sendouq/core/SendouQ.server"; +import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server"; +import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; +import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server"; import { refreshStreamsCache } from "~/features/sendouq-streams/core/streams.server"; import invariant from "~/utils/invariant"; import { logger } from "~/utils/logger"; @@ -72,7 +76,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { })(); }; - const match = notFoundIfFalsy(findMatchById(matchId)); + const unmappedMatch = notFoundIfFalsy( + await SQMatchRepository.findById(matchId), + ); + const match = SendouQ.mapMatch(unmappedMatch, user); if (match.isLocked) { reportWeapons(); return null; @@ -83,17 +90,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { "Only mods can report scores as admin", ); const members = [ - ...(await QMatchRepository.findGroupById({ - groupId: match.alphaGroupId, - }))!.members.map((m) => ({ + ...match.groupAlpha.members.map((m) => ({ ...m, - groupId: match.alphaGroupId, + groupId: match.groupAlpha.id, })), - ...(await QMatchRepository.findGroupById({ - groupId: match.bravoGroupId, - }))!.members.map((m) => ({ + ...match.groupBravo.members.map((m) => ({ ...m, - groupId: match.bravoGroupId, + groupId: match.groupBravo.id, })), ]; @@ -105,9 +108,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const winner = winnersArrayToWinner(data.winners); const winnerGroupId = - winner === "ALPHA" ? match.alphaGroupId : match.bravoGroupId; + winner === "ALPHA" ? match.groupAlpha.id : match.groupBravo.id; const loserGroupId = - winner === "ALPHA" ? match.bravoGroupId : match.alphaGroupId; + winner === "ALPHA" ? match.groupBravo.id : match.groupAlpha.id; // when admin reports match gets locked right away const compared = data.adminReport @@ -133,12 +136,14 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { 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), + winner: (match.groupAlpha.id === winnerGroupId + ? match.groupAlpha + : match.groupBravo + ).members.map((m) => m.id), + loser: (match.groupAlpha.id === loserGroupId + ? match.groupAlpha + : match.groupBravo + ).members.map((m) => m.id), winnerGroupId, loserGroupId, }) @@ -188,8 +193,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { } // admin reporting, just set both groups inactive if (data.adminReport) { - setGroupAsInactive(match.alphaGroupId); - setGroupAsInactive(match.bravoGroupId); + setGroupAsInactive(match.groupAlpha.id); + setGroupAsInactive(match.groupBravo.id); } })(); @@ -216,6 +221,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { // in a different transaction but it's okay reportWeapons(); + await refreshSendouQInstance(); + if (match.chatCode) { const type = (): NonNullable => { if (compared === "SAME") { @@ -242,13 +249,20 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const season = Seasons.current(); errorToastIfFalsy(season, "Season is not active"); - const previousGroup = await QMatchRepository.findGroupById({ - groupId: data.previousGroupId, - }); - errorToastIfFalsy(previousGroup, "Previous group not found"); + const match = notFoundIfFalsy(await SQMatchRepository.findById(matchId)); + const previousGroup = + match.groupAlpha.id === data.previousGroupId + ? match.groupAlpha + : match.groupBravo.id === data.previousGroupId + ? match.groupBravo + : null; + errorToastIfFalsy( + previousGroup, + "Previous group not found in this match", + ); for (const member of previousGroup.members) { - const currentGroup = findCurrentGroupByUserId(member.id); + const currentGroup = SendouQ.findOwnGroup(member.id); errorToastIfFalsy(!currentGroup, "Member is already in a group"); if (member.id === user.id) { errorToastIfFalsy( @@ -258,11 +272,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { } } - await QRepository.createGroupFromPrevious({ + await SQGroupRepository.createGroupFromPrevious({ previousGroupId: data.previousGroupId, members: previousGroup.members.map((m) => ({ id: m.id, role: m.role })), }); + await refreshSendouQInstance(); + throw redirect(SENDOUQ_PREPARING_PAGE); } case "REPORT_WEAPONS": { @@ -287,7 +303,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { break; } case "ADD_PRIVATE_USER_NOTE": { - await QRepository.upsertPrivateUserNote({ + await PrivateUserNoteRepository.upsert({ authorId: user.id, sentiment: data.sentiment, targetId: data.targetId, diff --git a/app/features/sendouq-match/components/AddPrivateNoteDialog.tsx b/app/features/sendouq-match/components/AddPrivateNoteDialog.tsx index a56a96f8a..c6354c309 100644 --- a/app/features/sendouq-match/components/AddPrivateNoteDialog.tsx +++ b/app/features/sendouq-match/components/AddPrivateNoteDialog.tsx @@ -6,16 +6,16 @@ import { FormMessage } from "~/components/FormMessage"; import { Label } from "~/components/Label"; import { SubmitButton } from "~/components/SubmitButton"; import type { Tables } from "~/db/tables"; +import type { SQMatchGroup } from "~/features/sendouq/core/SendouQ.server"; import { SENDOUQ } from "~/features/sendouq/q-constants"; import { preferenceEmojiUrl } from "~/utils/urls"; -import type { GroupForMatch } from "../QMatchRepository.server"; export function AddPrivateNoteDialog({ aboutUser, close, }: { aboutUser?: Pick< - GroupForMatch["members"][number], + SQMatchGroup["members"][number], "id" | "username" | "privateNote" >; close: () => void; diff --git a/app/features/sendouq-match/core/match.server.ts b/app/features/sendouq-match/core/match.server.ts index 647279c30..befd65748 100644 --- a/app/features/sendouq-match/core/match.server.ts +++ b/app/features/sendouq-match/core/match.server.ts @@ -5,9 +5,11 @@ import { MapPool } from "~/features/map-list-generator/core/map-pool"; import * as Seasons from "~/features/mmr/core/Seasons"; import { userSkills } from "~/features/mmr/tiered.server"; import { getDefaultMapWeights } from "~/features/sendouq/core/default-maps.server"; -import { addSkillsToGroups } from "~/features/sendouq/core/groups.server"; +import type { + SQMatch, + SQUncensoredGroup, +} from "~/features/sendouq/core/SendouQ.server"; import { SENDOUQ_BEST_OF } from "~/features/sendouq/q-constants"; -import type { LookingGroupWithInviteCode } from "~/features/sendouq/q-types"; import { BANNED_MAPS, SENDOUQ_MAP_POOL, @@ -20,7 +22,6 @@ import type { } from "~/modules/tournament-map-list-generator/types"; import { logger } from "~/utils/logger"; import { averageArray } from "~/utils/number"; -import type { MatchById } from "../queries/findMatchById.server"; type WeightsMap = Map; @@ -312,7 +313,7 @@ export function compareMatchToReportedScores({ newReporterGroupId, previousReporterGroupId, }: { - match: MatchById; + match: SQMatch; winners: ("ALPHA" | "BRAVO")[]; newReporterGroupId: number; previousReporterGroupId?: number; @@ -342,7 +343,7 @@ export function compareMatchToReportedScores({ if (newWinner && !previousWinnerGroupId) return differentConstant; const previousWinner = - previousWinnerGroupId === match.alphaGroupId ? "ALPHA" : "BRAVO"; + previousWinnerGroupId === match.groupAlpha.id ? "ALPHA" : "BRAVO"; if (previousWinner !== newWinner) return differentConstant; } @@ -355,11 +356,11 @@ export function compareMatchToReportedScores({ type CreateMatchMementoArgs = { own: { - group: LookingGroupWithInviteCode; + group: SQUncensoredGroup; preferences: { userId: number; preferences: UserMapModePreferences }[]; }; their: { - group: LookingGroupWithInviteCode; + group: SQUncensoredGroup; preferences: { userId: number; preferences: UserMapModePreferences }[]; }; mapList: TournamentMapListMap[]; @@ -368,17 +369,9 @@ export function createMatchMemento( args: CreateMatchMementoArgs, ): Omit { const skills = userSkills(Seasons.currentOrPrevious()!.nth); - const withTiers = addSkillsToGroups({ - groups: { - neutral: [], - likesReceived: [args.their.group], - own: args.own.group, - }, - ...skills, - }); - const ownWithTier = withTiers.own; - const theirWithTier = withTiers.likesReceived[0]; + const ownWithTier = args.own.group; + const theirWithTier = args.their.group; return { modePreferences: modePreferencesMemento(args), @@ -401,7 +394,7 @@ export function createMatchMemento( [ownWithTier, theirWithTier].map((group) => [ group!.id, { - tier: group!.tier, + tier: group!.tier!, }, ]), ), diff --git a/app/features/sendouq-match/core/reported-weapons.server.ts b/app/features/sendouq-match/core/reported-weapons.server.ts index 1dd1399de..187f0baf4 100644 --- a/app/features/sendouq-match/core/reported-weapons.server.ts +++ b/app/features/sendouq-match/core/reported-weapons.server.ts @@ -1,4 +1,4 @@ -import type { GroupForMatch } from "~/features/sendouq-match/QMatchRepository.server"; +import type { SQMatchGroup } from "~/features/sendouq/core/SendouQ.server"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import type { MatchById } from "../queries/findMatchById.server"; import type { reportedWeaponsByMatchId } from "../queries/reportedWeaponsByMatchId.server"; @@ -67,8 +67,8 @@ export function reportedWeaponsToArrayOfArrays({ }: { reportedWeapons: ReturnType; mapList: MatchById["mapList"]; - groupAlpha: GroupForMatch; - groupBravo: GroupForMatch; + groupAlpha: SQMatchGroup; + groupBravo: SQMatchGroup; }) { if (!reportedWeapons) return null; diff --git a/app/features/sendouq-match/core/summarizer.server.ts b/app/features/sendouq-match/core/summarizer.server.ts index cef242240..5cc65ca5a 100644 --- a/app/features/sendouq-match/core/summarizer.server.ts +++ b/app/features/sendouq-match/core/summarizer.server.ts @@ -1,15 +1,15 @@ import type { Tables } from "~/db/tables"; import * as Seasons from "~/features/mmr/core/Seasons"; +import type { SQMatch } from "~/features/sendouq/core/SendouQ.server"; import invariant from "~/utils/invariant"; import { winnersArrayToWinner } from "../q-match-utils"; -import type { MatchById } from "../queries/findMatchById.server"; export function summarizeMaps({ match, winners, members, }: { - match: MatchById; + match: SQMatch; winners: ("ALPHA" | "BRAVO")[]; members: { id: number; groupId: number }[]; }) { @@ -23,7 +23,7 @@ export function summarizeMaps({ for (const [i, map] of playedMaps.entries()) { const winnerSide = winners[i]; const winnerGroupId = - winnerSide === "ALPHA" ? match.alphaGroupId : match.bravoGroupId; + winnerSide === "ALPHA" ? match.groupAlpha.id : match.groupBravo.id; const winnerPlayers = members.filter((p) => p.groupId === winnerGroupId); const loserPlayers = members.filter((p) => p.groupId !== winnerGroupId); @@ -59,7 +59,7 @@ export function summarizePlayerResults({ winners, members, }: { - match: MatchById; + match: SQMatch; winners: ("ALPHA" | "BRAVO")[]; members: { id: number; groupId: number }[]; }) { @@ -108,8 +108,8 @@ export function summarizePlayerResults({ const type = member.groupId === member2.groupId ? "MATE" : "ENEMY"; const won = winner === "ALPHA" - ? member.groupId === match.alphaGroupId - : member.groupId === match.bravoGroupId; + ? member.groupId === match.groupAlpha.id + : member.groupId === match.groupBravo.id; addMapResult({ ownerUserId: member.id, @@ -130,8 +130,8 @@ export function summarizePlayerResults({ const type = member.groupId === member2.groupId ? "MATE" : "ENEMY"; const won = winner === "ALPHA" - ? member.groupId === match.alphaGroupId - : member.groupId === match.bravoGroupId; + ? member.groupId === match.groupAlpha.id + : member.groupId === match.groupBravo.id; result.push({ ownerUserId: member.id, diff --git a/app/features/sendouq-match/loaders/q.match.$id.server.ts b/app/features/sendouq-match/loaders/q.match.$id.server.ts index efd9af3ed..cd89d8bd8 100644 --- a/app/features/sendouq-match/loaders/q.match.$id.server.ts +++ b/app/features/sendouq-match/loaders/q.match.$id.server.ts @@ -1,12 +1,10 @@ -import cachified from "@epic-web/cachified"; import type { LoaderFunctionArgs } from "@remix-run/node"; import { getUser } from "~/features/auth/core/user.server"; +import { SendouQ } from "~/features/sendouq/core/SendouQ.server"; +import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server"; import { reportedWeaponsToArrayOfArrays } from "~/features/sendouq-match/core/reported-weapons.server"; -import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server"; import { reportedWeaponsByMatchId } from "~/features/sendouq-match/queries/reportedWeaponsByMatchId.server"; -import { cache } from "~/utils/cache.server"; -import { databaseTimestampToDate } from "~/utils/dates"; -import invariant from "~/utils/invariant"; +import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server"; import { notFoundIfFalsy, parseParams } from "~/utils/remix.server"; import { qMatchPageParamsSchema } from "../q-match-schemas"; @@ -16,97 +14,30 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { params, schema: qMatchPageParamsSchema, }).id; - const match = notFoundIfFalsy(await QMatchRepository.findById(matchId)); + const matchUnmapped = notFoundIfFalsy( + await SQMatchRepository.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 matchUsers = [ + ...matchUnmapped.groupAlpha.members, + ...matchUnmapped.groupBravo.members, + ].map((m) => m.id); + const privateNotes = user + ? await PrivateUserNoteRepository.byAuthorUserId(user.id, matchUsers) + : undefined; - const isTeamAlphaMember = groupAlpha.members.some((m) => m.id === user?.id); - const isTeamBravoMember = groupBravo.members.some((m) => m.id === user?.id); - const isMatchInsider = - isTeamAlphaMember || isTeamBravoMember || user?.roles.includes("STAFF"); - 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 match = SendouQ.mapMatch(matchUnmapped, user, privateNotes); 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, + match, reportedWeapons: match.reportedAt ? reportedWeaponsToArrayOfArrays({ - groupAlpha, - groupBravo, + groupAlpha: match.groupAlpha, + groupBravo: match.groupBravo, mapList: match.mapList, reportedWeapons: rawReportedWeapons, }) diff --git a/app/features/sendouq-match/q-match-utils.ts b/app/features/sendouq-match/q-match-utils.ts index 12b400cf4..ff91e8c89 100644 --- a/app/features/sendouq-match/q-match-utils.ts +++ b/app/features/sendouq-match/q-match-utils.ts @@ -7,3 +7,21 @@ export function winnersArrayToWinner(winners: ("ALPHA" | "BRAVO")[]) { return null; } + +export function resolveGroupMemberOf(args: { + groupAlpha: { members: { id: number }[] }; + groupBravo: { members: { id: number }[] }; + userId: number | undefined; +}): "ALPHA" | "BRAVO" | null { + if (!args.userId) return null; + + if (args.groupAlpha.members.some((m) => m.id === args.userId)) { + return "ALPHA"; + } + + if (args.groupBravo.members.some((m) => m.id === args.userId)) { + return "BRAVO"; + } + + return null; +} diff --git a/app/features/sendouq-match/queries/addSkills.server.ts b/app/features/sendouq-match/queries/addSkills.server.ts index cb08d912c..ce6aece4b 100644 --- a/app/features/sendouq-match/queries/addSkills.server.ts +++ b/app/features/sendouq-match/queries/addSkills.server.ts @@ -60,7 +60,7 @@ export function addSkills({ Tables["Skill"], "groupMatchId" | "identifier" | "mu" | "season" | "sigma" | "userId" >[]; - oldMatchMemento: ParsedMemento; + oldMatchMemento: ParsedMemento | null; differences: MementoSkillDifferences; }) { for (const skill of skills) { diff --git a/app/features/sendouq-match/routes/q.match.$id.module.css b/app/features/sendouq-match/routes/q.match.$id.module.css new file mode 100644 index 000000000..3951a9711 --- /dev/null +++ b/app/features/sendouq-match/routes/q.match.$id.module.css @@ -0,0 +1,128 @@ +.stagePopoverButton { + background-color: transparent; + color: var(--text-lighter); + font-size: var(--fonts-xs); + padding: 0; + border: none; + text-decoration: underline; + text-decoration-style: dotted; + font-weight: var(--body); + height: 19.8281px; +} + +.stagePopoverButton:focus { + outline: none; + color: var(--theme); +} + +.modePopoverButton { + background-color: transparent; + padding: 0; + border: none; +} + +.modePopoverButton:focus { + outline: none; +} + +.container { + /** Push footer down to avoid it "flashing" when the score reporter animates */ + padding-bottom: 14rem; +} + +.header { + line-height: 1.2; +} + +.teamsContainer { + display: grid; + grid-template-columns: 1fr; + gap: var(--s-8); +} + +.mapListChatContainer { + display: grid; + grid-template-columns: 2fr 1fr 2fr; + place-items: center; + gap: var(--s-4); +} + +.userNameContainer { + display: flex; + gap: var(--s-2); + width: 175px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.reportSection { + display: grid; + grid-template-columns: max-content 1fr; + row-gap: var(--s-2); + column-gap: var(--s-2-5); + align-items: center; + font-size: var(--fonts-xs); +} + +.poolPassContainer { + display: flex; + gap: var(--s-2); + flex-direction: column; + max-width: max-content; +} + +.chatContainer { + align-self: flex-start; + top: var(--sticky-top); + position: sticky; + width: 100%; +} + +.bottomMidSection { + display: flex; + flex-direction: column; + align-self: flex-start; + top: var(--sticky-top); + position: sticky; +} + +.infoHeader { + text-transform: uppercase; + color: var(--text-lighter); + font-size: var(--fonts-xs); + line-height: 1.1; +} + +.infoValue { + font-size: var(--fonts-xl); + font-weight: var(--semi-bold); + letter-spacing: 1px; +} + +.screenLegality svg { + width: 24px; +} + +.screenLegality .alert { + padding-block: var(--s-1); + padding-inline: var(--s-2-5); +} + +.screenLegalityButton { + width: 100%; +} + +.screenLegalityButton:focus-visible { + outline: none !important; +} + +.screenLegalityButton:focus-visible .alert { + background-color: var(--bg-lighter); +} + +@media screen and (min-width: 640px) { + .teamsContainer { + grid-template-columns: 1fr 1fr; + } +} diff --git a/app/features/sendouq-match/routes/q.match.$id.tsx b/app/features/sendouq-match/routes/q.match.$id.tsx index b70bc5f21..3bcd56a00 100644 --- a/app/features/sendouq-match/routes/q.match.$id.tsx +++ b/app/features/sendouq-match/routes/q.match.$id.tsx @@ -31,6 +31,7 @@ import { RefreshArrowsIcon } from "~/components/icons/RefreshArrows"; import { ScaleIcon } from "~/components/icons/Scale"; import { UsersIcon } from "~/components/icons/Users"; import { Main } from "~/components/Main"; +import { Placeholder } from "~/components/Placeholder"; import { SubmitButton } from "~/components/SubmitButton"; import { WeaponSelect } from "~/components/WeaponSelect"; import type { Tables } from "~/db/tables"; @@ -73,9 +74,10 @@ import { import { action } from "../actions/q.match.$id.server"; import { matchEndedAtIndex } from "../core/match"; import { loader } from "../loaders/q.match.$id.server"; +import { resolveGroupMemberOf } from "../q-match-utils"; export { loader, action }; -import "~/features/sendouq/q.css"; +import styles from "./q.match.$id.module.css"; export const meta: MetaFunction = (args) => { const data = args.data as SerializeFrom | null; @@ -85,9 +87,9 @@ export const meta: MetaFunction = (args) => { return metaTags({ title: `SendouQ - Match #${data.match.id}`, description: `${new Intl.ListFormat("en-US").format( - data.groupAlpha.members.map((m) => m.username), + data.match.groupAlpha.members.map((m) => m.username), )} vs. ${new Intl.ListFormat("en-US").format( - data.groupBravo.members.map((m) => m.username), + data.match.groupBravo.members.map((m) => m.username), )}`, location: args.location, }); @@ -102,7 +104,20 @@ export const handle: SendouRouteHandle = { }), }; -export default function QMatchPage() { +export default function QMatchShell() { + const isMounted = useIsMounted(); + + if (!isMounted) + return ( +
+ +
+ ); + + return ; +} + +function QMatchPage() { const user = useUser(); const isStaff = useHasRole("STAFF"); const isMounted = useIsMounted(); @@ -119,16 +134,16 @@ export default function QMatchPage() { }, [data.reportedWeapons, data.match.id]); const ownMember = - data.groupAlpha.members.find((m) => m.id === user?.id) ?? - data.groupBravo.members.find((m) => m.id === user?.id); + data.match.groupAlpha.members.find((m) => m.id === user?.id) ?? + data.match.groupBravo.members.find((m) => m.id === user?.id); const canReportScore = Boolean( !data.match.isLocked && (ownMember || isStaff), ); - const ownGroup = data.groupAlpha.members.some((m) => m.id === user?.id) - ? data.groupAlpha - : data.groupBravo.members.some((m) => m.id === user?.id) - ? data.groupBravo + const ownGroup = data.match.groupAlpha.members.some((m) => m.id === user?.id) + ? data.match.groupAlpha + : data.match.groupBravo.members.some((m) => m.id === user?.id) + ? data.match.groupBravo : null; const ownTeamReported = Boolean( @@ -138,17 +153,23 @@ export default function QMatchPage() { const showScore = data.match.isLocked || (data.match.reportedByUserId && ownGroup); + const groupMemberOf = resolveGroupMemberOf({ + groupAlpha: data.match.groupAlpha, + groupBravo: data.match.groupBravo, + userId: user?.id, + }); + const addingNoteFor = ( - data.groupMemberOf === "ALPHA" ? data.groupAlpha : data.groupBravo + groupMemberOf === "ALPHA" ? data.match.groupAlpha : data.match.groupBravo ).members.find((m) => m.id === safeNumberParse(searchParams.get("note"))); return ( -
+
navigate(sendouQMatchPage(data.match.id))} /> -
+

{t("q:match.header", { number: data.match.id })}

-
- {[data.groupAlpha, data.groupBravo].map((group, i) => { +
+ {[data.match.groupAlpha, data.match.groupBravo].map((group, i) => { const side = i === 0 ? "ALPHA" : "BRAVO"; - const isOwnGroup = data.groupMemberOf === side; + const isOwnGroup = groupMemberOf === side; const matchHasBeenReported = Boolean(data.match.reportedByUserId); const showAddNote = - data.groupMemberOf === side && matchHasBeenReported; - + groupMemberOf === side && matchHasBeenReported; return (
@@ -246,14 +266,18 @@ function Score({ const { formatDateTime } = useTimeFormat(); const data = useLoaderData(); const reporter = - data.groupAlpha.members.find((m) => m.id === data.match.reportedByUserId) ?? - data.groupBravo.members.find((m) => m.id === data.match.reportedByUserId); + data.match.groupAlpha.members.find( + (m) => m.id === data.match.reportedByUserId, + ) ?? + data.match.groupBravo.members.find( + (m) => m.id === data.match.reportedByUserId, + ); const score = data.match.mapList.reduce( (acc, cur) => { if (!cur.winnerGroupId) return acc; - if (cur.winnerGroupId === data.match.alphaGroupId) { + if (cur.winnerGroupId === data.match.groupAlpha.id) { return [acc[0] + 1, acc[1]]; } @@ -411,7 +435,7 @@ function ReportWeaponsForm() { const playedMaps = data.match.mapList.filter((m) => m.winnerGroupId); const winners = playedMaps.map((m) => - m.winnerGroupId === data.match.alphaGroupId ? "ALPHA" : "BRAVO", + m.winnerGroupId === data.match.groupAlpha.id ? "ALPHA" : "BRAVO", ); const handleCopyWeaponsFromPreviousMap = @@ -441,8 +465,17 @@ function ReportWeaponsForm() { }); }; + const groupMemberOf = resolveGroupMemberOf({ + groupAlpha: data.match.groupAlpha, + groupBravo: data.match.groupBravo, + userId: user?.id, + }); + const playersToReport = () => { - const allPlayers = [...data.groupAlpha.members, ...data.groupBravo.members]; + const allPlayers = [ + ...data.match.groupAlpha.members, + ...data.match.groupBravo.members, + ]; switch (reportingMode) { case "ALL": { @@ -455,9 +488,9 @@ function ReportWeaponsForm() { return [me]; } case "MY_TEAM": { - return data.groupMemberOf === "ALPHA" - ? data.groupAlpha.members - : data.groupBravo.members; + return groupMemberOf === "ALPHA" + ? data.match.groupAlpha.members + : data.match.groupBravo.members; } default: assertUnreachable(reportingMode); @@ -549,7 +582,7 @@ function ReportWeaponsForm() { key={member.id} className="stack horizontal sm justify-between items-center flex-wrap" > -
+
{" "} {member.inGameName ? ( <> @@ -638,10 +671,9 @@ function BottomSection({ const chatUsers = React.useMemo(() => { return Object.fromEntries( - [...data.groupAlpha.members, ...data.groupBravo.members].map((m) => [ - m.id, - m, - ]), + [...data.match.groupAlpha.members, ...data.match.groupBravo.members].map( + (m) => [m.id, m], + ), ); }, [data]); @@ -652,12 +684,17 @@ function BottomSection({ setUnseenMessages((msg) => msg + 1); }, []); + const groupChatCode = + data.match.groupAlpha.chatCode ?? data.match.groupBravo.chatCode; + const chatRooms = React.useMemo(() => { return [ - data.matchChatCode ? { code: data.matchChatCode, label: "Match" } : null, - data.groupChatCode ? { code: data.groupChatCode, label: "Group" } : null, + data.match.chatCode + ? { code: data.match.chatCode, label: "Match" } + : null, + groupChatCode ? { code: groupChatCode, label: "Group" } : null, ].filter(Boolean) as ChatProps["rooms"]; - }, [data.matchChatCode, data.groupChatCode]); + }, [data.match.chatCode, groupChatCode]); const chatHidden = chatRooms.length === 0; @@ -706,7 +743,7 @@ function BottomSection({ onUnmount={onChatUnmount} users={chatUsers} rooms={chatRooms} - disabled={!data.canPostChatMessages} + disabled={!groupChatCode} // no message sending by staff to match chat /> ); @@ -721,9 +758,7 @@ function BottomSection({ ); const roomJoiningInfoElement = ( -
+
); + const groupMemberOf = resolveGroupMemberOf({ + groupAlpha: data.match.groupAlpha, + groupBravo: data.match.groupBravo, + userId: user?.id, + }); + const cancelMatchElement = canReportScore && !data.match.isLocked ? ( ) : null; - const screenLegalityInfoElement = - data.banScreen !== null ? ( - - ) : null; + const screenBanned = Boolean( + data.match.groupAlpha.noScreen || data.match.groupBravo.noScreen, + ); + + const screenLegalityInfoElement = !data.match.isLocked ? ( + + ) : null; if (!showMid && chatHidden) { return mapListElement; @@ -832,10 +876,10 @@ function BottomSection({ return ( <> -
+
{mapListElement}
@@ -847,7 +891,7 @@ function BottomSection({ {cancelMatchElement}
-
+
{chatRooms.length > 0 ? chatElement : null}
@@ -869,13 +913,13 @@ function ScreenLegalityInfo({ ban }: { ban: boolean }) { const { t } = useTranslation(["q", "weapons"]); return ( -
+
@@ -905,8 +949,8 @@ function ScreenLegalityInfo({ ban }: { ban: boolean }) { function InfoWithHeader({ header, value }: { header: string; value: string }) { return (
-
{header}
-
{value}
+
{header}
+
{value}
); } @@ -937,7 +981,7 @@ function MapList({ ? data.match.mapList .filter((m) => m.winnerGroupId) .map((m) => - m.winnerGroupId === data.groupAlpha.id ? "ALPHA" : "BRAVO", + m.winnerGroupId === data.match.groupAlpha.id ? "ALPHA" : "BRAVO", ) : []; const [winners, setWinners] = React.useState<("ALPHA" | "BRAVO")[]>( @@ -1016,7 +1060,11 @@ function MapList({ {scoreCanBeReported ? (
- + {isResubmission ? t("q:match.submitScores.adjusted") : t("q:match.submitScores")} @@ -1093,25 +1141,31 @@ function MapListMap({ ); const winnerSide = - winnerId === data.match.alphaGroupId + winnerId === data.match.groupAlpha.id ? t("q:match.sides.alpha") : t("q:match.sides.bravo"); return <>• {t("q:match.won", { side: winnerSide })}; }; - const relativeSideText = (side: "ALPHA" | "BRAVO") => { - if (!data.groupMemberOf) return ""; + const groupMemberOf = resolveGroupMemberOf({ + groupAlpha: data.match.groupAlpha, + groupBravo: data.match.groupBravo, + userId: user?.id, + }); - return data.groupMemberOf === side ? " (us)" : " (them)"; + const relativeSideText = (side: "ALPHA" | "BRAVO") => { + if (!groupMemberOf) return ""; + + return groupMemberOf === side ? " (us)" : " (them)"; }; const modePreferences = data.match.memento?.modePreferences?.[map.mode]; const userIdToName = (userId: number) => { const member = [ - ...data.groupAlpha.members, - ...data.groupBravo.members, + ...data.match.groupAlpha.members, + ...data.match.groupBravo.members, ].find((m) => m.id === userId); return member?.username ?? ""; @@ -1129,7 +1183,7 @@ function MapListMap({ + } @@ -1198,7 +1252,7 @@ function MapListMap({ el.style.opacity = "1"; }} > -
+
@@ -1216,7 +1270,7 @@ function MapListMap({ {t("q:match.sides.alpha")} {relativeSideText("ALPHA")} @@ -1236,7 +1290,7 @@ function MapListMap({ {t("q:match.sides.bravo")} {relativeSideText("BRAVO")} @@ -1316,8 +1370,8 @@ function MapListMapPickInfo({ const userIdToUser = (userId: number) => { const member = [ - ...data.groupAlpha.members, - ...data.groupBravo.members, + ...data.match.groupAlpha.members, + ...data.match.groupBravo.members, ].find((m) => m.id === userId); return member; @@ -1328,7 +1382,7 @@ function MapListMapPickInfo({ if (!data.match.memento?.pools) return result; - const pickerGroups = [data.groupAlpha, data.groupBravo].filter( + const pickerGroups = [data.match.groupAlpha, data.match.groupBravo].filter( (g) => map.source === "BOTH" || String(g.id) === map.source, ); if (pickerGroups.length === 0) return result; @@ -1364,7 +1418,7 @@ function MapListMapPickInfo({ + {pickInfo(map.source)} } @@ -1419,7 +1473,7 @@ function ResultSummary({ winners }: { winners: ("ALPHA" | "BRAVO")[] }) { const user = useUser(); const data = useLoaderData(); - const ownSide = data.groupAlpha.members.some((m) => m.id === user?.id) + const ownSide = data.match.groupAlpha.members.some((m) => m.id === user?.id) ? "ALPHA" : "BRAVO"; diff --git a/app/features/sendouq-streams/routes/q.streams.module.css b/app/features/sendouq-streams/routes/q.streams.module.css new file mode 100644 index 000000000..ff4e6746c --- /dev/null +++ b/app/features/sendouq-streams/routes/q.streams.module.css @@ -0,0 +1,27 @@ +.userContainer { + font-size: var(--fonts-xs); + display: flex; + gap: var(--s-2); + align-items: center; + font-weight: var(--semi-bold); + color: var(--text); +} + +.viewerCount { + font-size: var(--fonts-xs); + display: flex; + gap: var(--s-2); + align-items: center; + margin-block-start: -5px; + color: var(--text-lighter); +} + +.viewerCount > svg { + width: 0.75rem; +} + +.infoCircle { + border-radius: 100%; + background-color: var(--bg-lighter); + padding: var(--s-1); +} diff --git a/app/features/sendouq-streams/routes/q.streams.tsx b/app/features/sendouq-streams/routes/q.streams.tsx index 3cf82a914..870d5b8fe 100644 --- a/app/features/sendouq-streams/routes/q.streams.tsx +++ b/app/features/sendouq-streams/routes/q.streams.tsx @@ -16,7 +16,7 @@ import { FAQ_PAGE, sendouQMatchPage, twitchUrl, userPage } from "~/utils/urls"; import { loader } from "../loaders/q.streams.server"; export { loader }; -import "~/features/sendouq/q.css"; +import styles from "./q.streams.module.css"; export const handle: SendouRouteHandle = { i18n: ["q"], @@ -59,14 +59,14 @@ export default function SendouQStreamsPage() {
{" "} {streamedMatch.user.username}
{streamedMatch.weaponSplId ? ( -
+
) : null} {streamedMatch.tier ? ( -
+
) : null} @@ -108,7 +108,7 @@ export default function SendouQStreamsPage() { )} />
-
+
{streamedMatch.stream.viewerCount}
diff --git a/app/features/sendouq/PrivateUserNoteRepository.server.ts b/app/features/sendouq/PrivateUserNoteRepository.server.ts new file mode 100644 index 000000000..86349a1ae --- /dev/null +++ b/app/features/sendouq/PrivateUserNoteRepository.server.ts @@ -0,0 +1,61 @@ +import { db } from "~/db/sql"; +import type { TablesInsertable } from "~/db/tables"; +import { databaseTimestampNow } from "~/utils/dates"; + +export function byAuthorUserId( + authorId: number, + /** Which users to get notes for, if omitted all notes for author are returned */ + targetUserIds: number[] = [], +) { + let query = db + .selectFrom("PrivateUserNote") + .select([ + "PrivateUserNote.sentiment", + "PrivateUserNote.targetId as targetUserId", + "PrivateUserNote.text", + "PrivateUserNote.updatedAt", + ]) + .where("authorId", "=", authorId); + + const targetUsersWithoutAuthor = targetUserIds.filter( + (id) => id !== authorId, + ); + if (targetUsersWithoutAuthor.length > 0) { + query = query.where("targetId", "in", targetUsersWithoutAuthor); + } + + return query.execute(); +} + +export function upsert(args: TablesInsertable["PrivateUserNote"]) { + return db + .insertInto("PrivateUserNote") + .values({ + authorId: args.authorId, + targetId: args.targetId, + sentiment: args.sentiment, + text: args.text, + }) + .onConflict((oc) => + oc.columns(["authorId", "targetId"]).doUpdateSet({ + sentiment: args.sentiment, + text: args.text, + updatedAt: databaseTimestampNow(), + }), + ) + .execute(); +} + +export function del({ + authorId, + targetId, +}: { + authorId: number; + targetId: number; +}) { + return db + .deleteFrom("PrivateUserNote") + .where("authorId", "=", authorId) + .where("targetId", "=", targetId) + .execute(); +} diff --git a/app/features/sendouq/QRepository.server.ts b/app/features/sendouq/QRepository.server.ts deleted file mode 100644 index ea0ccfa56..000000000 --- a/app/features/sendouq/QRepository.server.ts +++ /dev/null @@ -1,409 +0,0 @@ -import { sub } from "date-fns"; -import { type NotNull, sql } from "kysely"; -import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite"; -import { db } from "~/db/sql"; -import type { - Tables, - TablesInsertable, - UserMapModePreferences, -} from "~/db/tables"; -import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates"; -import { IS_E2E_TEST_RUN } from "~/utils/e2e"; -import { shortNanoid } from "~/utils/id"; -import { COMMON_USER_FIELDS } from "~/utils/kysely.server"; -import { userIsBanned } from "../ban/core/banned.server"; -import type { LookingGroupWithInviteCode } from "./q-types"; - -export function mapModePreferencesByGroupId(groupId: number) { - return db - .selectFrom("GroupMember") - .innerJoin("User", "User.id", "GroupMember.userId") - .select(["User.id as userId", "User.mapModePreferences as preferences"]) - .where("GroupMember.groupId", "=", groupId) - .where("User.mapModePreferences", "is not", null) - .$narrowType<{ preferences: NotNull }>() - .execute(); -} - -// groups visible for longer to make development easier -const SECONDS_TILL_STALE = - process.env.NODE_ENV === "development" || IS_E2E_TEST_RUN ? 1_000_000 : 1_800; - -export async function findLookingGroups({ - minGroupSize, - maxGroupSize, - ownGroupId, - includeChatCode = false, - includeMapModePreferences = false, - loggedInUserId, -}: { - minGroupSize?: number; - maxGroupSize?: number; - ownGroupId?: number; - includeChatCode?: boolean; - includeMapModePreferences?: boolean; - loggedInUserId?: number; -}): Promise { - const rows = await db - .selectFrom("Group") - .leftJoin("GroupMatch", (join) => - join.on((eb) => - eb.or([ - eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")), - eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")), - ]), - ), - ) - .select((eb) => [ - "Group.id", - "Group.createdAt", - "Group.chatCode", - "Group.inviteCode", - jsonArrayFrom( - eb - .selectFrom("GroupMember") - .innerJoin("User", "User.id", "GroupMember.userId") - .leftJoin("PlusTier", "PlusTier.userId", "GroupMember.userId") - .select((arrayEb) => [ - ...COMMON_USER_FIELDS, - "User.qWeaponPool as weapons", - "PlusTier.tier as plusTier", - "GroupMember.note", - "GroupMember.role", - "User.languages", - "User.vc", - "User.noScreen", - jsonObjectFrom( - eb - .selectFrom("PrivateUserNote") - .select([ - "PrivateUserNote.sentiment", - "PrivateUserNote.text", - "PrivateUserNote.updatedAt", - ]) - .where("authorId", "=", loggedInUserId ?? -1) - .where("targetId", "=", arrayEb.ref("User.id")), - ).as("privateNote"), - sql< - string | null - >`IIF(COALESCE("User"."patronTier", 0) >= 2, "User"."css" ->> 'chat', null)`.as( - "chatNameColor", - ), - ]) - .where("GroupMember.groupId", "=", eb.ref("Group.id")) - .groupBy("GroupMember.userId"), - ).as("members"), - ]) - .$if(includeMapModePreferences, (qb) => - qb.select((eb) => - jsonArrayFrom( - eb - .selectFrom("GroupMember") - .innerJoin("User", "User.id", "GroupMember.userId") - .select("User.mapModePreferences") - .where("GroupMember.groupId", "=", eb.ref("Group.id")) - .where("User.mapModePreferences", "is not", null), - ).as("mapModePreferences"), - ), - ) - .where("Group.status", "=", "ACTIVE") - .where("GroupMatch.id", "is", null) - .where((eb) => - eb.or([ - eb( - "Group.latestActionAt", - ">", - sql`(unixepoch() - ${SECONDS_TILL_STALE})`, - ), - eb("Group.id", "=", ownGroupId ?? -1), - ]), - ) - .execute(); - - // TODO: a bit weird we filter chatCode here but not inviteCode and do some logic about filtering - return rows - .map((row) => { - return { - ...row, - chatCode: includeChatCode ? row.chatCode : undefined, - mapModePreferences: row.mapModePreferences?.map( - (c) => c.mapModePreferences, - ) as NonNullable[], - members: row.members.map((member) => { - return { - ...member, - languages: member.languages ? member.languages.split(",") : [], - } as LookingGroupWithInviteCode["members"][number]; - }), - }; - }) - .filter((group) => { - if (group.id === ownGroupId) return true; - if (maxGroupSize && group.members.length > maxGroupSize) return false; - if (minGroupSize && group.members.length < minGroupSize) return false; - - return true; - }); -} - -export async function findActiveGroupMembers() { - return db - .selectFrom("GroupMember") - .innerJoin("Group", "Group.id", "GroupMember.groupId") - .select("GroupMember.userId") - .where("Group.status", "!=", "INACTIVE") - .execute(); -} - -type CreateGroupArgs = { - status: Exclude; - userId: number; -}; -export function createGroup(args: CreateGroupArgs) { - return db.transaction().execute(async (trx) => { - const createdGroup = await trx - .insertInto("Group") - .values({ - inviteCode: shortNanoid(), - chatCode: shortNanoid(), - status: args.status, - }) - .returning("id") - .executeTakeFirstOrThrow(); - - await trx - .insertInto("GroupMember") - .values({ - groupId: createdGroup.id, - userId: args.userId, - role: "OWNER", - }) - .execute(); - - return createdGroup; - }); -} - -type CreateGroupFromPreviousGroupArgs = { - previousGroupId: number; - members: { - id: number; - role: Tables["GroupMember"]["role"]; - }[]; -}; -export async function createGroupFromPrevious( - args: CreateGroupFromPreviousGroupArgs, -) { - return db.transaction().execute(async (trx) => { - const createdGroup = await trx - .insertInto("Group") - .columns(["teamId", "chatCode", "inviteCode", "status"]) - .expression((eb) => - eb - .selectFrom("Group") - .select((eb) => [ - "Group.teamId", - "Group.chatCode", - eb.val(shortNanoid()).as("inviteCode"), - eb.val("PREPARING").as("status"), - ]) - .where("Group.id", "=", args.previousGroupId), - ) - .returning("id") - .executeTakeFirstOrThrow(); - - await trx - .insertInto("GroupMember") - .values( - args.members.map((member) => ({ - groupId: createdGroup.id, - userId: member.id, - role: member.role, - })), - ) - .execute(); - - return createdGroup; - }); -} - -export function rechallenge({ - likerGroupId, - targetGroupId, -}: { - likerGroupId: number; - targetGroupId: number; -}) { - return db - .updateTable("GroupLike") - .set({ isRechallenge: 1 }) - .where("likerGroupId", "=", likerGroupId) - .where("targetGroupId", "=", targetGroupId) - .execute(); -} - -export function upsertPrivateUserNote( - args: TablesInsertable["PrivateUserNote"], -) { - return db - .insertInto("PrivateUserNote") - .values({ - authorId: args.authorId, - targetId: args.targetId, - sentiment: args.sentiment, - text: args.text, - }) - .onConflict((oc) => - oc.columns(["authorId", "targetId"]).doUpdateSet({ - sentiment: args.sentiment, - text: args.text, - updatedAt: dateToDatabaseTimestamp(new Date()), - }), - ) - .execute(); -} - -export function deletePrivateUserNote({ - authorId, - targetId, -}: { - authorId: number; - targetId: number; -}) { - return db - .deleteFrom("PrivateUserNote") - .where("authorId", "=", authorId) - .where("targetId", "=", targetId) - .execute(); -} - -/** - * Retrieves information about users who have trusted the specified user, - * including their associated teams and explicit trust relationships. Banned users are excluded. - */ -export async function usersThatTrusted(userId: number) { - const teams = await db - .selectFrom("TeamMemberWithSecondary") - .innerJoin("Team", "Team.id", "TeamMemberWithSecondary.teamId") - .select(["Team.id", "Team.name", "TeamMemberWithSecondary.isMainTeam"]) - .where("userId", "=", userId) - .execute(); - - const rows = await db - .selectFrom("TeamMemberWithSecondary") - .innerJoin("User", "User.id", "TeamMemberWithSecondary.userId") - .innerJoin("UserFriendCode", "UserFriendCode.userId", "User.id") - .select([ - ...COMMON_USER_FIELDS, - "User.inGameName", - "TeamMemberWithSecondary.teamId", - ]) - .where( - "TeamMemberWithSecondary.teamId", - "in", - teams.map((t) => t.id), - ) - .union((eb) => - eb - .selectFrom("TrustRelationship") - .innerJoin("User", "User.id", "TrustRelationship.trustGiverUserId") - .innerJoin("UserFriendCode", "UserFriendCode.userId", "User.id") - .select([ - ...COMMON_USER_FIELDS, - "User.inGameName", - sql`null`.as("teamId"), - ]) - .where("TrustRelationship.trustReceiverUserId", "=", userId), - ) - .execute(); - - const rowsWithoutBanned = rows.filter((row) => !userIsBanned(row.id)); - - const teamMemberIds = rowsWithoutBanned - .filter((row) => row.teamId) - .map((row) => row.id); - - // we want user to show twice if member of two different teams - // but we don't want a user from the team to show in teamless section - const deduplicatedRows = rowsWithoutBanned.filter( - (row) => row.teamId || !teamMemberIds.includes(row.id), - ); - - // done here at not sql just because it was easier to do here ignoring case - deduplicatedRows.sort((a, b) => a.username.localeCompare(b.username)); - - return { - teams: teams.sort((a, b) => b.isMainTeam - a.isMainTeam), - trusters: deduplicatedRows, - }; -} - -/** Update the timestamp of the trust relationship, delaying its automatic deletion */ -export async function refreshTrust({ - trustGiverUserId, - trustReceiverUserId, -}: { - trustGiverUserId: number; - trustReceiverUserId: number; -}) { - return db - .updateTable("TrustRelationship") - .set({ lastUsedAt: databaseTimestampNow() }) - .where("trustGiverUserId", "=", trustGiverUserId) - .where("trustReceiverUserId", "=", trustReceiverUserId) - .execute(); -} - -export async function deleteOldTrust() { - const twoMonthsAgo = sub(new Date(), { months: 2 }); - - return db - .deleteFrom("TrustRelationship") - .where("lastUsedAt", "<", dateToDatabaseTimestamp(twoMonthsAgo)) - .executeTakeFirst(); -} - -export async function setOldGroupsAsInactive() { - const oneHourAgo = sub(new Date(), { hours: 1 }); - - return db.transaction().execute(async (trx) => { - const groupsToSetInactive = await trx - .selectFrom("Group") - .leftJoin("GroupMatch", (join) => - join.on((eb) => - eb.or([ - eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")), - eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")), - ]), - ), - ) - .select(["Group.id"]) - .where("status", "!=", "INACTIVE") - .where("GroupMatch.id", "is", null) - .where("latestActionAt", "<", dateToDatabaseTimestamp(oneHourAgo)) - .execute(); - - return trx - .updateTable("Group") - .set({ status: "INACTIVE" }) - .where( - "Group.id", - "in", - groupsToSetInactive.map((g) => g.id), - ) - .executeTakeFirst(); - }); -} - -export async function mapModePreferencesBySeasonNth(seasonNth: number) { - return db - .selectFrom("Skill") - .innerJoin("User", "User.id", "Skill.userId") - .select("User.mapModePreferences") - .where("Skill.season", "=", seasonNth) - .where("Skill.userId", "is not", null) - .where("User.mapModePreferences", "is not", null) - .groupBy("Skill.userId") - .$narrowType<{ mapModePreferences: UserMapModePreferences }>() - .execute(); -} diff --git a/app/features/sendouq/SQGroupRepository.server.ts b/app/features/sendouq/SQGroupRepository.server.ts new file mode 100644 index 000000000..60a44777f --- /dev/null +++ b/app/features/sendouq/SQGroupRepository.server.ts @@ -0,0 +1,694 @@ +import { sub } from "date-fns"; +import { type NotNull, sql, type Transaction } from "kysely"; +import { jsonArrayFrom, jsonBuildObject } from "kysely/helpers/sqlite"; +import { db } from "~/db/sql"; +import type { DB, Tables, UserMapModePreferences } from "~/db/tables"; +import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates"; +import { shortNanoid } from "~/utils/id"; +import invariant from "~/utils/invariant"; +import { + COMMON_USER_FIELDS, + userChatNameColorForJson, +} from "~/utils/kysely.server"; +import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql"; +import { userIsBanned } from "../ban/core/banned.server"; +import { FULL_GROUP_SIZE } from "./q-constants"; +import { SendouQError } from "./q-utils.server"; + +export function mapModePreferencesByGroupId(groupId: number) { + return db + .selectFrom("GroupMember") + .innerJoin("User", "User.id", "GroupMember.userId") + .select(["User.id as userId", "User.mapModePreferences as preferences"]) + .where("GroupMember.groupId", "=", groupId) + .where("User.mapModePreferences", "is not", null) + .$narrowType<{ preferences: NotNull }>() + .execute(); +} + +export async function findCurrentGroups() { + type SendouQMemberObject = { + id: Tables["User"]["id"]; + username: Tables["User"]["username"]; + discordId: Tables["User"]["discordId"]; + discordAvatar: Tables["User"]["discordAvatar"]; + customUrl: Tables["User"]["customUrl"]; + mapModePreferences: Tables["User"]["mapModePreferences"]; + noScreen: Tables["User"]["noScreen"]; + languages: Tables["User"]["languages"]; + vc: Tables["User"]["vc"]; + role: Tables["GroupMember"]["role"]; + weapons: Tables["User"]["qWeaponPool"]; + chatNameColor: string | null; + plusTier: Tables["PlusTier"]["tier"] | null; + }; + + return db + .selectFrom("Group") + .innerJoin("GroupMember", "GroupMember.groupId", "Group.id") + .innerJoin("User", "User.id", "GroupMember.userId") + .leftJoin("PlusTier", "PlusTier.userId", "User.id") + .leftJoin("GroupMatch", (join) => + join.on((eb) => + eb.or([ + eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")), + eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")), + ]), + ), + ) + .select(({ fn, eb }) => [ + "Group.id", + "Group.chatCode", + "Group.inviteCode", + "Group.latestActionAt", + "Group.chatCode", + "Group.inviteCode", + "Group.status", + "GroupMatch.id as matchId", + fn + .agg("json_group_array", [ + jsonBuildObject({ + id: eb.ref("User.id"), + username: eb.ref("User.username"), + discordId: eb.ref("User.discordId"), + discordAvatar: eb.ref("User.discordAvatar"), + customUrl: eb.ref("User.customUrl"), + mapModePreferences: eb.ref("User.mapModePreferences"), + noScreen: eb.ref("User.noScreen"), + role: eb.ref("GroupMember.role"), + weapons: eb.ref("User.qWeaponPool"), + languages: eb.ref("User.languages"), + plusTier: eb.ref("PlusTier.tier"), + vc: eb.ref("User.vc"), + chatNameColor: userChatNameColorForJson, + }), + ]) + .$castTo() + .as("members"), + ]) + .where((eb) => + eb.or([ + eb("Group.status", "=", "ACTIVE"), + eb("Group.status", "=", "PREPARING"), + ]), + ) + .groupBy("Group.id") + .execute(); +} + +export async function findActiveGroupMembers() { + return db + .selectFrom("GroupMember") + .innerJoin("Group", "Group.id", "GroupMember.groupId") + .select("GroupMember.userId") + .where("Group.status", "!=", "INACTIVE") + .execute(); +} + +type CreateGroupArgs = { + status: Exclude; + userId: number; +}; +export function createGroup(args: CreateGroupArgs) { + return db.transaction().execute(async (trx) => { + const createdGroup = await trx + .insertInto("Group") + .values({ + inviteCode: shortNanoid(), + chatCode: shortNanoid(), + status: args.status, + }) + .returning("id") + .executeTakeFirstOrThrow(); + + await trx + .insertInto("GroupMember") + .values({ + groupId: createdGroup.id, + userId: args.userId, + role: "OWNER", + }) + .execute(); + + if (!(await isGroupCorrect(createdGroup.id, trx))) { + throw new SendouQError("Group has a member in multiple groups"); + } + + return createdGroup; + }); +} + +type CreateGroupFromPreviousGroupArgs = { + previousGroupId: number; + members: { + id: number; + role: Tables["GroupMember"]["role"]; + }[]; +}; +export async function createGroupFromPrevious( + args: CreateGroupFromPreviousGroupArgs, +) { + return db.transaction().execute(async (trx) => { + const createdGroup = await trx + .insertInto("Group") + .columns(["teamId", "chatCode", "inviteCode", "status"]) + .expression((eb) => + eb + .selectFrom("Group") + .select((eb) => [ + "Group.teamId", + "Group.chatCode", + eb.val(shortNanoid()).as("inviteCode"), + eb.val("PREPARING").as("status"), + ]) + .where("Group.id", "=", args.previousGroupId), + ) + .returning("id") + .executeTakeFirstOrThrow(); + + await trx + .insertInto("GroupMember") + .values( + args.members.map((member) => ({ + groupId: createdGroup.id, + userId: member.id, + role: member.role, + })), + ) + .execute(); + + if (!(await isGroupCorrect(createdGroup.id, trx))) { + throw new SendouQError( + "Group has too many members or member in multiple groups", + ); + } + + return createdGroup; + }); +} + +function deleteLikesByGroupId(groupId: number, trx: Transaction) { + return trx + .deleteFrom("GroupLike") + .where((eb) => + eb.or([ + eb("GroupLike.likerGroupId", "=", groupId), + eb("GroupLike.targetGroupId", "=", groupId), + ]), + ) + .execute(); +} + +export function morphGroups({ + survivingGroupId, + otherGroupId, +}: { + survivingGroupId: number; + otherGroupId: number; +}) { + return db.transaction().execute(async (trx) => { + // reset chat code so previous messages are not visible + await trx + .updateTable("Group") + .set({ chatCode: shortNanoid() }) + .where("Group.id", "=", survivingGroupId) + .execute(); + + const otherGroupMembers = await trx + .selectFrom("GroupMember") + .select(["GroupMember.userId", "GroupMember.role"]) + .where("GroupMember.groupId", "=", otherGroupId) + .execute(); + + for (const member of otherGroupMembers) { + const oldRole = otherGroupMembers.find( + (m) => m.userId === member.userId, + )?.role; + invariant(oldRole, "Member lacking a role"); + + await trx + .updateTable("GroupMember") + .set({ + role: + oldRole === "OWNER" + ? "MANAGER" + : oldRole === "MANAGER" + ? "MANAGER" + : "REGULAR", + groupId: survivingGroupId, + }) + .where("GroupMember.groupId", "=", otherGroupId) + .where("GroupMember.userId", "=", member.userId) + .execute(); + } + + await deleteLikesByGroupId(survivingGroupId, trx); + await refreshGroup(survivingGroupId, trx); + + await trx + .deleteFrom("Group") + .where("Group.id", "=", otherGroupId) + .execute(); + + if (!(await isGroupCorrect(survivingGroupId, trx))) { + throw new SendouQError( + "Group has too many members or member in multiple groups", + ); + } + }); +} + +/** Check that the group has at most FULL_GROUP_SIZE members and each member is only in this group */ +async function isGroupCorrect( + groupId: number, + trx: Transaction, +): Promise { + const members = await trx + .selectFrom("GroupMember") + .select("GroupMember.userId") + .where("GroupMember.groupId", "=", groupId) + .execute(); + + if (members.length > FULL_GROUP_SIZE) { + return false; + } + + for (const member of members) { + const otherGroup = await trx + .selectFrom("GroupMember") + .innerJoin("Group", "Group.id", "GroupMember.groupId") + .select(["Group.id"]) + .where("GroupMember.userId", "=", member.userId) + .where("Group.status", "!=", "INACTIVE") + .where("GroupMember.groupId", "!=", groupId) + .executeTakeFirst(); + + if (otherGroup) { + return false; + } + } + + return true; +} + +export function addMember( + groupId: number, + { + userId, + role = "REGULAR", + }: { + userId: number; + role?: Tables["GroupMember"]["role"]; + }, +) { + return db.transaction().execute(async (trx) => { + await trx + .insertInto("GroupMember") + .values({ + groupId, + userId, + role, + }) + .execute(); + + await deleteLikesByGroupId(groupId, trx); + + if (!(await isGroupCorrect(groupId, trx))) { + throw new SendouQError( + "Group has too many members or member in multiple groups", + ); + } + }); +} + +export async function allLikesByGroupId(groupId: number) { + const rows = await db + .selectFrom("GroupLike") + .select([ + "GroupLike.likerGroupId", + "GroupLike.targetGroupId", + "GroupLike.isRechallenge", + ]) + .where((eb) => + eb.or([ + eb("GroupLike.likerGroupId", "=", groupId), + eb("GroupLike.targetGroupId", "=", groupId), + ]), + ) + .execute(); + + return { + given: rows + .filter((row) => row.likerGroupId === groupId) + .map((row) => ({ + groupId: row.targetGroupId, + isRechallenge: row.isRechallenge, + })), + received: rows + .filter((row) => row.targetGroupId === groupId) + .map((row) => ({ + groupId: row.likerGroupId, + isRechallenge: row.isRechallenge, + })), + }; +} + +export function rechallenge({ + likerGroupId, + targetGroupId, +}: { + likerGroupId: number; + targetGroupId: number; +}) { + return db + .updateTable("GroupLike") + .set({ isRechallenge: 1 }) + .where("likerGroupId", "=", likerGroupId) + .where("targetGroupId", "=", targetGroupId) + .execute(); +} + +/** + * Retrieves information about users who have trusted the specified user, + * including their associated teams and explicit trust relationships. Banned users are excluded. + */ +export async function usersThatTrusted(userId: number) { + const teams = await db + .selectFrom("TeamMemberWithSecondary") + .innerJoin("Team", "Team.id", "TeamMemberWithSecondary.teamId") + .select(["Team.id", "Team.name", "TeamMemberWithSecondary.isMainTeam"]) + .where("userId", "=", userId) + .execute(); + + const rows = await db + .selectFrom("TeamMemberWithSecondary") + .innerJoin("User", "User.id", "TeamMemberWithSecondary.userId") + .innerJoin("UserFriendCode", "UserFriendCode.userId", "User.id") + .select([ + ...COMMON_USER_FIELDS, + "User.inGameName", + "TeamMemberWithSecondary.teamId", + ]) + .where( + "TeamMemberWithSecondary.teamId", + "in", + teams.map((t) => t.id), + ) + .union((eb) => + eb + .selectFrom("TrustRelationship") + .innerJoin("User", "User.id", "TrustRelationship.trustGiverUserId") + .innerJoin("UserFriendCode", "UserFriendCode.userId", "User.id") + .select([ + ...COMMON_USER_FIELDS, + "User.inGameName", + sql`null`.as("teamId"), + ]) + .where("TrustRelationship.trustReceiverUserId", "=", userId), + ) + .execute(); + + const rowsWithoutBanned = rows.filter((row) => !userIsBanned(row.id)); + + const teamMemberIds = rowsWithoutBanned + .filter((row) => row.teamId) + .map((row) => row.id); + + // we want user to show twice if member of two different teams + // but we don't want a user from the team to show in teamless section + const deduplicatedRows = rowsWithoutBanned.filter( + (row) => row.teamId || !teamMemberIds.includes(row.id), + ); + + // done here at not sql just because it was easier to do here ignoring case + deduplicatedRows.sort((a, b) => a.username.localeCompare(b.username)); + + return { + teams: teams.sort((a, b) => b.isMainTeam - a.isMainTeam), + trusters: deduplicatedRows, + }; +} + +/** Update the timestamp of the trust relationship, delaying its automatic deletion */ +export async function refreshTrust({ + trustGiverUserId, + trustReceiverUserId, +}: { + trustGiverUserId: number; + trustReceiverUserId: number; +}) { + return db + .updateTable("TrustRelationship") + .set({ lastUsedAt: databaseTimestampNow() }) + .where("trustGiverUserId", "=", trustGiverUserId) + .where("trustReceiverUserId", "=", trustReceiverUserId) + .execute(); +} + +export async function deleteOldTrust() { + const twoMonthsAgo = sub(new Date(), { months: 2 }); + + return db + .deleteFrom("TrustRelationship") + .where("lastUsedAt", "<", dateToDatabaseTimestamp(twoMonthsAgo)) + .executeTakeFirst(); +} + +export async function setOldGroupsAsInactive() { + const oneHourAgo = sub(new Date(), { hours: 1 }); + + return db.transaction().execute(async (trx) => { + const groupsToSetInactive = await trx + .selectFrom("Group") + .leftJoin("GroupMatch", (join) => + join.on((eb) => + eb.or([ + eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")), + eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")), + ]), + ), + ) + .select(["Group.id"]) + .where("status", "!=", "INACTIVE") + .where("GroupMatch.id", "is", null) + .where("latestActionAt", "<", dateToDatabaseTimestamp(oneHourAgo)) + .execute(); + + return trx + .updateTable("Group") + .set({ status: "INACTIVE" }) + .where( + "Group.id", + "in", + groupsToSetInactive.map((g) => g.id), + ) + .executeTakeFirst(); + }); +} + +export async function mapModePreferencesBySeasonNth(seasonNth: number) { + return db + .selectFrom("Skill") + .innerJoin("User", "User.id", "Skill.userId") + .select("User.mapModePreferences") + .where("Skill.season", "=", seasonNth) + .where("Skill.userId", "is not", null) + .where("User.mapModePreferences", "is not", null) + .groupBy("Skill.userId") + .$narrowType<{ mapModePreferences: UserMapModePreferences }>() + .execute(); +} + +export async function findRecentlyFinishedMatches() { + const twoHoursAgo = sub(new Date(), { hours: 2 }); + + const rows = await db + .selectFrom("GroupMatch") + .select((eb) => [ + jsonArrayFrom( + eb + .selectFrom("GroupMember") + .select("GroupMember.userId") + .whereRef("GroupMember.groupId", "=", "GroupMatch.alphaGroupId"), + ).as("groupAlphaMemberIds"), + jsonArrayFrom( + eb + .selectFrom("GroupMember") + .select("GroupMember.userId") + .whereRef("GroupMember.groupId", "=", "GroupMatch.bravoGroupId"), + ).as("groupBravoMemberIds"), + ]) + .where("GroupMatch.reportedAt", "is not", null) + .where("GroupMatch.reportedAt", ">", dateToDatabaseTimestamp(twoHoursAgo)) + .execute(); + + return rows.map((row) => ({ + groupAlphaMemberIds: row.groupAlphaMemberIds.map((m) => m.userId), + groupBravoMemberIds: row.groupBravoMemberIds.map((m) => m.userId), + })); +} + +export function addLike({ + likerGroupId, + targetGroupId, +}: { + likerGroupId: number; + targetGroupId: number; +}) { + return db.transaction().execute(async (trx) => { + try { + await trx + .insertInto("GroupLike") + .values({ likerGroupId, targetGroupId }) + .onConflict((oc) => + oc.columns(["likerGroupId", "targetGroupId"]).doNothing(), + ) + .execute(); + } catch (error) { + if (errorIsSqliteForeignKeyConstraintFailure(error)) { + throw new SendouQError(error.message); + } + throw error; + } + + await refreshGroup(likerGroupId, trx); + }); +} + +export function deleteLike({ + likerGroupId, + targetGroupId, +}: { + likerGroupId: number; + targetGroupId: number; +}) { + return db.transaction().execute(async (trx) => { + await trx + .deleteFrom("GroupLike") + .where("likerGroupId", "=", likerGroupId) + .where("targetGroupId", "=", targetGroupId) + .execute(); + + await refreshGroup(likerGroupId, trx); + }); +} + +export function leaveGroup(userId: number) { + return db.transaction().execute(async (trx) => { + const userGroup = await trx + .selectFrom("GroupMember") + .innerJoin("Group", "Group.id", "GroupMember.groupId") + .select(["Group.id", "GroupMember.role"]) + .where("userId", "=", userId) + .where("Group.status", "!=", "INACTIVE") + .executeTakeFirstOrThrow(); + + await trx + .deleteFrom("GroupMember") + .where("userId", "=", userId) + .where("GroupMember.groupId", "=", userGroup.id) + .execute(); + + const remainingMembers = await trx + .selectFrom("GroupMember") + .select(["userId", "role"]) + .where("groupId", "=", userGroup.id) + .execute(); + + if (remainingMembers.length === 0) { + await trx.deleteFrom("Group").where("id", "=", userGroup.id).execute(); + return; + } + + if (userGroup.role === "OWNER") { + const newOwner = + remainingMembers.find((m) => m.role === "MANAGER") ?? + remainingMembers[0]; + + await trx + .updateTable("GroupMember") + .set({ role: "OWNER" }) + .where("userId", "=", newOwner.userId) + .where("groupId", "=", userGroup.id) + .execute(); + } + + const match = await trx + .selectFrom("GroupMatch") + .select(["GroupMatch.id"]) + .where((eb) => + eb.or([ + eb("alphaGroupId", "=", userGroup.id), + eb("bravoGroupId", "=", userGroup.id), + ]), + ) + .executeTakeFirst(); + + if (match) { + throw new SendouQError("Can't leave group when already in a match"); + } + }); +} + +export function refreshGroup(groupId: number, trx?: Transaction) { + return (trx ?? db) + .updateTable("Group") + .set({ latestActionAt: databaseTimestampNow() }) + .where("Group.id", "=", groupId) + .execute(); +} + +export function updateMemberNote({ + groupId, + userId, + value, +}: { + groupId: number; + userId: number; + value: string | null; +}) { + return db.transaction().execute(async (trx) => { + await trx + .updateTable("GroupMember") + .set({ note: value }) + .where("groupId", "=", groupId) + .where("userId", "=", userId) + .execute(); + + await refreshGroup(groupId, trx); + }); +} + +export function updateMemberRole({ + userId, + groupId, + role, +}: { + userId: number; + groupId: number; + role: Tables["GroupMember"]["role"]; +}) { + if (role === "OWNER") { + throw new Error("Can't set role to OWNER with this function"); + } + + return db.transaction().execute(async (trx) => { + await trx + .updateTable("GroupMember") + .set({ role }) + .where("userId", "=", userId) + .where("groupId", "=", groupId) + .execute(); + + await refreshGroup(groupId, trx); + }); +} + +export function setPreparingGroupAsActive(groupId: number) { + return db + .updateTable("Group") + .set({ status: "ACTIVE", latestActionAt: databaseTimestampNow() }) + .where("id", "=", groupId) + .where("status", "=", "PREPARING") + .execute(); +} diff --git a/app/features/sendouq/actions/q.looking.server.ts b/app/features/sendouq/actions/q.looking.server.ts index e99ee7940..e9e9eee3f 100644 --- a/app/features/sendouq/actions/q.looking.server.ts +++ b/app/features/sendouq/actions/q.looking.server.ts @@ -3,41 +3,20 @@ 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 type { LookingGroupWithInviteCode } from "~/features/sendouq/q-types"; +import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; import { createMatchMemento, matchMapList, } from "~/features/sendouq-match/core/match.server"; -import invariant from "~/utils/invariant"; -import { logger } from "~/utils/logger"; -import { - errorToast, - errorToastIfFalsy, - parseRequestPayload, -} from "~/utils/remix.server"; -import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql"; +import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server"; +import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; import { SENDOUQ_PAGE, sendouQMatchPage } from "~/utils/urls"; import { groupAfterMorph } from "../core/groups"; -import { membersNeededForFull } from "../core/groups.server"; -import { FULL_GROUP_SIZE } from "../q-constants"; +import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server"; +import * as PrivateUserNoteRepository from "../PrivateUserNoteRepository.server"; 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"; +import { SendouQError } from "../q-utils.server"; // this function doesn't throw normally because we are assuming // if there is a validation error the user saw stale data @@ -48,362 +27,278 @@ export const action: ActionFunction = async ({ request }) => { request, schema: lookingSchema, }); - const currentGroup = findCurrentGroupByUserId(user.id); + const currentGroup = SendouQ.findOwnGroup(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"; + try { + // this throws because there should normally be no way user loses ownership by the action of some other user + const validateIsGroupOwner = () => + errorToastIfFalsy(currentGroup.usersRole === "OWNER", "Not owner"); + const isGroupManager = () => + currentGroup.usersRole === "MANAGER" || + currentGroup.usersRole === "OWNER"; - switch (data._action) { - case "LIKE": { - if (!isGroupManager()) return null; + switch (data._action) { + case "LIKE": { + if (!isGroupManager()) return null; - try { - addLike({ + await SQGroupRepository.addLike({ likerGroupId: currentGroup.id, targetGroupId: data.targetGroupId, }); - } catch (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", + const targetChatCode = SendouQ.findUncensoredGroupById( + data.targetGroupId, + )?.chatCode; + if (targetChatCode) { + ChatSystemMessage.send({ + room: targetChatCode, + type: "LIKE_RECEIVED", revalidateOnly: true, - }, - { - room: theirGroup.chatCode, - type: "NEW_GROUP", - revalidateOnly: true, - }, - ]); + }); + } + + break; } + case "RECHALLENGE": { + if (!isGroupManager()) return null; - 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 = await matchMapList( - { - id: ourGroup.id, - preferences: ourGroupPreferences, - }, - { - id: theirGroup.id, - preferences: theirGroupPreferences, - ignoreModePreferences: data._action === "MATCH_UP_RECHALLENGE", - }, - ); - - const memberInManyGroups = verifyNoMemberInTwoGroups( - [...ourGroup.members, ...theirGroup.members], - lookingGroups, - ); - if (memberInManyGroups) { - logger.error("User in two groups preventing match creation", { - userId: memberInManyGroups.id, + await SQGroupRepository.rechallenge({ + likerGroupId: currentGroup.id, + targetGroupId: data.targetGroupId, }); - errorToast( - `${memberInManyGroups.username} is in two groups so match can't be started`, + const targetChatCode = SendouQ.findUncensoredGroupById( + data.targetGroupId, + )?.chatCode; + if (targetChatCode) { + ChatSystemMessage.send({ + room: targetChatCode, + type: "LIKE_RECEIVED", + revalidateOnly: true, + }); + } + break; + } + case "UNLIKE": { + if (!isGroupManager()) return null; + + await SQGroupRepository.deleteLike({ + likerGroupId: currentGroup.id, + targetGroupId: data.targetGroupId, + }); + + break; + } + case "GROUP_UP": { + if (!isGroupManager()) return null; + + const allLikes = await SQGroupRepository.allLikesByGroupId( + data.targetGroupId, ); - } + if (!allLikes.given.some((like) => like.groupId === currentGroup.id)) { + return null; + } - const createdMatch = createMatch({ - alphaGroupId: ourGroup.id, - bravoGroupId: theirGroup.id, - mapList, - memento: createMatchMemento({ - own: { group: ourGroup, preferences: ourGroupPreferences }, - their: { group: theirGroup, preferences: theirGroupPreferences }, - mapList, - }), - }); + const ourGroup = SendouQ.findOwnGroup(user.id); + const theirGroup = SendouQ.findUncensoredGroupById(data.targetGroupId); + if (!ourGroup || !theirGroup) return null; - 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 }, + const { id: survivingGroupId } = groupAfterMorph({ + liker: "THEM", + ourGroup, + theirGroup, }); + + const otherGroup = + ourGroup.id === survivingGroupId ? theirGroup : ourGroup; + + await SQGroupRepository.morphGroups({ + survivingGroupId, + otherGroupId: otherGroup.id, + }); + + await refreshSendouQInstance(); + + 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; - throw redirect(SENDOUQ_PAGE); - } - case "KICK_FROM_GROUP": { - validateIsGroupOwner(); - errorToastIfFalsy(data.userId !== user.id, "Can't kick yourself"); + const ourGroup = SendouQ.findOwnGroup(user.id); + const theirGroup = SendouQ.findUncensoredGroupById(data.targetGroupId); + if (!ourGroup || !theirGroup) return null; - leaveGroup({ - groupId: currentGroup.id, - userId: data.userId, - newOwnerId: null, - wasOwner: false, - }); + const ourGroupPreferences = + await SQGroupRepository.mapModePreferencesByGroupId(ourGroup.id); + const theirGroupPreferences = + await SQGroupRepository.mapModePreferencesByGroupId(theirGroup.id); + const mapList = await matchMapList( + { + id: ourGroup.id, + preferences: ourGroupPreferences, + }, + { + id: theirGroup.id, + preferences: theirGroupPreferences, + ignoreModePreferences: data._action === "MATCH_UP_RECHALLENGE", + }, + ); - break; - } - case "REFRESH_GROUP": { - refreshGroup(currentGroup.id); + const createdMatch = await SQMatchRepository.create({ + alphaGroupId: ourGroup.id, + bravoGroupId: theirGroup.id, + mapList, + memento: createMatchMemento({ + own: { group: ourGroup, preferences: ourGroupPreferences }, + their: { group: theirGroup, preferences: theirGroupPreferences }, + mapList, + }), + }); - break; - } - case "UPDATE_NOTE": { - updateNote({ - note: data.value, - groupId: currentGroup.id, - userId: user.id, - }); - refreshGroup(currentGroup.id); + await refreshSendouQInstance(); - break; - } - case "DELETE_PRIVATE_USER_NOTE": { - await QRepository.deletePrivateUserNote({ - authorId: user.id, - targetId: data.targetId, - }); + if (ourGroup.chatCode && theirGroup.chatCode) { + ChatSystemMessage.send([ + { + room: ourGroup.chatCode, + type: "MATCH_STARTED", + revalidateOnly: true, + }, + { + room: theirGroup.chatCode, + type: "MATCH_STARTED", + revalidateOnly: true, + }, + ]); + } - break; + 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(); + + await SQGroupRepository.updateMemberRole({ + groupId: currentGroup.id, + userId: data.userId, + role: "MANAGER", + }); + + await refreshSendouQInstance(); + + break; + } + case "REMOVE_MANAGER": { + validateIsGroupOwner(); + + await SQGroupRepository.updateMemberRole({ + groupId: currentGroup.id, + userId: data.userId, + role: "REGULAR", + }); + + await refreshSendouQInstance(); + + break; + } + case "LEAVE_GROUP": { + await SQGroupRepository.leaveGroup(user.id); + + await refreshSendouQInstance(); + + const targetChatCode = SendouQ.findUncensoredGroupById( + currentGroup.id, + )?.chatCode; + 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"); + + await SQGroupRepository.leaveGroup(data.userId); + + await refreshSendouQInstance(); + + break; + } + case "REFRESH_GROUP": { + await SQGroupRepository.refreshGroup(currentGroup.id); + + await refreshSendouQInstance(); + + break; + } + case "UPDATE_NOTE": { + await SQGroupRepository.updateMemberNote({ + groupId: currentGroup.id, + userId: user.id, + value: data.value, + }); + + await refreshSendouQInstance(); + + break; + } + case "DELETE_PRIVATE_USER_NOTE": { + await PrivateUserNoteRepository.del({ + authorId: user.id, + targetId: data.targetId, + }); + + break; + } + default: { + assertUnreachable(data); + } } - default: { - assertUnreachable(data); + + return null; + } catch (error) { + // some errors are expected to happen, for example they might request two groups at the same time + // then after morphing one group the other request fails because the group no longer exists + // return null causes loaders to run and they see the fresh state again instead of error page + if (error instanceof SendouQError) { + return null; } + + throw error; } - - return null; }; - -/** Sanity check that no member is in two groups due to a bug or race condition. - * - * @returns null if no member is in two groups, otherwise return the problematic member - */ -function verifyNoMemberInTwoGroups( - members: LookingGroupWithInviteCode["members"], - allGroups: LookingGroupWithInviteCode[], -) { - for (const member of members) { - if ( - allGroups.filter((group) => group.members.some((m) => m.id === member.id)) - .length > 1 - ) { - return member; - } - } - - return null; -} diff --git a/app/features/sendouq/actions/q.preparing.server.ts b/app/features/sendouq/actions/q.preparing.server.ts index 8146c624e..37a7e2ec7 100644 --- a/app/features/sendouq/actions/q.preparing.server.ts +++ b/app/features/sendouq/actions/q.preparing.server.ts @@ -3,19 +3,12 @@ import { redirect } from "@remix-run/node"; import { requireUser } from "~/features/auth/core/user.server"; import * as Seasons from "~/features/mmr/core/Seasons"; import { notify } from "~/features/notifications/core/notify.server"; -import * as QRepository from "~/features/sendouq/QRepository.server"; -import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server"; -import invariant from "~/utils/invariant"; +import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; 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 { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server"; 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; @@ -26,10 +19,11 @@ export const action = async ({ request }: ActionFunctionArgs) => { schema: preparingSchema, }); - const currentGroup = findCurrentGroupByUserId(user.id); - errorToastIfFalsy(currentGroup, "No group found"); + const ownGroup = SendouQ.findOwnGroup(user.id); + errorToastIfFalsy(ownGroup, "No group found"); - if (!hasGroupManagerPerms(currentGroup.role)) { + // no perms, possibly just lost them so no more graceful degradation + if (ownGroup.usersRole === "REGULAR") { return null; } @@ -38,48 +32,37 @@ export const action = async ({ request }: ActionFunctionArgs) => { switch (data._action) { case "JOIN_QUEUE": { - if (currentGroup.status !== "PREPARING") { - return null; - } + await SQGroupRepository.setPreparingGroupAsActive(ownGroup.id); - setGroupAsActive(currentGroup.id); - refreshGroup(currentGroup.id); + await refreshSendouQInstance(); return redirect(SENDOUQ_LOOKING_PAGE); } case "ADD_TRUSTED": { - const available = await QRepository.findActiveGroupMembers(); + const available = await SQGroupRepository.findActiveGroupMembers(); if (available.some(({ userId }) => userId === data.id)) { return { error: "taken" } as const; } errorToastIfFalsy( - (await QRepository.usersThatTrusted(user.id)).trusters.some( + (await SQGroupRepository.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, + await SQGroupRepository.addMember(ownGroup.id, { userId: data.id, role: "MANAGER", }); - await QRepository.refreshTrust({ + await SQGroupRepository.refreshTrust({ trustGiverUserId: data.id, trustReceiverUserId: user.id, }); + await refreshSendouQInstance(); + notify({ userIds: [data.id], notification: { diff --git a/app/features/sendouq/actions/q.server.ts b/app/features/sendouq/actions/q.server.ts index 3b089c6f4..b2f9fcc79 100644 --- a/app/features/sendouq/actions/q.server.ts +++ b/app/features/sendouq/actions/q.server.ts @@ -1,11 +1,10 @@ import type { ActionFunction } from "@remix-run/node"; import { redirect } from "@remix-run/node"; -import { sql } from "~/db/sql"; import * as AdminRepository from "~/features/admin/AdminRepository.server"; import { requireUser } from "~/features/auth/core/user.server"; import { refreshBannedCache } from "~/features/ban/core/banned.server"; import * as Seasons from "~/features/mmr/core/Seasons"; -import * as QRepository from "~/features/sendouq/QRepository.server"; +import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; import { giveTrust } from "~/features/tournament/queries/giveTrust.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import invariant from "~/utils/invariant"; @@ -16,13 +15,10 @@ import { SENDOUQ_PREPARING_PAGE, SUSPENDED_PAGE, } from "~/utils/urls"; -import { FULL_GROUP_SIZE, JOIN_CODE_SEARCH_PARAM_KEY } from "../q-constants"; +import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server"; +import { 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); @@ -35,11 +31,13 @@ export const action: ActionFunction = async ({ request }) => { case "JOIN_QUEUE": { await validateCanJoinQ(user); - await QRepository.createGroup({ + await SQGroupRepository.createGroup({ status: data.direct === "true" ? "ACTIVE" : "PREPARING", userId: user.id, }); + await refreshSendouQInstance(); + return redirect( data.direct === "true" ? SENDOUQ_LOOKING_PAGE : SENDOUQ_PREPARING_PAGE, ); @@ -53,34 +51,28 @@ export const action: ActionFunction = async ({ request }) => { ); const groupInvitedTo = - code && user ? findGroupByInviteCode(code) : undefined; + code && user ? SendouQ.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", + await SQGroupRepository.addMember(groupInvitedTo.id, { + userId: user.id, + role: "MANAGER", + }); + + 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, }); - 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, - }); - } - })(); + await refreshSendouQInstance(); return redirect( groupInvitedTo.status === "PREPARING" @@ -132,6 +124,5 @@ async function validateCanJoinQ(user: { id: number; discordId: string }) { const canJoinQueue = userCanJoinQueueAt(user, friendCode) === "NOW"; errorToastIfFalsy(Seasons.current(), "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/GroupCard.module.css b/app/features/sendouq/components/GroupCard.module.css new file mode 100644 index 000000000..69b54673c --- /dev/null +++ b/app/features/sendouq/components/GroupCard.module.css @@ -0,0 +1,174 @@ +.group { + background-color: var(--bg-lighter-solid); + width: 100%; + border-radius: var(--rounded); + padding: var(--s-2-5); + display: flex; + flex-direction: column; + gap: var(--s-4); + position: relative; + color: var(--text); +} + +.noScreen { + background-color: var(--theme-error-transparent); + border-radius: 100%; + padding: var(--s-1); + width: 30px; + height: 30px; + display: grid; + place-items: center; +} + +.displayOnly { + height: 100%; + padding-block-end: var(--s-10); +} + +.member { + display: flex; + gap: var(--s-2); + align-items: center; + background-color: var(--bg-darker); + border-radius: var(--rounded); + font-size: var(--fonts-xsm); + font-weight: var(--semi-bold); + padding-inline-end: var(--s-2-5); +} + +.name { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + max-width: 7.5rem; + font-size: var(--fonts-xs); + color: var(--text); +} + +.avatar { + min-width: 36px; +} + +.avatarPositive { + outline: 2px solid var(--theme-success-transparent); +} + +.avatarNeutral { + outline: 2px solid var(--theme-warning-transparent); +} + +.avatarNegative { + outline: 2px solid var(--theme-error-transparent); +} + +.tier { + margin-inline-start: auto; +} + +.tierPlaceholder { + min-width: 26.58px; +} + +.extraInfo { + font-size: var(--fonts-xs); + background-color: var(--bg-darker); + border-radius: var(--rounded); + padding: var(--s-0-5) var(--s-1-5); + width: max-content; + display: flex; + align-items: center; + gap: var(--s-1); + font-weight: var(--semi-bold); + min-height: 24px; +} + +.extraInfoButton { + font-size: var(--fonts-xs); + background-color: var(--bg-darker); + border-radius: var(--rounded); + padding: var(--s-0-5) var(--s-1-5); + width: max-content; + display: flex; + align-items: center; + gap: var(--s-1); + font-weight: var(--semi-bold); + color: var(--text); + border: none; + min-height: 24px; +} + +.addNoteButton { + border: none; + padding: 0 var(--s-1-5); + color: var(--body); + font-size: var(--fonts-xxs); + font-weight: var(--semi-bold); + background-color: var(--bg-darker); + white-space: nowrap; +} + +.addNoteButtonEdit > svg { + color: var(--theme); +} + +.addNoteButton > svg { + width: 14px; + margin-inline-end: var(--s-1); +} + +.noteTextarea { + height: 4rem !important; +} + +.futureMatchMode { + border-radius: 100%; + background-color: var(--bg-lightest); + height: 30px; + width: 30px; + display: grid; + place-items: center; + padding: var(--s-1-5); +} + +.vcIcon { + height: 15px; + stroke-width: 2; +} + +.star { + min-width: 18px; + max-width: 18px; + color: var(--theme-secondary); + stroke-width: 2; +} + +.starInactive { + color: var(--text-lighter); +} + +.displayTier { + display: flex; + gap: var(--s-1); + align-items: center; + position: absolute; + border-radius: var(--rounded); + background-color: var(--bg-darker); + padding: var(--s-0-5) var(--s-2-5); + font-size: var(--fonts-xs); + font-weight: var(--semi-bold); + bottom: -36px; + left: 50%; + transform: translate(-50%, -50%); +} + +.popoverButton { + background-color: transparent; + color: var(--text-lighter); + font-size: var(--fonts-xs); + padding: 0; + border: none; + text-decoration: underline; + text-decoration-style: dotted; + font-weight: var(--bold); + height: 19.8281px; +} diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index dc54782f0..7a8b194b7 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -17,14 +17,13 @@ import { StarIcon } from "~/components/icons/Star"; import { StarFilledIcon } from "~/components/icons/StarFilled"; import { TrashIcon } from "~/components/icons/Trash"; import { SubmitButton } from "~/components/SubmitButton"; -import type { ParsedMemento, Tables } from "~/db/tables"; +import type { ParsedMemento } from "~/db/tables"; import { useUser } from "~/features/auth/core/user"; import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants"; import { ordinalToRoundedSp } from "~/features/mmr/mmr-utils"; import type { TieredSkill } from "~/features/mmr/tiered.server"; import { useTimeFormat } from "~/hooks/useTimeFormat"; import { languagesUnified } from "~/modules/i18n/config"; -import type { ModeShort } from "~/modules/in-game-lists/types"; import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids"; import { databaseTimestampToDate } from "~/utils/dates"; import { inGameNameWithoutDiscriminator } from "~/utils/strings"; @@ -36,35 +35,37 @@ import { tierImageUrl, userPage, } from "~/utils/urls"; +import type { + SQGroup, + SQGroupMember, + SQMatchGroup, + SQMatchGroupMember, + SQOwnGroup, +} from "../core/SendouQ.server"; import { FULL_GROUP_SIZE, SENDOUQ } from "../q-constants"; -import type { LookingGroup } from "../q-types"; +import { resolveFutureMatchModes } from "../q-utils"; +import styles from "./GroupCard.module.css"; export function GroupCard({ group, action, - ownRole, - ownGroup = false, - isExpired = false, displayOnly = false, hideVc = false, hideWeapons = false, hideNote: _hidenote = false, - enableKicking, showAddNote, showNote = false, + ownGroup, }: { - group: Omit; + group: SQGroup | SQOwnGroup | SQMatchGroup; action?: "LIKE" | "UNLIKE" | "GROUP_UP" | "MATCH_UP" | "MATCH_UP_RECHALLENGE"; - ownRole?: Tables["GroupMember"]["role"] | "PREVIEWER"; - ownGroup?: boolean; - isExpired?: boolean; displayOnly?: boolean; hideVc?: SqlBool; hideWeapons?: SqlBool; hideNote?: boolean; - enableKicking?: boolean; showAddNote?: SqlBool; showNote?: boolean; + ownGroup?: SQOwnGroup; }) { const { t } = useTranslation(["q"]); const user = useUser(); @@ -76,10 +77,22 @@ export function GroupCard({ group.members.length === FULL_GROUP_SIZE || _hidenote; + const isOwnGroup = group.id === ownGroup?.id; + + const futureMatchModes = ownGroup + ? resolveFutureMatchModes({ + ownGroup, + theirGroup: group, + }) + : null; + + const enableKicking = group.usersRole === "OWNER" && !displayOnly; + return ( - +
{group.members ? (
@@ -87,7 +100,7 @@ export function GroupCard({ return ( ) : null} - {group.futureMatchModes && !group.members ? ( + {futureMatchModes && !group.members ? (
- {group.futureMatchModes.map((mode) => { + {futureMatchModes.map((mode) => { return ( -
+
); })}
- {group.isNoScreen ? ( -
+ {group.noScreen ? ( +
) : null} - {group.tier && !displayOnly ? ( + {group.tier && !displayOnly && !group.members ? (
@@ -152,26 +159,26 @@ export function GroupCard({
) : null} - {group.tier && displayOnly ? ( -
+ {group.tier && displayOnly && !group.members ? ( +
{group.tier.name} {group.tier.isPlus ? "+" : ""}
) : null} - {group.tierRange?.range ? ( + {group.tierRange ? (
- (-{group.tierRange.diff}) + ({group.tierRange.diff[0]})
+ {t("q:looking.range.or")} } @@ -181,7 +188,7 @@ export function GroupCard({
- (+{group.tierRange.diff}) + (+{group.tierRange.diff[1]})
@@ -196,8 +203,8 @@ export function GroupCard({ ) : null} {action && - (ownRole === "OWNER" || ownRole === "MANAGER") && - !isExpired ? ( + (ownGroup?.usersRole === "OWNER" || + ownGroup?.usersRole === "MANAGER") ? ( ) : null} - {!group.isRechallenge && - group.rechallengeMatchModes && - (ownRole === "OWNER" || ownRole === "MANAGER") && - !isExpired ? ( - - ) : null}
); } function GroupCardContainer({ - ownGroup, + isOwnGroup, groupId, children, }: { - ownGroup: boolean; + isOwnGroup: boolean; groupId: number; children: React.ReactNode; }) { // we don't want it to animate - if (ownGroup) return <>{children}; + if (isOwnGroup) return <>{children}; return {children}; } @@ -258,7 +256,7 @@ function GroupMember({ showAddNote, showNote, }: { - member: NonNullable[number]; + member: SQGroupMember | SQMatchGroupMember; showActions: boolean; displayOnly?: boolean; hideVc?: SqlBool; @@ -273,8 +271,8 @@ function GroupMember({ const { formatDateTime } = useTimeFormat(); return ( -
-
+
+
{showNote && member.privateNote ? ( @@ -319,7 +319,7 @@ function GroupMember({ ) : ( )} - + {member.inGameName ? ( <> @@ -346,12 +346,12 @@ function GroupMember({
{member.vc && !hideVc ? ( -
+
) : null} {member.plusTier ? ( -
+
{member.plusTier}
@@ -359,7 +359,7 @@ function GroupMember({ {member.friendCode ? ( + FC } @@ -371,8 +371,8 @@ function GroupMember({ } - className={clsx("q__group-member__add-note-button", { - "q__group-member__add-note-button__edit": member.privateNote, + className={clsx(styles.addNoteButton, { + [styles.addNoteButtonEdit]: member.privateNote, })} > {member.privateNote @@ -382,7 +382,7 @@ function GroupMember({ ) : null}
{member.weapons && member.weapons.length > 0 && !hideWeapons ? ( -
+
{member.weapons?.map((weapon) => { return ( {!hideNote ? ( - + ) : null}
); @@ -485,7 +488,7 @@ function AddPrivateNoteForm({ value={value} onChange={(e) => setValue(e.target.value)} rows={2} - className="q__group-member__note-textarea mt-1" + className={`${styles.noteTextarea} mt-1`} name="value" ref={textareaRef} /> @@ -515,36 +518,6 @@ function AddPrivateNoteForm({ ); } -function RechallengeForm({ - modes, - targetGroupId, -}: { - modes: ModeShort[]; - targetGroupId: number; -}) { - const { t } = useTranslation(["q"]); - const fetcher = useFetcher(); - - return ( - - - - {t("q:looking.groups.actions.rechallenge")} -
- {modes.map((mode) => ( - - ))} -
-
-
- ); -} - function DeletePrivateNoteForm({ targetId, name, @@ -622,7 +595,7 @@ function MemberSkillDifference({ ); return ( -
+
{symbol} {Math.abs(skillDifference.spDiff)}SP
@@ -631,7 +604,7 @@ function MemberSkillDifference({ if (skillDifference.matchesCount === skillDifference.matchesCountNeeded) { return ( -
+
{t("q:looking.sp.calculated")}:{" "} {skillDifference.newSp ? <>{skillDifference.newSp}SP : null}
@@ -639,7 +612,7 @@ function MemberSkillDifference({ } return ( -
+
{t("q:looking.sp.calculating")} ( {skillDifference.matchesCount}/{skillDifference.matchesCountNeeded})
@@ -651,7 +624,7 @@ function MemberRoleManager({ displayOnly, enableKicking, }: { - member: NonNullable[number]; + member: Pick; displayOnly?: boolean; enableKicking?: boolean; }) { @@ -669,8 +642,8 @@ function MemberRoleManager({ variant="minimal" icon={ } @@ -728,7 +701,7 @@ function TierInfo({ skill }: { skill: TieredSkill | "CALCULATING" }) { if (skill === "CALCULATING") { return ( -
+
@@ -736,7 +709,7 @@ function TierInfo({ skill }: { skill: TieredSkill | "CALCULATING" }) { path={tierImageUrl("CALCULATING")} alt="" height={32.965} - className="q__group-member__tier__placeholder" + className={styles.tierPlaceholder} /> } @@ -750,7 +723,7 @@ function TierInfo({ skill }: { skill: TieredSkill | "CALCULATING" }) { } return ( -
+
@@ -785,7 +758,7 @@ function TierInfo({ skill }: { skill: TieredSkill | "CALCULATING" }) { function VoiceChatInfo({ member, }: { - member: NonNullable[number]; + member: Pick; }) { const user = useUser(); const { t } = useTranslation(["q"]); @@ -830,7 +803,7 @@ function VoiceChatInfo({ } + icon={} /> } > diff --git a/app/features/sendouq/components/MemberAdder.module.css b/app/features/sendouq/components/MemberAdder.module.css new file mode 100644 index 000000000..7223ee1f4 --- /dev/null +++ b/app/features/sendouq/components/MemberAdder.module.css @@ -0,0 +1,4 @@ +.input { + --input-width: 11rem; + width: 11rem; +} diff --git a/app/features/sendouq/components/MemberAdder.tsx b/app/features/sendouq/components/MemberAdder.tsx index f511043e3..64e58df95 100644 --- a/app/features/sendouq/components/MemberAdder.tsx +++ b/app/features/sendouq/components/MemberAdder.tsx @@ -14,6 +14,7 @@ import { sendouQInviteLink, } from "~/utils/urls"; import type { SendouQPreparingAction } from "../actions/q.preparing.server"; +import styles from "./MemberAdder.module.css"; export function MemberAdder({ inviteCode, @@ -56,7 +57,7 @@ export function MemberAdder({ value={inviteLink} readOnly id="invite" - className="q__member-adder__input" + className={styles.input} /> - ); + return