Add new org by user + some other things

This commit is contained in:
Kalle 2025-10-12 13:35:37 +03:00
parent 1bbb14b1dc
commit c6e7b08ebc
45 changed files with 395 additions and 62 deletions

View File

@ -24,7 +24,6 @@
## React
- project uses Remix as metaframework
- prefer functional components over class components
- prefer using hooks over class lifecycle methods
- do not use `useMemo`, `useCallback` or `useReducer` at all
@ -35,6 +34,10 @@
- all texts should be provided translations via the i18next library's `useTranslations` hook's `t` function
- instead of `&&` operator for conditional rendering, use the ternary operator
## Remix/React Router
- new routes need to be added to `routes.ts`
## Styling
- use CSS modules

View File

@ -11,6 +11,7 @@ import {
newAssociationsPage,
newScrimPostPage,
newVodPage,
ORGANIZATION_NEW_PAGE,
plusSuggestionsNewPage,
TOURNAMENT_NEW_PAGE,
userNewBuildPage,
@ -37,6 +38,12 @@ export function AnythingAdder() {
imagePath: navIconUrl("medal"),
href: TOURNAMENT_NEW_PAGE,
},
{
id: "organization",
children: t("header.adder.organization"),
imagePath: navIconUrl("medal"),
href: ORGANIZATION_NEW_PAGE,
},
{
id: "calendarEvent",
children: t("header.adder.calendarEvent"),

View File

@ -23,7 +23,6 @@ import {
} from "~/utils/dates";
import {
badRequestIfFalsy,
errorToast,
errorToastIfFalsy,
parseFormData,
uploadImageIfSubmitted,
@ -51,17 +50,7 @@ export const action: ActionFunction = async ({ request }) => {
user,
});
if (data.badges && data.badges.length > 0) {
const managedBadges = await BadgeRepository.findManagedByUserId(user.id);
if (
data.badges.some((badge) => !managedBadges.some((mb) => mb.id === badge))
) {
errorToast(
"You don't manage any badges, so you cannot add any to the event",
);
}
}
const managedBadges = await BadgeRepository.findManagedByUserId(user.id);
const startTimes = data.date.map((date) => dateToDatabaseTimestamp(date));
const commonArgs = {
@ -82,7 +71,10 @@ export const action: ActionFunction = async ({ request }) => {
)
.join(",")
: data.tags,
badges: data.badges ?? [],
badges:
data.badges?.filter((badge) =>
managedBadges.some((mb) => mb.id === badge),
) ?? [],
// newly uploaded avatar
avatarFileName,
// reused avatar either via edit or template

View File

@ -27,10 +27,10 @@ export function create(args: CreateArgs) {
name: args.name,
slug: mySlugify(args.name),
})
.returning("id")
.returning(["id", "slug"])
.executeTakeFirstOrThrow();
return trx
await trx
.insertInto("TournamentOrganizationMember")
.values({
organizationId: org.id,
@ -38,6 +38,8 @@ export function create(args: CreateArgs) {
role: "ADMIN",
})
.execute();
return org;
});
}
@ -517,3 +519,16 @@ export async function isUserBannedByOrganization({
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);
}

View File

@ -0,0 +1,33 @@
import { type ActionFunction, redirect } from "@remix-run/node";
import { requireUser } from "~/features/auth/core/user.server";
import { requireRole } from "~/modules/permissions/guards.server";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { tournamentOrganizationPage } from "~/utils/urls";
import * as TournamentOrganizationRepository from "../TournamentOrganizationRepository.server";
import { TOURNAMENT_ORGANIZATION } from "../tournament-organization-constants";
import { newOrganizationSchema } from "../tournament-organization-schemas";
export const action: ActionFunction = async ({ request }) => {
const user = await requireUser(request);
requireRole(user, "TOURNAMENT_ADDER");
const data = await parseRequestPayload({
request,
schema: newOrganizationSchema,
});
const orgCount =
await TournamentOrganizationRepository.countOrganizationsByUserId(user.id);
errorToastIfFalsy(
orgCount < TOURNAMENT_ORGANIZATION.MAX_MEMBER_OF_COUNT,
`You are already a member of ${TOURNAMENT_ORGANIZATION.MAX_MEMBER_OF_COUNT} organizations. Leave one before creating a new one.`,
);
const org = await TournamentOrganizationRepository.create({
name: data.name,
ownerId: user.id,
});
return redirect(tournamentOrganizationPage({ organizationSlug: org.slug }));
};

