import { sub } from "date-fns"; import type { Expression, ExpressionBuilder, NotNull, SqlBool, Transaction, } from "kysely"; import { sql } from "kysely"; import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite"; import * as R from "remeda"; import { db } from "~/db/sql"; import type { CalendarEventTag, DB, Tables, TournamentSettings, } from "~/db/tables"; import { EXCLUDED_TAGS } from "~/features/calendar/calendar-constants"; import * as Progression from "~/features/tournament-bracket/core/Progression"; import { getTentativeTier } from "~/features/tournament-organization/core/tentativeTiers.server"; import { databaseTimestampNow, databaseTimestampToDate, databaseTimestampToJavascriptTimestamp, dateToDatabaseTimestamp, } from "~/utils/dates"; import invariant from "~/utils/invariant"; import { concatUserSubmittedImagePrefix, tournamentLogoWithDefault, } from "~/utils/kysely.server"; import { calendarEventPage, tournamentPage } from "~/utils/urls"; import { modesIncluded, normalizedTeamCount, tournamentIsRanked, } from "../tournament/tournament-utils"; import type { CalendarEvent } from "./calendar-types"; import { calendarEventSorter } from "./calendar-utils"; function hasBadge(eb: ExpressionBuilder) { return eb .exists( eb .selectFrom("CalendarEventBadge") .select("CalendarEventBadge.eventId") .whereRef( "CalendarEventBadge.eventId", "=", "CalendarEventDate.eventId", ), ) .as("hasBadge"); } const withMapPool = (eb: ExpressionBuilder) => { return jsonArrayFrom( eb .selectFrom("MapPoolMap") .select(["MapPoolMap.stageId", "MapPoolMap.mode"]) .whereRef("MapPoolMap.calendarEventId", "=", "CalendarEvent.id"), ).as("mapPool"); }; const withTieBreakerMapPool = (eb: ExpressionBuilder) => { return jsonArrayFrom( eb .selectFrom("MapPoolMap") .select(["MapPoolMap.stageId", "MapPoolMap.mode"]) .whereRef( "MapPoolMap.tieBreakerCalendarEventId", "=", "CalendarEvent.id", ), ).as("tieBreakerMapPool"); }; const withBadgePrizes = (eb: ExpressionBuilder) => { return jsonArrayFrom( eb .selectFrom("CalendarEventBadge") .innerJoin("Badge", "CalendarEventBadge.badgeId", "Badge.id") .select(["Badge.id", "Badge.code", "Badge.hue", "Badge.displayName"]) .whereRef("CalendarEventBadge.eventId", "=", "CalendarEvent.id"), ).as("badgePrizes"); }; function tournamentOrganization(organizationId: Expression) { return jsonObjectFrom( db .selectFrom("TournamentOrganization") .leftJoin( "UserSubmittedImage", "TournamentOrganization.avatarImgId", "UserSubmittedImage.id", ) .select((eb) => [ "TournamentOrganization.id", "TournamentOrganization.name", "TournamentOrganization.slug", "TournamentOrganization.isEstablished", concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as( "logoUrl", ), ]) .whereRef("TournamentOrganization.id", "=", organizationId), ); } interface FindAllBetweenTwoTimestampsArgs { startTime: Date; endTime: Date; } export async function findAllBetweenTwoTimestamps( args: FindAllBetweenTwoTimestampsArgs, ) { const rows = await findAllBetweenTwoTimestampsQuery(args); return findAllBetweenTwoTimestampsMapped(rows); } const withOrganization = (eb: ExpressionBuilder) => jsonObjectFrom( eb .selectFrom("TournamentOrganization") .select(["TournamentOrganization.name", "TournamentOrganization.slug"]) .whereRef( "TournamentOrganization.id", "=", "CalendarEvent.organizationId", ), ); const withTeamsCount = ( eb: ExpressionBuilder, ) => eb .selectFrom("TournamentTeam") .leftJoin("TournamentTeamCheckIn", (join) => join .on("TournamentTeamCheckIn.bracketIdx", "is", null) .onRef( "TournamentTeamCheckIn.tournamentTeamId", "=", "TournamentTeam.id", ), ) .whereRef("TournamentTeam.tournamentId", "=", "Tournament.id") .where((eb) => eb.or([ eb("TournamentTeamCheckIn.checkedInAt", "is not", null), eb("CalendarEventDate.startTime", ">", databaseTimestampNow()), ]), ) .select(({ fn }) => [fn.countAll().as("teamsCount")]); function findAllBetweenTwoTimestampsQuery({ startTime, endTime, }: FindAllBetweenTwoTimestampsArgs) { return db .selectFrom("CalendarEvent") .innerJoin( "CalendarEventDate", "CalendarEvent.id", "CalendarEventDate.eventId", ) .leftJoin("Tournament", "CalendarEvent.tournamentId", "Tournament.id") .select((eb) => [ "CalendarEvent.id as eventId", "CalendarEvent.authorId", "CalendarEvent.organizationId", "Tournament.id as tournamentId", "Tournament.settings as tournamentSettings", "Tournament.mapPickingStyle", "Tournament.tier", "CalendarEvent.name", "CalendarEvent.tags", "CalendarEventDate.startTime", // events get grouped to their closest :00 or :30 so for example users can't make their event start at :59 to make it show at the top sql`(("CalendarEventDate"."startTime" + 900) / 1800) * 1800`.as( "normalizedStartTime", ), withOrganization(eb).as("organization"), withTeamsCount(eb).as("teamsCount"), tournamentLogoWithDefault(eb).as("logoUrl"), jsonArrayFrom( eb .selectFrom("MapPoolMap") .select(["MapPoolMap.mode"]) .whereRef("MapPoolMap.calendarEventId", "=", "CalendarEvent.id"), ).as("toSetMapPool"), jsonArrayFrom( eb .selectFrom("CalendarEventBadge") .innerJoin("Badge", "CalendarEventBadge.badgeId", "Badge.id") .select(["Badge.id", "Badge.code", "Badge.hue", "Badge.displayName"]) .whereRef( "CalendarEventBadge.eventId", "=", "CalendarEventDate.eventId", ) .orderBy("Badge.id", "asc"), ).as("badges"), ]) .where("CalendarEvent.hidden", "=", 0) .where( "CalendarEventDate.startTime", ">=", dateToDatabaseTimestamp(startTime), ) .where( "CalendarEventDate.startTime", "<=", dateToDatabaseTimestamp(endTime), ) .$narrowType<{ teamsCount: NotNull }>() .execute(); } function findAllBetweenTwoTimestampsMapped( rows: Awaited>, ): Array<{ at: number; events: Array; }> { const mapped: Array = rows.map( (row) => { const tags = row.tags ? (row.tags.split(",") as CalendarEvent["tags"]) : []; const isPastEvent = databaseTimestampToDate(row.startTime) < sub(new Date(), { days: 1 }); const tentativeTier = row.tier === null && row.organizationId !== null && row.tournamentId !== null && !isPastEvent ? getTentativeTier(row.organizationId, row.name) : null; return { at: databaseTimestampToJavascriptTimestamp(row.startTime), type: "calendar", id: row.eventId, url: row.tournamentId ? tournamentPage(row.tournamentId) : calendarEventPage(row.eventId), name: row.name, organization: row.organization, authorId: row.authorId, tags: tags.filter((tag) => !EXCLUDED_TAGS.includes(tag)), teamsCount: row.teamsCount, normalizedTeamCount: normalizedTeamCount({ teamsCount: row.teamsCount, minMembersPerTeam: row.tournamentSettings?.minMembersPerTeam ?? 4, }), modes: tags.includes("CARDS") ? ["TB"] : tags.includes("SR") ? ["SR"] : row.mapPickingStyle ? modesIncluded(row.mapPickingStyle, row.toSetMapPool) : null, badges: row.badges, logoUrl: row.logoUrl, startTime: row.normalizedStartTime, isRanked: row.tournamentSettings ? tournamentIsRanked({ isSetAsRanked: row.tournamentSettings.isRanked, startTime: databaseTimestampToDate(row.startTime), minMembersPerTeam: row.tournamentSettings.minMembersPerTeam ?? 4, isTest: row.tournamentSettings.isTest ?? false, }) : null, tier: row.tier ?? null, tentativeTier, }; }, ); const grouped = R.groupBy(mapped, (row) => row.startTime); const dates = Object.keys(grouped) .map((dbTimestamp) => ({ at: databaseTimestampToDate(Number(dbTimestamp)).getTime(), events: grouped[Number(dbTimestamp)].sort(calendarEventSorter), })) .sort((a, b) => a.at - b.at); return dates; } export async function findById( id: number, { includeMapPool = false, includeTieBreakerMapPool = false, includeBadgePrizes = false, }: { includeMapPool?: boolean; includeTieBreakerMapPool?: boolean; includeBadgePrizes?: boolean; } = {}, ) { const [firstRow, ...rest] = await db .selectFrom("CalendarEvent") .$if(includeMapPool, (qb) => qb.select(withMapPool)) .$if(includeTieBreakerMapPool, (qb) => qb.select(withTieBreakerMapPool)) .$if(includeBadgePrizes, (qb) => qb.select(withBadgePrizes)) .innerJoin( "CalendarEventDate", "CalendarEvent.id", "CalendarEventDate.eventId", ) .innerJoin("User", "CalendarEvent.authorId", "User.id") .leftJoin("Tournament", "CalendarEvent.tournamentId", "Tournament.id") .select((eb) => [ "CalendarEvent.name", "CalendarEvent.description", "CalendarEvent.discordInviteCode", "CalendarEvent.discordUrl", "CalendarEvent.bracketUrl", "CalendarEvent.tags", "CalendarEvent.tournamentId", "CalendarEvent.participantCount", "CalendarEvent.avatarImgId", "Tournament.mapPickingStyle", "User.id as authorId", "CalendarEventDate.startTime", "CalendarEventDate.eventId", "User.username", "User.discordId", "User.discordAvatar", hasBadge(eb), tournamentOrganization(eb.ref("CalendarEvent.organizationId")).as( "organization", ), ]) .where("CalendarEvent.id", "=", id) .orderBy("CalendarEventDate.startTime", "asc") .execute(); if (!firstRow) return null; return { ...firstRow, tags: tagsArray(firstRow), startTimes: [firstRow, ...rest].map((row) => row.startTime), startTime: undefined, }; } export async function findRecentTournamentsByAuthorId(authorId: number) { return db .selectFrom("CalendarEvent") .innerJoin("Tournament", "Tournament.id", "CalendarEvent.tournamentId") .innerJoin( "CalendarEventDate", "CalendarEvent.id", "CalendarEventDate.eventId", ) .select([ "CalendarEvent.id", "CalendarEvent.name", "CalendarEventDate.startTime", ]) .where("CalendarEvent.authorId", "=", authorId) .orderBy("CalendarEvent.id", "desc") .limit(10) .execute(); } function tagsArray(args: { hasBadge: SqlBool; tags?: Tables["CalendarEvent"]["tags"]; tournamentId: Tables["CalendarEvent"]["tournamentId"]; }) { const tags = ( args.tags ? args.tags.split(",") : [] ) as Array; return tags; } export async function findResultsByEventId(eventId: number) { return db .selectFrom("CalendarEventResultTeam") .select(({ eb }) => [ "CalendarEventResultTeam.id", "CalendarEventResultTeam.name as teamName", "CalendarEventResultTeam.placement", jsonArrayFrom( eb .selectFrom("CalendarEventResultPlayer") .leftJoin("User", "User.id", "CalendarEventResultPlayer.userId") .select([ "CalendarEventResultPlayer.userId as id", "CalendarEventResultPlayer.name", "User.username", "User.discordId", "User.discordAvatar", "User.customUrl", ]) .whereRef( "CalendarEventResultPlayer.teamId", "=", "CalendarEventResultTeam.id", ), ).as("players"), ]) .where("CalendarEventResultTeam.eventId", "=", eventId) .orderBy("CalendarEventResultTeam.placement", "asc") .execute(); } type CreateArgs = Pick< Tables["CalendarEvent"], | "name" | "authorId" | "tags" | "description" | "discordInviteCode" | "bracketUrl" | "organizationId" > & { startTimes: Array; badges: Array; mapPoolMaps?: Array>; isFullTournament: boolean; mapPickingStyle: Tables["Tournament"]["mapPickingStyle"]; bracketProgression: TournamentSettings["bracketProgression"] | null; minMembersPerTeam?: number; maxMembersPerTeam?: number; teamsPerGroup?: number; thirdPlaceMatch?: boolean; requireInGameNames?: boolean; isRanked?: boolean; isTest?: boolean; isInvitational?: boolean; enableNoScreenToggle?: boolean; enableSubs?: boolean; autonomousSubs?: boolean; regClosesAt?: number; rules: string | null; tournamentToCopyId?: number | null; swissGroupCount?: number; swissRoundCount?: number; avatarFileName?: string; avatarImgId?: number; autoValidateAvatar?: boolean; parentTournamentId?: number; }; export async function create(args: CreateArgs) { const copiedStaff = args.tournamentToCopyId ? await db .selectFrom("TournamentStaff") .select(["role", "userId"]) .where("tournamentId", "=", args.tournamentToCopyId) .where("TournamentStaff.userId", "!=", args.authorId) .execute() : []; return db.transaction().execute(async (trx) => { let tournamentId: number | null = null; if (args.isFullTournament) { invariant(args.bracketProgression, "Expected bracketProgression"); const settings: Tables["Tournament"]["settings"] = { bracketProgression: args.bracketProgression, teamsPerGroup: args.teamsPerGroup, thirdPlaceMatch: args.thirdPlaceMatch, isRanked: args.isRanked, isTest: args.isTest, isInvitational: args.isInvitational, enableNoScreenToggle: args.enableNoScreenToggle, enableSubs: args.enableSubs, autonomousSubs: args.autonomousSubs, regClosesAt: args.regClosesAt, requireInGameNames: args.requireInGameNames, minMembersPerTeam: args.minMembersPerTeam, maxMembersPerTeam: args.maxMembersPerTeam, swiss: args.swissGroupCount && args.swissRoundCount ? { groupCount: args.swissGroupCount, roundCount: args.swissRoundCount, } : undefined, }; tournamentId = ( await trx .insertInto("Tournament") .values({ mapPickingStyle: args.mapPickingStyle, settings: JSON.stringify(settings), parentTournamentId: args.parentTournamentId, rules: args.rules, }) .returning("id") .executeTakeFirstOrThrow() ).id; if (copiedStaff.length > 0) { await trx .insertInto("TournamentStaff") .columns(["role", "userId", "tournamentId"]) .values( copiedStaff.map((staff) => ({ role: staff.role, userId: staff.userId, tournamentId: tournamentId!, })), ) .execute(); } } const avatarImgId = args.avatarFileName ? await createSubmittedImageInTrx({ trx, avatarFileName: args.avatarFileName, userId: args.authorId, }) : null; const { id: eventId } = await trx .insertInto("CalendarEvent") .values({ name: args.name, authorId: args.authorId, tags: args.tags, description: args.description, discordInviteCode: args.discordInviteCode, bracketUrl: args.bracketUrl, avatarImgId: args.avatarImgId ?? avatarImgId, organizationId: args.organizationId, hidden: args.parentTournamentId || args.isTest ? 1 : 0, tournamentId, }) .returning("id") .executeTakeFirstOrThrow(); await createDatesInTrx({ eventId, startTimes: args.startTimes, trx }); await createBadgesInTrx({ eventId, badges: args.badges, trx }); await upsertMapPoolInTrx({ trx, eventId, mapPoolMaps: args.mapPoolMaps ?? [], column: args.isFullTournament && args.mapPickingStyle !== "TO" ? "tieBreakerCalendarEventId" : "calendarEventId", }); return { eventId, tournamentId }; }); } async function createSubmittedImageInTrx({ trx, avatarFileName, userId, }: { trx: Transaction; avatarFileName: string; userId: number; }) { const result = await trx .insertInto("UnvalidatedUserSubmittedImage") .values({ url: avatarFileName, validatedAt: databaseTimestampNow(), submitterUserId: userId, }) .returning("id") .executeTakeFirstOrThrow(); return result.id; } type UpdateArgs = Omit< CreateArgs, "createTournament" | "mapPickingStyle" | "isFullTournament" > & { eventId: number; }; export async function update(args: UpdateArgs) { return db.transaction().execute(async (trx) => { const avatarImgId = args.avatarFileName ? await createSubmittedImageInTrx({ trx, avatarFileName: args.avatarFileName, userId: args.authorId, }) : null; const { tournamentId } = await trx .updateTable("CalendarEvent") .set({ name: args.name, tags: args.tags, description: args.description, discordInviteCode: args.discordInviteCode, bracketUrl: args.bracketUrl, avatarImgId: args.avatarImgId ?? avatarImgId, organizationId: args.organizationId, }) .where("id", "=", args.eventId) .returning("tournamentId") .executeTakeFirstOrThrow(); const mapPickingStyle = tournamentId ? await updateTournamentTables(args, trx, tournamentId) : null; await trx .deleteFrom("CalendarEventDate") .where("eventId", "=", args.eventId) .execute(); await createDatesInTrx({ eventId: args.eventId, startTimes: args.startTimes, trx, }); await trx .deleteFrom("CalendarEventBadge") .where("eventId", "=", args.eventId) .execute(); await createBadgesInTrx({ eventId: args.eventId, badges: args.badges, trx, }); if (!tournamentId || mapPickingStyle === "TO") { await upsertMapPoolInTrx({ trx, eventId: args.eventId, mapPoolMaps: args.mapPoolMaps ?? [], column: "calendarEventId", }); } }); } async function updateTournamentTables( args: UpdateArgs, trx: Transaction, tournamentId: number, ) { invariant(args.bracketProgression, "Expected bracketProgression"); const existingSettings = ( await trx .selectFrom("Tournament") .select("settings") .where("id", "=", tournamentId) .executeTakeFirstOrThrow() ).settings; const settings: Tables["Tournament"]["settings"] = { bracketProgression: args.bracketProgression, teamsPerGroup: args.teamsPerGroup, thirdPlaceMatch: args.thirdPlaceMatch, isRanked: args.isRanked, isTest: existingSettings.isTest, // this one is not editable after creation isInvitational: args.isInvitational, enableNoScreenToggle: args.enableNoScreenToggle, enableSubs: args.enableSubs, autonomousSubs: args.autonomousSubs, regClosesAt: args.regClosesAt, requireInGameNames: args.requireInGameNames, minMembersPerTeam: args.minMembersPerTeam, maxMembersPerTeam: args.maxMembersPerTeam, swiss: args.swissGroupCount && args.swissRoundCount ? { groupCount: args.swissGroupCount, roundCount: args.swissRoundCount, } : undefined, }; const { mapPickingStyle } = await trx .updateTable("Tournament") .set({ settings: JSON.stringify(settings), rules: args.rules, preparedMaps: Progression.changedBracketProgressionFormat( existingSettings.bracketProgression, args.bracketProgression, ) ? null : undefined, }) .where("id", "=", tournamentId) .returning("mapPickingStyle") .executeTakeFirstOrThrow(); if ( Progression.changedBracketProgressionFormat( existingSettings.bracketProgression, args.bracketProgression, ) ) { await trx .updateTable("TournamentTeam") .set({ startingBracketIdx: null }) .where("tournamentId", "=", tournamentId) .execute(); } return mapPickingStyle; } function createDatesInTrx({ eventId, startTimes, trx, }: { eventId: number; startTimes: CreateArgs["startTimes"]; trx: Transaction; }) { return trx .insertInto("CalendarEventDate") .values(startTimes.map((startTime) => ({ startTime, eventId }))) .execute(); } function createBadgesInTrx({ eventId, badges, trx, }: { eventId: number; badges: CreateArgs["badges"]; trx: Transaction; }) { if (!badges.length) return; return trx .insertInto("CalendarEventBadge") .values( badges.map((badgeId) => ({ eventId, badgeId, })), ) .execute(); } export function upsertReportedScores(args: { eventId: number; participantCount: number; results: Array<{ teamName: string; placement: number; players: Array<{ userId: number | null; name: string | null; }>; }>; }) { return db.transaction().execute(async (trx) => { await trx .updateTable("CalendarEvent") .set({ participantCount: args.participantCount, }) .where("id", "=", args.eventId) .execute(); await trx .deleteFrom("CalendarEventResultTeam") .where("eventId", "=", args.eventId) .execute(); for (const result of args.results) { const insertedResultTeam = await trx .insertInto("CalendarEventResultTeam") .values({ eventId: args.eventId, name: result.teamName, placement: result.placement, }) .returning("CalendarEventResultTeam.id") .executeTakeFirstOrThrow(); await trx .insertInto("CalendarEventResultPlayer") .values( result.players.map((player) => ({ teamId: insertedResultTeam.id, name: player.name, userId: player.userId, })), ) .execute(); } }); } async function upsertMapPoolInTrx({ eventId, mapPoolMaps, column, trx, }: { eventId: number; mapPoolMaps: NonNullable; column: "tieBreakerCalendarEventId" | "calendarEventId"; trx: Transaction; }) { await trx .deleteFrom("MapPoolMap") .where((eb) => eb.or([ eb("calendarEventId", "=", eventId), eb("tieBreakerCalendarEventId", "=", eventId), ]), ) .execute(); if (!mapPoolMaps.length) return; await trx .insertInto("MapPoolMap") .values( mapPoolMaps.map((mapPoolMap) => ({ stageId: mapPoolMap.stageId, mode: mapPoolMap.mode, [column]: eventId, })), ) .execute(); } export function deleteById({ eventId, tournamentId, }: { eventId: number; tournamentId: number | null; }) { return db.transaction().execute(async (trx) => { await trx.deleteFrom("CalendarEvent").where("id", "=", eventId).execute(); if (tournamentId) { await trx .deleteFrom("Tournament") .where("id", "=", tournamentId) .execute(); } }); }