mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Add new org by user + some other things
This commit is contained in:
parent
1bbb14b1dc
commit
c6e7b08ebc
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
};
|
||||
43
app/features/tournament-organization/routes/org.new.tsx
Normal file
43
app/features/tournament-organization/routes/org.new.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -39,5 +39,7 @@
|
|||
"banned.banModal.note": "",
|
||||
"banned.banModal.noteHelp": "",
|
||||
"banned.banModal.expiresAt": "",
|
||||
"banned.banModal.expiresAtHelp": ""
|
||||
"banned.banModal.expiresAtHelp": "",
|
||||
"new.heading": "",
|
||||
"new.noPermissions": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -39,5 +39,7 @@
|
|||
"banned.banModal.note": "",
|
||||
"banned.banModal.noteHelp": "",
|
||||
"banned.banModal.expiresAt": "",
|
||||
"banned.banModal.expiresAtHelp": ""
|
||||
"banned.banModal.expiresAtHelp": "",
|
||||
"new.heading": "",
|
||||
"new.noPermissions": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -39,5 +39,7 @@
|
|||
"banned.banModal.note": "",
|
||||
"banned.banModal.noteHelp": "",
|
||||
"banned.banModal.expiresAt": "",
|
||||
"banned.banModal.expiresAtHelp": ""
|
||||
"banned.banModal.expiresAtHelp": "",
|
||||
"new.heading": "",
|
||||
"new.noPermissions": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -39,5 +39,7 @@
|
|||
"banned.banModal.note": "",
|
||||
"banned.banModal.noteHelp": "",
|
||||
"banned.banModal.expiresAt": "",
|
||||
"banned.banModal.expiresAtHelp": ""
|
||||
"banned.banModal.expiresAtHelp": "",
|
||||
"new.heading": "",
|
||||
"new.noPermissions": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -39,5 +39,7 @@
|
|||
"banned.banModal.note": "",
|
||||
"banned.banModal.noteHelp": "",
|
||||
"banned.banModal.expiresAt": "",
|
||||
"banned.banModal.expiresAtHelp": ""
|
||||
"banned.banModal.expiresAtHelp": "",
|
||||
"new.heading": "",
|
||||
"new.noPermissions": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -39,5 +39,7 @@
|
|||
"banned.banModal.note": "",
|
||||
"banned.banModal.noteHelp": "",
|
||||
"banned.banModal.expiresAt": "",
|
||||
"banned.banModal.expiresAtHelp": ""
|
||||
"banned.banModal.expiresAtHelp": "",
|
||||
"new.heading": "",
|
||||
"new.noPermissions": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
"header.loggedInAs": "הנכם מחוברים בתור {{userName}}",
|
||||
"header.theme": "נושא",
|
||||
"header.adder.tournament": "",
|
||||
"header.adder.organization": "",
|
||||
"header.adder.calendarEvent": "",
|
||||
"header.adder.build": "",
|
||||
"header.adder.team": "",
|
||||
|
|
|
|||
|
|
@ -39,5 +39,7 @@
|
|||
"banned.banModal.note": "",
|
||||
"banned.banModal.noteHelp": "",
|
||||
"banned.banModal.expiresAt": "",
|
||||
"banned.banModal.expiresAtHelp": ""
|
||||
"banned.banModal.expiresAtHelp": "",
|
||||
"new.heading": "",
|
||||
"new.noPermissions": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -39,5 +39,7 @@
|
|||
"banned.banModal.note": "",
|
||||
"banned.banModal.noteHelp": "",
|
||||
"banned.banModal.expiresAt": "",
|
||||
"banned.banModal.expiresAtHelp": ""
|
||||
"banned.banModal.expiresAtHelp": "",
|
||||
"new.heading": "",
|
||||
"new.noPermissions": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
"header.loggedInAs": "{{userName}} でログインしています",
|
||||
"header.theme": "テーマ",
|
||||
"header.adder.tournament": "トーナメント",
|
||||
"header.adder.organization": "",
|
||||
"header.adder.calendarEvent": "カレンダーイベント",
|
||||
"header.adder.build": "ギア",
|
||||
"header.adder.team": "チーム",
|
||||
|
|
|
|||
|
|
@ -39,5 +39,7 @@
|
|||
"banned.banModal.note": "",
|
||||
"banned.banModal.noteHelp": "",
|
||||
"banned.banModal.expiresAt": "",
|
||||
"banned.banModal.expiresAtHelp": ""
|
||||
"banned.banModal.expiresAtHelp": "",
|
||||
"new.heading": "",
|
||||
"new.noPermissions": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
"header.loggedInAs": "{{userName}}로 로그인됨",
|
||||
"header.theme": "테마",
|
||||
"header.adder.tournament": "",
|
||||
"header.adder.organization": "",
|
||||
"header.adder.calendarEvent": "",
|
||||
"header.adder.build": "",
|
||||
"header.adder.team": "",
|
||||
|
|
|
|||
|
|
@ -39,5 +39,7 @@
|
|||
"banned.banModal.note": "",
|
||||
"banned.banModal.noteHelp": "",
|
||||
"banned.banModal.expiresAt": "",
|
||||
"banned.banModal.expiresAtHelp": ""
|
||||
"banned.banModal.expiresAtHelp": "",
|
||||
"new.heading": "",
|
||||
"new.noPermissions": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
"header.loggedInAs": "",
|
||||
"header.theme": "",
|
||||
"header.adder.tournament": "",
|
||||
"header.adder.organization": "",
|
||||
"header.adder.calendarEvent": "",
|
||||
"header.adder.build": "",
|
||||
"header.adder.team": "",
|
||||
|
|
|
|||
|
|
@ -39,5 +39,7 @@
|
|||
"banned.banModal.note": "",
|
||||
"banned.banModal.noteHelp": "",
|
||||
"banned.banModal.expiresAt": "",
|
||||
"banned.banModal.expiresAtHelp": ""
|
||||
"banned.banModal.expiresAtHelp": "",
|
||||
"new.heading": "",
|
||||
"new.noPermissions": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -39,5 +39,7 @@
|
|||
"banned.banModal.note": "",
|
||||
"banned.banModal.noteHelp": "",
|
||||
"banned.banModal.expiresAt": "",
|
||||
"banned.banModal.expiresAtHelp": ""
|
||||
"banned.banModal.expiresAtHelp": "",
|
||||
"new.heading": "",
|
||||
"new.noPermissions": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -39,5 +39,7 @@
|
|||
"banned.banModal.note": "",
|
||||
"banned.banModal.noteHelp": "",
|
||||
"banned.banModal.expiresAt": "",
|
||||
"banned.banModal.expiresAtHelp": ""
|
||||
"banned.banModal.expiresAtHelp": "",
|
||||
"new.heading": "",
|
||||
"new.noPermissions": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
"header.loggedInAs": "Вы вошли как {{userName}}",
|
||||
"header.theme": "Тема",
|
||||
"header.adder.tournament": "Турнир",
|
||||
"header.adder.organization": "",
|
||||
"header.adder.calendarEvent": "Календарное событие",
|
||||
"header.adder.build": "Сборка",
|
||||
"header.adder.team": "Команда",
|
||||
|
|
|
|||
|
|
@ -39,5 +39,7 @@
|
|||
"banned.banModal.note": "",
|
||||
"banned.banModal.noteHelp": "",
|
||||
"banned.banModal.expiresAt": "",
|
||||
"banned.banModal.expiresAtHelp": ""
|
||||
"banned.banModal.expiresAtHelp": "",
|
||||
"new.heading": "",
|
||||
"new.noPermissions": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
"header.loggedInAs": "以 {{userName}} 登录",
|
||||
"header.theme": "主题",
|
||||
"header.adder.tournament": "",
|
||||
"header.adder.organization": "",
|
||||
"header.adder.calendarEvent": "",
|
||||
"header.adder.build": "",
|
||||
"header.adder.team": "",
|
||||
|
|
|
|||
|
|
@ -39,5 +39,7 @@
|
|||
"banned.banModal.note": "",
|
||||
"banned.banModal.noteHelp": "",
|
||||
"banned.banModal.expiresAt": "",
|
||||
"banned.banModal.expiresAtHelp": ""
|
||||
"banned.banModal.expiresAtHelp": "",
|
||||
"new.heading": "",
|
||||
"new.noPermissions": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
191
scripts/org-monthly-active-players.ts
Normal file
191
scripts/org-monthly-active-players.ts
Normal 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);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user