View File

@ -0,0 +1,43 @@
import { useTranslation } from "react-i18next";
import type { z } from "zod/v4";
import { Alert } from "~/components/Alert";
import { InputFormField } from "~/components/form/InputFormField";
import { SendouForm } from "~/components/form/SendouForm";
import { Main } from "~/components/Main";
import { useHasRole } from "~/modules/permissions/hooks";
import { action } from "../actions/org.new.server";
import { newOrganizationSchema } from "../tournament-organization-schemas";
export { action };
type FormFields = z.infer<typeof newOrganizationSchema>;
export default function NewOrganizationPage() {
const isTournamentAdder = useHasRole("TOURNAMENT_ADDER");
const { t } = useTranslation(["common", "org"]);
if (!isTournamentAdder) {
return (
<Main className="stack items-center">
<Alert variation="WARNING">{t("org:new.noPermissions")}</Alert>
</Main>
);
}
return (
<Main halfWidth>
<SendouForm
heading={t("org:new.heading")}
schema={newOrganizationSchema}
defaultValues={{
name: "",
}}
>
<InputFormField<FormFields>
label={t("common:forms.name")}
name="name"
required
/>
</SendouForm>
</Main>
);
}

View File

@ -5,4 +5,5 @@ export const TOURNAMENT_ORGANIZATION = {
DESCRIPTION_MAX_LENGTH: 1_000,
BAN_REASON_MAX_LENGTH: 200,
MAX_BANNED_USERS: 100,
MAX_MEMBER_OF_COUNT: 3,
};

View File

