mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
660 lines
16 KiB
TypeScript
660 lines
16 KiB
TypeScript
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 {
|
|
TIER_HISTORY_LENGTH,
|
|
type TournamentTierNumber,
|
|
updateTierHistory,
|
|
} from "~/features/tournament/core/tiering";
|
|
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",
|
|
"TournamentOrganizationSeries.tierHistory",
|
|
])
|
|
.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<Tables["TournamentOrganizationMember"]["role"]>;
|
|
} = {},
|
|
) {
|
|
return db
|
|
.selectFrom("TournamentOrganizationMember")
|
|
.innerJoin(
|
|
"TournamentOrganization",
|
|
"TournamentOrganization.id",
|
|
"TournamentOrganizationMember.organizationId",
|
|
)
|
|
.leftJoin(
|
|
"UserSubmittedImage",
|
|
"UserSubmittedImage.id",
|
|
"TournamentOrganization.avatarImgId",
|
|
)
|
|
.select(({ eb }) => [
|
|
"TournamentOrganization.id",
|
|
"TournamentOrganization.name",
|
|
"TournamentOrganization.slug",
|
|
"TournamentOrganization.isEstablished",
|
|
"TournamentOrganizationMember.role",
|
|
"TournamentOrganizationMember.roleDisplayName",
|
|
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
|
|
"logoUrl",
|
|
),
|
|
])
|
|
.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>`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<Tables["TournamentOrganizationSeries"], "description" | "name"> & {
|
|
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) {
|
|
const insertedSeries = 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),
|
|
})),
|
|
)
|
|
.returning(["id", "substringMatches"])
|
|
.execute();
|
|
|
|
const finalizedTournaments = await trx
|
|
.selectFrom("Tournament")
|
|
.innerJoin(
|
|
"CalendarEvent",
|
|
"CalendarEvent.tournamentId",
|
|
"Tournament.id",
|
|
)
|
|
.innerJoin(
|
|
"CalendarEventDate",
|
|
"CalendarEventDate.eventId",
|
|
"CalendarEvent.id",
|
|
)
|
|
.select([
|
|
"Tournament.id as tournamentId",
|
|
"CalendarEvent.name",
|
|
"Tournament.tier",
|
|
"CalendarEventDate.startTime",
|
|
])
|
|
.where("Tournament.isFinalized", "=", 1)
|
|
.where("CalendarEvent.organizationId", "=", id)
|
|
.where("CalendarEvent.hidden", "=", 0)
|
|
.orderBy("CalendarEventDate.startTime", "asc")
|
|
.execute();
|
|
|
|
for (const s of insertedSeries) {
|
|
const matchingTiers = finalizedTournaments
|
|
.filter((t) => {
|
|
const eventNameLower = t.name.toLowerCase();
|
|
return s.substringMatches.some((match) =>
|
|
eventNameLower.includes(match.toLowerCase()),
|
|
);
|
|
})
|
|
.filter((t) => t.tier !== null)
|
|
.map((t) => t.tier);
|
|
|
|
if (matchingTiers.length === 0) continue;
|
|
|
|
const tierHistory = matchingTiers.slice(-TIER_HISTORY_LENGTH);
|
|
|
|
await trx
|
|
.updateTable("TournamentOrganizationSeries")
|
|
.set({ tierHistory: JSON.stringify(tierHistory) })
|
|
.where("id", "=", s.id)
|
|
.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<TablesInsertable["TournamentOrganizationBannedUser"], "updatedAt">,
|
|
) {
|
|
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();
|
|
}
|
|
|
|
export function findAllSeriesWithTierHistory() {
|
|
return db
|
|
.selectFrom("TournamentOrganizationSeries")
|
|
.select(["organizationId", "substringMatches", "tierHistory"])
|
|
.execute();
|
|
}
|
|
|
|
export async function updateSeriesTierHistory({
|
|
organizationId,
|
|
eventName,
|
|
newTier,
|
|
}: {
|
|
organizationId: number;
|
|
eventName: string;
|
|
newTier: TournamentTierNumber;
|
|
}) {
|
|
const series = await db
|
|
.selectFrom("TournamentOrganizationSeries")
|
|
.select(["id", "substringMatches", "tierHistory"])
|
|
.where("organizationId", "=", organizationId)
|
|
.execute();
|
|
|
|
const eventNameLower = eventName.toLowerCase();
|
|
const matchingSeries = series.find((s) =>
|
|
s.substringMatches.some((match) =>
|
|
eventNameLower.includes(match.toLowerCase()),
|
|
),
|
|
);
|
|
|
|
if (!matchingSeries) return;
|
|
|
|
const newTierHistory = updateTierHistory(matchingSeries.tierHistory, newTier);
|
|
|
|
await db
|
|
.updateTable("TournamentOrganizationSeries")
|
|
.set({ tierHistory: JSON.stringify(newTierHistory) })
|
|
.where("id", "=", matchingSeries.id)
|
|
.execute();
|
|
}
|