diff --git a/app/features/admin/AdminRepository.server.ts b/app/features/admin/AdminRepository.server.ts index 642b4455c..f27440e5c 100644 --- a/app/features/admin/AdminRepository.server.ts +++ b/app/features/admin/AdminRepository.server.ts @@ -1,10 +1,10 @@ import type { Transaction } from "kysely"; import { db, sql } from "~/db/sql"; import type { DB, Tables, TablesInsertable } from "~/db/tables"; +import * as BadgeRepository from "~/features/badges/BadgeRepository.server"; import * as BuildRepository from "~/features/builds/BuildRepository.server"; import { dateToDatabaseTimestamp } from "~/utils/dates"; import invariant from "~/utils/invariant"; -import { syncXPBadges } from "../badges/queries/syncXPBadges.server"; const removeOldLikesStm = sql.prepare(/*sql*/ ` delete from @@ -239,7 +239,7 @@ export async function linkUserAndPlayer({ .where("SplatoonPlayer.id", "=", playerId) .execute(); - syncXPBadges(); + await BadgeRepository.syncXPBadges(); await BuildRepository.recalculateAllTop500(); } diff --git a/app/features/badges/BadgeRepository.server.test.ts b/app/features/badges/BadgeRepository.server.test.ts new file mode 100644 index 000000000..0129447a8 --- /dev/null +++ b/app/features/badges/BadgeRepository.server.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { db } from "~/db/sql"; +import { dbInsertUsers, dbReset } from "~/utils/Test"; +import * as BadgeRepository from "./BadgeRepository.server"; +import { SPLATOON_3_XP_BADGE_VALUES } from "./badges-constants"; + +describe("syncXPBadges", () => { + beforeEach(async () => { + await dbInsertUsers(3); + await insertXPBadges(); + }); + + afterEach(() => { + dbReset(); + }); + + test("assigns badge to user with qualifying peakXp", async () => { + await insertSplatoonPlayer({ splId: "abc123", userId: 1, peakXp: 3000 }); + + await BadgeRepository.syncXPBadges(); + + const badge = await findBadgeByCode("3000"); + expect(badge?.owners).toHaveLength(1); + expect(badge?.owners[0].id).toBe(1); + }); + + test("assigns highest qualifying badge when peakXp exceeds threshold", async () => { + await insertSplatoonPlayer({ splId: "abc123", userId: 1, peakXp: 3250 }); + + await BadgeRepository.syncXPBadges(); + + const badge3200 = await findBadgeByCode("3200"); + const badge3300 = await findBadgeByCode("3300"); + + expect(badge3200?.owners).toHaveLength(1); + expect(badge3300?.owners).toHaveLength(0); + }); + + test("does not assign badge when peakXp is below minimum threshold", async () => { + await insertSplatoonPlayer({ splId: "abc123", userId: 1, peakXp: 2500 }); + + await BadgeRepository.syncXPBadges(); + + const badge2600 = await findBadgeByCode("2600"); + expect(badge2600?.owners).toHaveLength(0); + }); +}); + +async function insertXPBadges() { + await db + .insertInto("Badge") + .values( + SPLATOON_3_XP_BADGE_VALUES.map((value) => ({ + code: String(value), + displayName: `${value}+ XP`, + hue: null, + authorId: null, + })), + ) + .execute(); +} + +async function insertSplatoonPlayer(args: { + splId: string; + userId: number | null; + peakXp: number | null; +}) { + await db.insertInto("SplatoonPlayer").values(args).execute(); +} + +async function findBadgeByCode(code: string) { + const badges = await BadgeRepository.all(); + const badge = badges.find((b) => b.code === code); + if (!badge) return null; + return BadgeRepository.findById(badge.id); +} diff --git a/app/features/badges/BadgeRepository.server.ts b/app/features/badges/BadgeRepository.server.ts index 275e87f72..e66028b47 100644 --- a/app/features/badges/BadgeRepository.server.ts +++ b/app/features/badges/BadgeRepository.server.ts @@ -1,8 +1,11 @@ -import type { ExpressionBuilder } from "kysely"; +import type { ExpressionBuilder, NotNull } from "kysely"; import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite"; import { db } from "~/db/sql"; import type { DB } from "~/db/tables"; +import invariant from "~/utils/invariant"; import { COMMON_USER_FIELDS } from "~/utils/kysely.server"; +import { SPLATOON_3_XP_BADGE_VALUES } from "./badges-constants"; +import { findSplatoon3XpBadgeValue } from "./badges-utils"; const addPermissions = ( row: T, @@ -160,3 +163,46 @@ export function replaceOwners({ } }); } + +export async function syncXPBadges() { + return db.transaction().execute(async (trx) => { + for (const value of SPLATOON_3_XP_BADGE_VALUES) { + const badge = await trx + .selectFrom("Badge") + .select("id") + .where("code", "=", String(value)) + .executeTakeFirst(); + + invariant(badge, `Badge ${value} not found`); + + await trx + .deleteFrom("TournamentBadgeOwner") + .where("badgeId", "=", badge.id) + .execute(); + } + + const userTopXPowers = await trx + .selectFrom("SplatoonPlayer") + .select(["userId", "peakXp"]) + .where("userId", "is not", null) + .where("peakXp", "is not", null) + .$narrowType<{ userId: NotNull; peakXp: NotNull }>() + .execute(); + + for (const { userId, peakXp } of userTopXPowers) { + const badgeValue = findSplatoon3XpBadgeValue(peakXp!); + if (!badgeValue) continue; + + await trx + .insertInto("TournamentBadgeOwner") + .values((eb) => ({ + badgeId: eb + .selectFrom("Badge") + .select("id") + .where("code", "=", String(badgeValue)), + userId, + })) + .execute(); + } + }); +} diff --git a/app/features/badges/queries/syncXPBadges.server.ts b/app/features/badges/queries/syncXPBadges.server.ts deleted file mode 100644 index 502ea41dc..000000000 --- a/app/features/badges/queries/syncXPBadges.server.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { sql } from "~/db/sql"; -import invariant from "~/utils/invariant"; -import { SPLATOON_3_XP_BADGE_VALUES } from "../badges-constants"; -import { findSplatoon3XpBadgeValue } from "../badges-utils"; - -const badgeCodeToIdStm = sql.prepare(/* sql */ ` - select "id" - from "Badge" - where "code" = @code -`); - -const deleteBadgeOwnerStm = sql.prepare(/* sql */ ` - delete from "TournamentBadgeOwner" - where "badgeId" = @badgeId -`); - -const userTopXPowersStm = sql.prepare(/* sql */ ` - select - "SplatoonPlayer"."userId", - "SplatoonPlayer"."peakXp" as "xPower" - from - "SplatoonPlayer" - where "SplatoonPlayer"."userId" is not null - and "SplatoonPlayer"."peakXp" is not null -`); - -const addXPBadgeStm = sql.prepare(/* sql */ ` - insert into "TournamentBadgeOwner" ("badgeId", "userId") - values ( - (select "id" from "Badge" where "code" = @code), - @userId - ) -`); - -export const syncXPBadges = sql.transaction(() => { - for (const value of SPLATOON_3_XP_BADGE_VALUES) { - const badgeId = (badgeCodeToIdStm.get({ code: String(value) }) as any) - .id as number; - - invariant(badgeId, `Badge ${value} not found`); - - deleteBadgeOwnerStm.run({ badgeId }); - } - - const userTopXPowers = userTopXPowersStm.all() as Array<{ - userId: number; - xPower: number; - }>; - - for (const { userId, xPower } of userTopXPowers) { - const badgeValue = findSplatoon3XpBadgeValue(xPower); - if (!badgeValue) continue; - - addXPBadgeStm.run({ code: String(badgeValue), userId }); - } -}); diff --git a/app/features/top-search/actions/xsearch.player.$id.server.ts b/app/features/top-search/actions/xsearch.player.$id.server.ts index a095a4f0c..735dfea3c 100644 --- a/app/features/top-search/actions/xsearch.player.$id.server.ts +++ b/app/features/top-search/actions/xsearch.player.$id.server.ts @@ -1,6 +1,6 @@ import type { ActionFunctionArgs } from "react-router"; import { requireUser } from "~/features/auth/core/user.server"; -import { syncXPBadges } from "~/features/badges/queries/syncXPBadges.server"; +import * as BadgeRepository from "~/features/badges/BadgeRepository.server"; import { logger } from "~/utils/logger"; import { errorToastIfFalsy, @@ -35,7 +35,7 @@ export const action = async ({ params }: ActionFunctionArgs) => { await XRankPlacementRepository.unlinkPlayerByUserId(user.id); - syncXPBadges(); + await BadgeRepository.syncXPBadges(); return successToast("Unlink successful"); }; diff --git a/scripts/placements/index.ts b/scripts/placements/index.ts index f50ee4eca..4d6a67300 100644 --- a/scripts/placements/index.ts +++ b/scripts/placements/index.ts @@ -2,7 +2,7 @@ import "dotenv/config"; import { sql } from "~/db/sql"; import type { Tables } from "~/db/tables"; -import { syncXPBadges } from "~/features/badges/queries/syncXPBadges.server"; +import * as BadgeRepository from "~/features/badges/BadgeRepository.server"; import * as BuildRepository from "~/features/builds/BuildRepository.server"; import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; @@ -55,7 +55,7 @@ async function main() { addPlacements(placements); await XRankPlacementRepository.refreshAllPeakXp(); - syncXPBadges(); + await BadgeRepository.syncXPBadges(); await BuildRepository.recalculateAllTop500(); logger.info(`done reading in ${placements.length} placements`); } diff --git a/scripts/sync-xp-badges.ts b/scripts/sync-xp-badges.ts index da0393803..88d12a3f5 100644 --- a/scripts/sync-xp-badges.ts +++ b/scripts/sync-xp-badges.ts @@ -1,7 +1,8 @@ import "dotenv/config"; -import { syncXPBadges } from "~/features/badges/queries/syncXPBadges.server"; +import * as BadgeRepository from "~/features/badges/BadgeRepository.server"; import { logger } from "~/utils/logger"; -syncXPBadges(); - -logger.info("Synced XP badges"); +void (async () => { + await BadgeRepository.syncXPBadges(); + logger.info("Synced XP badges"); +})();