@ -12,15 +12,21 @@ import {
safeNullableStringSchema,
} from "~/utils/zod";
const nameSchema = z
.string()
.trim()
.min(2)
.max(64)
.refine((val) => mySlugify(val).length >= 2, {
message: "Not enough non-special characters",
});
export const newOrganizationSchema = z.object({
name: nameSchema,
});
export const organizationEditSchema = z.object({
name: z
.string()
.trim()
.min(2)
.max(64)
.refine((val) => mySlugify(val).length >= 2, {
message: "Not enough non-special characters",
}),
name: nameSchema,
description: z.preprocess(
falsyToNull,
z

View File

@ -120,6 +120,7 @@ export default [
]),
route("luti", "features/tournament/routes/luti.ts"),
route("/org/new", "features/tournament-organization/routes/org.new.tsx"),
...prefix("/org/:slug", [
index("features/tournament-organization/routes/org.$slug.tsx"),
route("edit", "features/tournament-organization/routes/org.$slug.edit.tsx"),

View File

@ -92,6 +92,7 @@ export const NEW_TEAM_PAGE = "/t?new=true";
export const CALENDAR_PAGE = "/calendar";
export const CALENDAR_NEW_PAGE = "/calendar/new";
export const TOURNAMENT_NEW_PAGE = "/calendar/new?tournament=true";
export const ORGANIZATION_NEW_PAGE = "/org/new";
export const CALENDAR_TOURNAMENTS_PAGE = "/calendar?tournaments=true";
export const STOP_IMPERSONATING_URL = "/auth/impersonate/stop";
export const SEED_URL = "/seed";

View File

@ -16,6 +16,20 @@ const url = tournamentOrganizationPage({
});
test.describe("Tournament Organization", () => {
test("can create a new organization", async ({ page }) => {
await seed(page);
await impersonate(page);
await navigate({ page, url: "/" });
await page.getByTestId("anything-adder-menu-button").click();
await page.getByTestId("menu-item-organization").click();
await page.getByLabel("Name").fill("Test Organization");
await submit(page);
await expect(page.getByTestId("edit-org-button")).toBeVisible();
});
test("user can be promoted to admin gaining org controls and can edit tournaments", async ({
page,
}) => {

View File

@ -33,6 +33,7 @@
"header.loggedInAs": "Du er logget ind som {{userName}}",
"header.theme": "Tema",
"header.adder.tournament": "",
"header.adder.organization": "",
"header.adder.calendarEvent": "",
"header.adder.build": "",
"header.adder.team": "",

View File

@ -39,5 +39,7 @@
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
"banned.banModal.expiresAtHelp": "",
"new.heading": "",
"new.noPermissions": ""
}

View File

@ -33,6 +33,7 @@
"header.loggedInAs": "Eingeloggt als {{userName}}",
"header.theme": "Theme",
"header.adder.tournament": "",
"header.adder.organization": "",
"header.adder.calendarEvent": "",
"header.adder.build": "",
"header.adder.team": "",

View File

@ -39,5 +39,7 @@
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
"banned.banModal.expiresAtHelp": "",
"new.heading": "",
"new.noPermissions": ""
}

View File

@ -33,6 +33,7 @@
"header.loggedInAs": "Logged in as {{userName}}",
"header.theme": "Theme",
"header.adder.tournament": "Tournament",
"header.adder.organization": "Organization",
"header.adder.calendarEvent": "Calendar event",
"header.adder.build": "Build",
"header.adder.team": "Team",

View File

@ -39,5 +39,7 @@
"banned.banModal.note": "Private note",
"banned.banModal.noteHelp": "This note is only visible to organization admins.",
"banned.banModal.expiresAt": "Ban expiration date",
"banned.banModal.expiresAtHelp": "Leave empty for a permanent ban"
"banned.banModal.expiresAtHelp": "Leave empty for a permanent ban",
"new.heading": "New Organization",
"new.noPermissions": "No permissions to add organizations. Organizations can be created by users with tournament adder permissions."
}

View File

@ -33,6 +33,7 @@
"header.loggedInAs": "Ingresado como {{userName}}",
"header.theme": "Tema",
"header.adder.tournament": "",
"header.adder.organization": "",
"header.adder.calendarEvent": "",
"header.adder.build": "",
"header.adder.team": "",

View File

@ -39,5 +39,7 @@
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
"banned.banModal.expiresAtHelp": "",
"new.heading": "",
"new.noPermissions": ""
}

View File

@ -33,6 +33,7 @@
"header.loggedInAs": "Ingresado como {{userName}}",
"header.theme": "Tema",
"header.adder.tournament": "",
"header.adder.organization": "",
"header.adder.calendarEvent": "",
"header.adder.build": "",
"header.adder.team": "",

View File

@ -39,5 +39,7 @@
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
"banned.banModal.expiresAtHelp": "",
"new.heading": "",
"new.noPermissions": ""
}

View File

@ -33,6 +33,7 @@
"header.loggedInAs": "Connecté en tant que {{userName}}",
"header.theme": "Thème",
"header.adder.tournament": "",
"header.adder.organization": "",
"header.adder.calendarEvent": "",
"header.adder.build": "",
"header.adder.team": "",

View File

@ -39,5 +39,7 @@
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
"banned.banModal.expiresAtHelp": "",
"new.heading": "",
"new.noPermissions": ""
}

View File

@ -33,6 +33,7 @@
"header.loggedInAs": "Connecté en tant que {{userName}}",
"header.theme": "Thème",
"header.adder.tournament": "Tournois",
"header.adder.organization": "",
"header.adder.calendarEvent": "Calendrier d'évenement",
"header.adder.build": "set",
"header.adder.team": "Equipe",

View File

@ -39,5 +39,7 @@
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
"banned.banModal.expiresAtHelp": "",
"new.heading": "",
"new.noPermissions": ""
}

View File

@ -33,6 +33,7 @@
"header.loggedInAs": "הנכם מחוברים בתור {{userName}}",
"header.theme": "נושא",
"header.adder.tournament": "",
"header.adder.organization": "",
"header.adder.calendarEvent": "",
"header.adder.build": "",
"header.adder.team": "",

View File

@ -39,5 +39,7 @@
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
"banned.banModal.expiresAtHelp": "",
"new.heading": "",
"new.noPermissions": ""
}

View File

@ -33,6 +33,7 @@
"header.loggedInAs": "Autenticato come {{userName}}",
"header.theme": "Tema",
"header.adder.tournament": "Torneo",
"header.adder.organization": "",
"header.adder.calendarEvent": "Evento calendario",
"header.adder.build": "Build",
"header.adder.team": "Team",

View File

@ -39,5 +39,7 @@
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
"banned.banModal.expiresAtHelp": "",
"new.heading": "",
"new.noPermissions": ""
}

View File

@ -33,6 +33,7 @@
"header.loggedInAs": "{{userName}} でログインしています",
"header.theme": "テーマ",
"header.adder.tournament": "トーナメント",
"header.adder.organization": "",
"header.adder.calendarEvent": "カレンダーイベント",
"header.adder.build": "ギア",
"header.adder.team": "チーム",

