sendou.ink/app/features/tournament-organization/TournamentOrganizationRepository.server.ts
Kalle 77978c450f
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run
New user page (#2812)
Co-authored-by: hfcRed <hfcred@gmx.net>
2026-02-16 19:26:57 +02:00

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();
}