import { isFuture } from "date-fns"; import { sql } from "kysely"; import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite"; import { db } from "~/db/sql"; import type { Tables, TablesInsertable } from "~/db/tables"; import { databaseTimestampNow, databaseTimestampToDate, dateToDatabaseTimestamp, } from "~/utils/dates"; import { COMMON_USER_FIELDS, concatUserSubmittedImagePrefix, tournamentLogoWithDefault, } from "~/utils/kysely.server"; import { mySlugify } from "~/utils/urls"; import { TOURNAMENT_SERIES_EVENTS_PER_PAGE } from "./tournament-organization-constants"; interface CreateArgs { ownerId: number; name: string; } export function create(args: CreateArgs) { return db.transaction().execute(async (trx) => { const org = await trx .insertInto("TournamentOrganization") .values({ name: args.name, slug: mySlugify(args.name), }) .returning(["id", "slug"]) .executeTakeFirstOrThrow(); await trx .insertInto("TournamentOrganizationMember") .values({ organizationId: org.id, userId: args.ownerId, role: "ADMIN", }) .execute(); return org; }); } export async function findBySlug(slug: string) { const organization = await db .selectFrom("TournamentOrganization") .leftJoin( "UserSubmittedImage", "UserSubmittedImage.id", "TournamentOrganization.avatarImgId", ) .select(({ eb }) => [ "TournamentOrganization.id", "TournamentOrganization.name", "TournamentOrganization.description", "TournamentOrganization.socials", "TournamentOrganization.slug", "TournamentOrganization.isEstablished", concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as( "avatarUrl", ), jsonArrayFrom( eb .selectFrom("TournamentOrganizationMember") .innerJoin("User", "User.id", "TournamentOrganizationMember.userId") .select([ "TournamentOrganizationMember.role", "TournamentOrganizationMember.roleDisplayName", ...COMMON_USER_FIELDS, ]) .whereRef( "TournamentOrganizationMember.organizationId", "=", "TournamentOrganization.id", ) .orderBy( sql`coalesce(TournamentOrganizationMember.roleDisplayName, TournamentOrganizationMember.role)`, "asc", ) .orderBy("User.username", "asc"), ).as("members"), jsonArrayFrom( eb .selectFrom("TournamentOrganizationSeries") .select([ "TournamentOrganizationSeries.id", "TournamentOrganizationSeries.name", "TournamentOrganizationSeries.substringMatches", "TournamentOrganizationSeries.showLeaderboard", "TournamentOrganizationSeries.description", ]) .whereRef( "TournamentOrganizationSeries.organizationId", "=", "TournamentOrganization.id", ), ).as("series"), jsonArrayFrom( eb .selectFrom("TournamentOrganizationBadge") .innerJoin("Badge", "Badge.id", "TournamentOrganizationBadge.badgeId") .select(["Badge.id", "Badge.displayName", "Badge.code", "Badge.hue"]) .whereRef( "TournamentOrganizationBadge.organizationId", "=", "TournamentOrganization.id", ), ).as("badges"), ]) .where("TournamentOrganization.slug", "=", slug) .executeTakeFirst(); if (!organization) return null; const orgAdminUserIds = organization.members .filter((member) => member.role === "ADMIN") .map((member) => member.id); return { ...organization, permissions: { EDIT: orgAdminUserIds, BAN: orgAdminUserIds, }, }; } export function findByUserId( userId: number, { roles = [], }: { /** If set, filters organizations by user's org member role */ roles?: Array; } = {}, ) { return db .selectFrom("TournamentOrganizationMember") .innerJoin( "TournamentOrganization", "TournamentOrganization.id", "TournamentOrganizationMember.organizationId", ) .select([ "TournamentOrganization.id", "TournamentOrganization.name", "TournamentOrganization.isEstablished", ]) .where("TournamentOrganizationMember.userId", "=", userId) .$if(roles.length > 0, (qb) => qb.where("TournamentOrganizationMember.role", "in", roles), ) .orderBy("TournamentOrganization.id", "asc") .execute(); } interface FindEventsByMonthArgs { month: number; year: number; organizationId: number; } const findEventsBaseQuery = (organizationId: number) => db .selectFrom("CalendarEvent") .innerJoin( "CalendarEventDate", "CalendarEventDate.eventId", "CalendarEvent.id", ) .select(({ eb }) => [ "CalendarEvent.id as eventId", "CalendarEvent.name", "CalendarEvent.tournamentId", eb.fn.min("CalendarEventDate.startTime").as("startTime"), tournamentLogoWithDefault(eb).as("logoUrl"), jsonObjectFrom( eb .selectFrom("TournamentResult") .innerJoin( "TournamentTeam", "TournamentTeam.id", "TournamentResult.tournamentTeamId", ) .leftJoin("AllTeam", "TournamentTeam.teamId", "AllTeam.id") .leftJoin("UserSubmittedImage as u1", "AllTeam.avatarImgId", "u1.id") .leftJoin( "UserSubmittedImage as u2", "TournamentTeam.avatarImgId", "u2.id", ) .select(({ eb: innerEb }) => [ "TournamentTeam.name", concatUserSubmittedImagePrefix( innerEb.fn.coalesce("u1.url", "u2.url"), ).as("avatarUrl"), jsonArrayFrom( innerEb .selectFrom("TournamentTeamMember") .innerJoin("User", "User.id", "TournamentTeamMember.userId") .select(["User.discordAvatar", "User.discordId"]) .whereRef( "TournamentTeamMember.tournamentTeamId", "=", "TournamentTeam.id", ) .orderBy("User.id", "asc"), ).as("members"), ]) .whereRef( "TournamentResult.tournamentId", "=", "CalendarEvent.tournamentId", ) .where("TournamentResult.placement", "=", 1), ).as("tournamentWinners"), jsonObjectFrom( eb .selectFrom("CalendarEventResultTeam") .select(({ eb: innerEb }) => [ "CalendarEventResultTeam.name", sql`null`.as("avatarUrl"), jsonArrayFrom( innerEb .selectFrom("CalendarEventResultPlayer") .innerJoin( "User", "User.id", "CalendarEventResultPlayer.userId", ) .select(["User.discordAvatar", "User.discordId"]) .whereRef( "CalendarEventResultPlayer.teamId", "=", "CalendarEventResultTeam.id", ) .orderBy("User.id", "asc"), ).as("members"), ]) .whereRef("CalendarEventResultTeam.eventId", "=", "CalendarEvent.id") .where("CalendarEventResultTeam.placement", "=", 1), ).as("eventWinners"), ]) .where("CalendarEvent.organizationId", "=", organizationId) .where("CalendarEvent.hidden", "=", 0) .groupBy("CalendarEvent.id"); const mapEvent = < T extends { tournamentId: number | null; logoUrl: string; name: string; }, >( event: T, ) => { return { ...event, logoUrl: !event.tournamentId ? null : event.logoUrl, }; }; export async function findEventsByMonth({ month, year, organizationId, }: FindEventsByMonthArgs) { const firstDayOfTheMonth = new Date(Date.UTC(year, month, 1)); const lastDayOfTheMonth = new Date(Date.UTC(year, month + 1, 0)); // a bit of margin for timezones, filtered in the frontend code firstDayOfTheMonth.setUTCDate(firstDayOfTheMonth.getUTCDate() - 1); lastDayOfTheMonth.setUTCDate(lastDayOfTheMonth.getUTCDate() + 1); const events = await findEventsBaseQuery(organizationId) .where( "CalendarEventDate.startTime", ">=", dateToDatabaseTimestamp(firstDayOfTheMonth), ) .where( "CalendarEventDate.startTime", "<=", dateToDatabaseTimestamp(lastDayOfTheMonth), ) .orderBy("CalendarEventDate.startTime", "asc") .execute(); return events.map(mapEvent); } export function findAllUnfinalizedEvents(organizationId: number) { return db .selectFrom("Tournament") .innerJoin("CalendarEvent", "CalendarEvent.tournamentId", "Tournament.id") .select(["Tournament.id"]) .where("Tournament.isFinalized", "=", 0) .where("CalendarEvent.organizationId", "=", organizationId) .execute(); } const findSeriesEventsBaseQuery = ({ organizationId, substringMatches, }: { organizationId: number; substringMatches: string[]; }) => findEventsBaseQuery(organizationId) .where((eb) => eb.or( substringMatches.map((match) => eb("CalendarEvent.name", "like", `%${match}%`), ), ), ) .orderBy("CalendarEventDate.startTime", "desc"); export async function findPaginatedEventsBySeries({ organizationId, substringMatches, page, }: { organizationId: number; substringMatches: string[]; page: number; }) { const events = await findSeriesEventsBaseQuery({ organizationId, substringMatches, }) .limit(TOURNAMENT_SERIES_EVENTS_PER_PAGE) .offset((page - 1) * TOURNAMENT_SERIES_EVENTS_PER_PAGE) .execute(); return events.map(mapEvent); } export async function findAllEventsBySeries({ organizationId, substringMatches, }: { organizationId: number; substringMatches: string[]; }) { const events = await findSeriesEventsBaseQuery({ organizationId, substringMatches, }).execute(); return events.map(mapEvent); } interface UpdateArgs extends Pick< Tables["TournamentOrganization"], "id" | "name" | "description" | "socials" > { members: Array< Pick< Tables["TournamentOrganizationMember"], "role" | "roleDisplayName" | "userId" > >; series: Array< Pick & { showLeaderboard: boolean; } >; badges: number[]; } export function update({ id, name, description, socials, members, series, badges, }: UpdateArgs) { return db.transaction().execute(async (trx) => { const updatedOrg = await trx .updateTable("TournamentOrganization") .set({ name, description, slug: mySlugify(name), socials: socials ? JSON.stringify(socials) : null, }) .where("id", "=", id) .returningAll() .executeTakeFirstOrThrow(); await trx .deleteFrom("TournamentOrganizationMember") .where("organizationId", "=", id) .execute(); await trx .insertInto("TournamentOrganizationMember") .values( members.map((member) => ({ organizationId: id, ...member, })), ) .execute(); await trx .deleteFrom("TournamentOrganizationSeries") .where("organizationId", "=", id) .execute(); if (series.length > 0) { await trx .insertInto("TournamentOrganizationSeries") .values( series.map((s) => ({ organizationId: id, name: s.name, description: s.description, substringMatches: JSON.stringify([s.name.toLowerCase()]), showLeaderboard: Number(s.showLeaderboard), })), ) .execute(); } await trx .deleteFrom("TournamentOrganizationBadge") .where("TournamentOrganizationBadge.organizationId", "=", id) .execute(); if (badges.length > 0) { await trx .insertInto("TournamentOrganizationBadge") .values( badges.map((badgeId) => ({ organizationId: id, badgeId, })), ) .execute(); } return updatedOrg; }); } /** * Inserts a user to the banned list for a tournament organization or updates the existing entry if already exists. */ export function upsertBannedUser( args: Omit, ) { return db .insertInto("TournamentOrganizationBannedUser") .values({ ...args, updatedAt: databaseTimestampNow() }) .execute(); } /** * Removes a user from the banned list for a tournament organization */ export function unbanUser({ organizationId, userId, }: { organizationId: number; userId: number; }) { return db .deleteFrom("TournamentOrganizationBannedUser") .where("organizationId", "=", organizationId) .where("userId", "=", userId) .execute(); } /** * Returns all banned users for a specific tournament organization */ export function allBannedUsersByOrganizationId(organizationId: number) { return db .selectFrom("TournamentOrganizationBannedUser") .innerJoin("User", "User.id", "TournamentOrganizationBannedUser.userId") .select([ "TournamentOrganizationBannedUser.privateNote", "TournamentOrganizationBannedUser.updatedAt", "TournamentOrganizationBannedUser.expiresAt", ...COMMON_USER_FIELDS, ]) .where( "TournamentOrganizationBannedUser.organizationId", "=", organizationId, ) .orderBy("TournamentOrganizationBannedUser.updatedAt", "desc") .execute(); } /** * Checks if a user is banned by a specific organization */ export async function isUserBannedByOrganization({ organizationId, userId, }: { organizationId: number; userId: number; }) { const result = await db .selectFrom("TournamentOrganizationBannedUser") .select(["userId", "expiresAt"]) .where("organizationId", "=", organizationId) .where("userId", "=", userId) .executeTakeFirst(); if (!result) return false; if (!result.expiresAt) return true; return isFuture(databaseTimestampToDate(result.expiresAt)); } /** * Returns the number of organizations a user is a member of. */ export async function countOrganizationsByUserId(userId: number) { const result = await db .selectFrom("TournamentOrganizationMember") .select((eb) => eb.fn.count("organizationId").as("count")) .where("userId", "=", userId) .executeTakeFirstOrThrow(); return Number(result.count); } /** * Updates the isEstablished status for a tournament organization. */ export function updateIsEstablished( organizationId: number, isEstablished: boolean, ) { return db .updateTable("TournamentOrganization") .set({ isEstablished: Number(isEstablished) }) .where("id", "=", organizationId) .execute(); }