View File

@ -39,5 +39,7 @@
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
"banned.banModal.expiresAtHelp": "",
"new.heading": "",
"new.noPermissions": ""
}

View File

@ -33,6 +33,7 @@
"header.loggedInAs": "{{userName}}로 로그인됨",
"header.theme": "테마",
"header.adder.tournament": "",
"header.adder.organization": "",
"header.adder.calendarEvent": "",
"header.adder.build": "",
"header.adder.team": "",

View File

@ -39,5 +39,7 @@
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
"banned.banModal.expiresAtHelp": "",
"new.heading": "",
"new.noPermissions": ""
}

View File

@ -33,6 +33,7 @@
"header.loggedInAs": "",
"header.theme": "",
"header.adder.tournament": "",
"header.adder.organization": "",
"header.adder.calendarEvent": "",
"header.adder.build": "",
"header.adder.team": "",

View File

@ -39,5 +39,7 @@
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
"banned.banModal.expiresAtHelp": "",
"new.heading": "",
"new.noPermissions": ""
}

View File

@ -33,6 +33,7 @@
"header.loggedInAs": "Zalogowany/a jako {{userName}}",
"header.theme": "Motyw",
"header.adder.tournament": "",
"header.adder.organization": "",
"header.adder.calendarEvent": "",
"header.adder.build": "",
"header.adder.team": "",

View File

@ -39,5 +39,7 @@
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
"banned.banModal.expiresAtHelp": "",
"new.heading": "",
"new.noPermissions": ""
}

View File

@ -33,6 +33,7 @@
"header.loggedInAs": "Logado como {{userName}}",
"header.theme": "Tema",
"header.adder.tournament": "",
"header.adder.organization": "",
"header.adder.calendarEvent": "",
"header.adder.build": "",
"header.adder.team": "",

View File

@ -39,5 +39,7 @@
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
"banned.banModal.expiresAtHelp": "",
"new.heading": "",
"new.noPermissions": ""
}

View File

@ -33,6 +33,7 @@
"header.loggedInAs": "Вы вошли как {{userName}}",
"header.theme": "Тема",
"header.adder.tournament": "Турнир",
"header.adder.organization": "",
"header.adder.calendarEvent": "Календарное событие",
"header.adder.build": "Сборка",
"header.adder.team": "Команда",

View File

@ -39,5 +39,7 @@
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
"banned.banModal.expiresAtHelp": "",
"new.heading": "",
"new.noPermissions": ""
}

View File

@ -33,6 +33,7 @@
"header.loggedInAs": "以 {{userName}} 登录",
"header.theme": "主题",
"header.adder.tournament": "",
"header.adder.organization": "",
"header.adder.calendarEvent": "",
"header.adder.build": "",
"header.adder.team": "",

View File

@ -39,5 +39,7 @@
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
"banned.banModal.expiresAtHelp": "",
"new.heading": "",
"new.noPermissions": ""
}

View File

@ -1,22 +0,0 @@
import "dotenv/config";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import * as TournamentOrganizationRepository from "../app/features/tournament-organization/TournamentOrganizationRepository.server";
async function main() {
const name = process.argv[2]?.trim();
const ownerIdRaw = process.argv[3]?.trim();
const ownerId = Number(ownerIdRaw);
invariant(name, "name of org is required (argument 1)");
invariant(ownerIdRaw, "owner id is required (argument 2)");
invariant(!Number.isNaN(ownerId), "owner id must be a number");
await TournamentOrganizationRepository.create({
name,
ownerId,
});
logger.info(`Added new organization: ${name}`);
}
main();

View File

@ -0,0 +1,191 @@
// calculates org stats for the last 6 finished months
// used to decide which orgs are considered "established"
// you need at least 150 average monthly active players to be considered established
// if you drop below 100 you lose it
import "dotenv/config";
import { db } from "~/db/sql";
import { logger } from "~/utils/logger";
interface MonthData {
year: number;
month: number;
label: string;
startTimestamp: number;
endTimestamp: number;
}
interface OrgMonthlyData {
orgId: number;
orgName: string;
monthlyParticipants: number[];
totalUniqueParticipants: number;
averageMonthlyParticipants: number;
}
function getLastSixFinishedMonths(): MonthData[] {
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth();
const months: MonthData[] = [];
for (let i = 1; i <= 6; i++) {
const date = new Date(currentYear, currentMonth - i, 1);
const year = date.getFullYear();
const month = date.getMonth();
const startTimestamp = Math.floor(
new Date(year, month, 1).getTime() / 1000,
);
const endTimestamp = Math.floor(
new Date(year, month + 1, 1).getTime() / 1000,
);
months.push({
year,
month,
label: date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
}),
startTimestamp,
endTimestamp,
});
}
return months;
}
async function getParticipantsForOrgInMonth(
organizationId: number,
startTimestamp: number,
endTimestamp: number,
): Promise<number> {
const result = await db
.selectFrom("CalendarEvent as ce")
.innerJoin("CalendarEventDate as ced", "ced.eventId", "ce.id")
.innerJoin("Tournament as t", "t.id", "ce.tournamentId")
.innerJoin("TournamentTeam as tt", "tt.tournamentId", "t.id")
.innerJoin(
"TournamentTeamCheckIn as ttci",
"ttci.tournamentTeamId",
"tt.id",
)
.innerJoin(
"TournamentMatchGameResultParticipant as tmgrp",
"tmgrp.tournamentTeamId",
"tt.id",
)
.select(({ fn }) => fn.count<number>("tmgrp.userId").distinct().as("count"))
.where("ce.organizationId", "=", organizationId)
.where("ced.startTime", ">=", startTimestamp)
.where("ced.startTime", "<", endTimestamp)
.where("ttci.checkedInAt", "is not", null)
.where("ttci.isCheckOut", "=", 0)
.executeTakeFirst();
return result?.count ?? 0;
}
async function getOrgsWithRecentTournaments(
months: MonthData[],
): Promise<number[]> {
const earliestTimestamp = months[months.length - 1].startTimestamp;
const orgs = await db
.selectFrom("CalendarEvent as ce")
.innerJoin("CalendarEventDate as ced", "ced.eventId", "ce.id")
.select("ce.organizationId")
.distinct()
.where("ce.organizationId", "is not", null)
.where("ced.startTime", ">=", earliestTimestamp)
.execute();
return orgs.map((org) => org.organizationId!);
}
async function main() {
const months = getLastSixFinishedMonths();
logger.info(
`Analyzing last 6 finished months: ${months.map((m) => m.label).join(", ")}\n`,
);
const orgIds = await getOrgsWithRecentTournaments(months);
logger.info(
`Found ${orgIds.length} organizations with tournaments in this period\n`,
);
const orgData: OrgMonthlyData[] = [];
for (const orgId of orgIds) {
const org = await db
.selectFrom("TournamentOrganization")
.select(["id", "name"])
.where("id", "=", orgId)
.executeTakeFirst();
if (!org) continue;
const monthlyParticipants: number[] = [];
for (const month of months) {
const count = await getParticipantsForOrgInMonth(
orgId,
month.startTimestamp,
month.endTimestamp,
);
monthlyParticipants.push(count);
}
const totalUniqueParticipants = monthlyParticipants.reduce(
(sum, count) => sum + count,
0,
);
const averageMonthlyParticipants = totalUniqueParticipants / 6;
orgData.push({
orgId: org.id,
orgName: org.name,
monthlyParticipants,
totalUniqueParticipants,
averageMonthlyParticipants,
});
}
orgData.sort((a, b) => {
if (b.averageMonthlyParticipants !== a.averageMonthlyParticipants) {
return b.averageMonthlyParticipants - a.averageMonthlyParticipants;
}
return b.totalUniqueParticipants - a.totalUniqueParticipants;
});
logger.info("=".repeat(80));
logger.info("ORGANIZATION MONTHLY ACTIVE PLAYERS REPORT");
logger.info("=".repeat(80));
logger.info();
const headers = ["Org", "Avg", ...months.map((m) => m.label)];
logger.info(headers.join(" | "));
logger.info("-".repeat(80));
for (const org of orgData) {
const monthValues = org.monthlyParticipants.map((count) =>
count === 0 ? "-" : count.toString(),
);
const row = [
org.orgName,
org.averageMonthlyParticipants.toFixed(1),
...monthValues,
];
logger.info(row.join(" | "));
}
logger.info();
logger.info(`Total organizations: ${orgData.length}`);
}
main().catch((err) => {
logger.error("Error in org-monthly-active-players.ts", err);
process.exit(1);
});