Established orgs (#2609)
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

This commit is contained in:
Kalle 2025-11-01 11:49:43 +02:00 committed by GitHub
parent 8d2811d49b
commit a2b3e36c49
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 375 additions and 63 deletions

View File

@ -2624,6 +2624,11 @@ async function organization() {
role: "MEMBER",
roleDisplayName: null,
},
{
userId: 3,
role: "ADMIN",
roleDisplayName: null,
},
],
series: [],
badges: [],

View File

@ -716,6 +716,7 @@ export interface TournamentOrganization {
description: string | null;
socials: JSONColumnTypeNullable<string[]>;
avatarImgId: number | null;
isEstablished: Generated<DBBoolean>;
}
export const TOURNAMENT_ORGANIZATION_ROLES = [

View File

@ -89,6 +89,7 @@ function tournamentOrganization(organizationId: Expression<number | null>) {
"TournamentOrganization.id",
"TournamentOrganization.name",
"TournamentOrganization.slug",
"TournamentOrganization.isEstablished",
"UserSubmittedImage.url as avatarUrl",
])
.whereRef("TournamentOrganization.id", "=", organizationId),

View File

@ -1,10 +1,7 @@
import type { ActionFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import type { CalendarEventTag } from "~/db/tables";
import {
type AuthenticatedUser,
requireUser,
} from "~/features/auth/core/user.server";
import { requireUser } from "~/features/auth/core/user.server";
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import { newCalendarEventActionSchema } from "~/features/calendar/calendar-schemas.server";
@ -23,6 +20,7 @@ import {
} from "~/utils/dates";
import {
badRequestIfFalsy,
errorToast,
errorToastIfFalsy,
parseFormData,
uploadImageIfSubmitted,
@ -30,6 +28,7 @@ import {
import { calendarEventPage } from "~/utils/urls";
import { CALENDAR_EVENT } from "../calendar-constants";
import { canEditCalendarEvent, regClosesAtDate } from "../calendar-utils";
import { findValidOrganizations } from "../loaders/calendar.new.server";
export const action: ActionFunction = async ({ request }) => {
const user = await requireUser(request);
@ -44,11 +43,21 @@ export const action: ActionFunction = async ({ request }) => {
parseAsync: true,
});
requireRoleIfNeeded({
isAddingTournament: data.toToolsEnabled,
isEditing: Boolean(data.eventToEditId),
user,
});
const isEditing = Boolean(data.eventToEditId);
const isAddingTournament = data.toToolsEnabled;
if (data.organizationId) {
await validateOrganization({
userId: user.id,
organizationId: data.organizationId,
isTournamentAdder: user.roles.includes("TOURNAMENT_ADDER"),
});
} else if (!isEditing) {
requireRole(
user,
isAddingTournament ? "TOURNAMENT_ADDER" : "CALENDAR_EVENT_ADDER",
);
}
const managedBadges = await BadgeRepository.findManagedByUserId(user.id);
@ -188,19 +197,21 @@ export const action: ActionFunction = async ({ request }) => {
throw redirect(calendarEventPage(createdEventId));
};
function requireRoleIfNeeded({
isAddingTournament,
isEditing,
user,
/** Checks user has permissions to create a tournament in this organization */
async function validateOrganization({
userId,
organizationId,
isTournamentAdder,
}: {
isAddingTournament: boolean;
isEditing: boolean;
user: AuthenticatedUser;
userId: number;
organizationId: number;
isTournamentAdder: boolean;
}) {
if (isEditing) return;
const orgs = await findValidOrganizations(userId, isTournamentAdder);
requireRole(
user,
isAddingTournament ? "TOURNAMENT_ADDER" : "CALENDAR_EVENT_ADDER",
const isValid = orgs.some(
(org) => typeof org !== "string" && org.id === organizationId,
);
if (!isValid) errorToast("Not authorized to add event for this organization");
}

View File

@ -1,5 +1,6 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import * as R from "remeda";
import { requireUser } from "~/features/auth/core/user.server";
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
@ -92,7 +93,10 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
? await CalendarRepository.findRecentTournamentsByAuthorId(user.id)
: undefined,
organizations: (
await TournamentOrganizationRepository.findByOrganizerUserId(user.id)
await findValidOrganizations(
user.id,
user.roles.includes("TOURNAMENT_ADDER"),
)
).concat(
eventToEdit?.tournament?.ctx.organization
? eventToEdit.tournament.ctx.organization
@ -100,3 +104,20 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
),
};
};
export async function findValidOrganizations(
userId: number,
isTournamentAdder: boolean,
) {
const orgs = await TournamentOrganizationRepository.findByUserId(userId, {
roles: ["ADMIN"],
});
if (isTournamentAdder) {
return ["NO_ORG", ...orgs.map((org) => R.omit(org, ["isEstablished"]))];
}
return orgs
.filter((org) => org.isEstablished)
.map((org) => R.omit(org, ["isEstablished"]));
}

View File

@ -1,5 +1,5 @@
import type { MetaFunction } from "@remix-run/node";
import { Form, useFetcher, useLoaderData } from "@remix-run/react";
import { Form, Link, useFetcher, useLoaderData } from "@remix-run/react";
import clsx from "clsx";
import Compressor from "compressorjs";
import * as React from "react";
@ -32,7 +32,7 @@ import {
import invariant from "~/utils/invariant";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { pathnameFromPotentialURL } from "~/utils/strings";
import { CREATING_TOURNAMENT_DOC_LINK } from "~/utils/urls";
import { CREATING_TOURNAMENT_DOC_LINK, FAQ_PAGE } from "~/utils/urls";
import { userSubmittedImage } from "~/utils/urls-img";
import {
CALENDAR_EVENT,
@ -80,7 +80,6 @@ const useBaseEvent = () => {
export default function CalendarNewEventPage() {
const baseEvent = useBaseEvent();
const isCalendarEventAdder = useHasRole("CALENDAR_EVENT_ADDER");
const isTournamentAdder = useHasRole("TOURNAMENT_ADDER");
const data = useLoaderData<typeof loader>();
if (!data.eventToEdit && !isCalendarEventAdder) {
@ -93,12 +92,17 @@ export default function CalendarNewEventPage() {
);
}
if (!data.eventToEdit && data.isAddingTournament && !isTournamentAdder) {
if (
!data.eventToEdit &&
data.isAddingTournament &&
data.organizations.length === 0
) {
return (
<Main className="stack items-center">
<Alert variation="WARNING">
No permissions to add tournaments. Tournaments are in beta, accessible
by Patreon supporters and established TO&apos;s.
by Patreon supporters and established TO&apos;s. See{" "}
<Link to={FAQ_PAGE}>FAQ</Link> for more info.
</Alert>
</Main>
);
@ -360,14 +364,20 @@ function OrganizationSelect() {
<select
id={id}
name="organizationId"
defaultValue={baseEvent?.organization?.id}
defaultValue={baseEvent?.organization?.id ?? ""}
>
<option>Select an organization</option>
{data.organizations.map((org) => (
<option key={org.id} value={org.id}>
{org.name}
{data.organizations.includes("NO_ORG") ? (
<option key="NO_ORG" value="">
None
</option>
))}
) : null}
{data.organizations
.filter((org) => typeof org !== "string")
.map((org) => (
<option key={org.id} value={org.id}>
{org.name}
</option>
))}
</select>
</div>
);

View File

@ -6,7 +6,7 @@ import type { SendouRouteHandle } from "~/utils/remix.server";
import styles from "./faq.module.css";
const AMOUNT_OF_QUESTIONS = 9;
const AMOUNT_OF_QUESTIONS = 10;
export const meta: MetaFunction = (args) => {
return metaTags({

View File

@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { dbInsertUsers, dbReset } from "~/utils/Test";
import * as TournamentOrganizationRepository from "./TournamentOrganizationRepository.server";
const createOrganization = async ({
ownerId,
name,
}: {
ownerId: number;
name: string;
}) => {
return TournamentOrganizationRepository.create({
ownerId,
name,
});
};
describe("findByUserId", () => {
beforeEach(async () => {
await dbInsertUsers(3);
});
afterEach(() => {
dbReset();
});
test("returns organizations where user is a member", async () => {
const org1 = await createOrganization({
ownerId: 1,
name: "Test Organization 1",
});
const org2 = await createOrganization({
ownerId: 1,
name: "Test Organization 2",
});
const result = await TournamentOrganizationRepository.findByUserId(1);
expect(result).toHaveLength(2);
expect(result.map((org) => org.id).sort()).toEqual(
[org1.id, org2.id].sort(),
);
});
test("filters organizations by role when roles parameter is provided", async () => {
const org1 = await createOrganization({
ownerId: 1,
name: "Test Organization 1",
});
const org2 = await createOrganization({
ownerId: 2,
name: "Test Organization 2",
});
const org2Data = await TournamentOrganizationRepository.findBySlug(
org2.slug,
);
await TournamentOrganizationRepository.update({
id: org2.id,
name: org2Data!.name,
description: org2Data!.description,
socials: org2Data!.socials,
members: [
{ userId: 2, role: "ADMIN", roleDisplayName: null },
{ userId: 1, role: "ORGANIZER", roleDisplayName: null },
],
series: [],
badges: [],
});
const adminOrgs = await TournamentOrganizationRepository.findByUserId(1, {
roles: ["ADMIN"],
});
const allOrgs = await TournamentOrganizationRepository.findByUserId(1);
expect(adminOrgs).toHaveLength(1);
expect(adminOrgs[0].id).toBe(org1.id);
expect(allOrgs).toHaveLength(2);
});
test("returns empty array when user is not a member of any organization", async () => {
await createOrganization({
ownerId: 1,
name: "Test Organization",
});
const result = await TournamentOrganizationRepository.findByUserId(2);
expect(result).toHaveLength(0);
});
});

View File

@ -57,6 +57,7 @@ export async function findBySlug(slug: string) {
"TournamentOrganization.description",
"TournamentOrganization.socials",
"TournamentOrganization.slug",
"TournamentOrganization.isEstablished",
"UserSubmittedImage.url as avatarUrl",
jsonArrayFrom(
eb
@ -124,7 +125,15 @@ export async function findBySlug(slug: string) {
};
}
export function findByOrganizerUserId(userId: number) {
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(
@ -132,14 +141,14 @@ export function findByOrganizerUserId(userId: number) {
"TournamentOrganization.id",
"TournamentOrganizationMember.organizationId",
)
.select(["TournamentOrganization.id", "TournamentOrganization.name"])
.select([
"TournamentOrganization.id",
"TournamentOrganization.name",
"TournamentOrganization.isEstablished",
])
.where("TournamentOrganizationMember.userId", "=", userId)
.where((eb) =>
eb("TournamentOrganizationMember.role", "=", "ADMIN").or(
"TournamentOrganizationMember.role",
"=",
"ORGANIZER",
),
.$if(roles.length > 0, (qb) =>
qb.where("TournamentOrganizationMember.role", "in", roles),
)
.orderBy("TournamentOrganization.id", "asc")
.execute();
@ -532,3 +541,17 @@ export async function countOrganizationsByUserId(userId: number) {
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();
}

View File

@ -1,7 +1,10 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { isFuture } from "date-fns";
import { requireUser } from "~/features/auth/core/user.server";
import { requirePermission } from "~/modules/permissions/guards.server";
import {
requirePermission,
requireRole,
} from "~/modules/permissions/guards.server";
import {
databaseTimestampToDate,
dateToDatabaseTimestamp,
@ -23,10 +26,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
schema: orgPageActionSchema,
});
requirePermission(organization, "BAN", user);
switch (data._action) {
case "BAN_USER": {
requirePermission(organization, "BAN", user);
const allBannedUsers =
await TournamentOrganizationRepository.allBannedUsersByOrganizationId(
organization.id,
@ -60,6 +63,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
break;
}
case "UNBAN_USER": {
requirePermission(organization, "BAN", user);
await TournamentOrganizationRepository.unbanUser({
organizationId: organization.id,
userId: data.userId,
@ -71,6 +76,20 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
break;
}
case "UPDATE_IS_ESTABLISHED": {
requireRole(user, "ADMIN");
await TournamentOrganizationRepository.updateIsEstablished(
organization.id,
data.isEstablished,
);
logger.info(
`Organization isEstablished updated: organization=${organization.name} (${organization.id}), isEstablished=${data.isEstablished}, updated by userId=${user.id}`,
);
break;
}
default: {
assertUnreachable(data);
}

View File

@ -1,9 +1,15 @@
import type { MetaFunction, SerializeFrom } from "@remix-run/node";
import { Link, useLoaderData, useSearchParams } from "@remix-run/react";
import {
Link,
useFetcher,
useLoaderData,
useSearchParams,
} from "@remix-run/react";
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import { Divider } from "~/components/Divider";
import { LinkButton } from "~/components/elements/Button";
import { SendouSwitch } from "~/components/elements/Switch";
import {
SendouTab,
SendouTabList,
@ -20,7 +26,7 @@ import { Pagination } from "~/components/Pagination";
import { Placement } from "~/components/Placement";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
import { BannedUsersList } from "~/features/tournament-organization/components/BannedPlayersList";
import { useHasPermission } from "~/modules/permissions/hooks";
import { useHasPermission, useHasRole } from "~/modules/permissions/hooks";
import { databaseTimestampNow, databaseTimestampToDate } from "~/utils/dates";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
@ -94,6 +100,7 @@ export default function TournamentOrganizationPage() {
return (
<Main className="stack lg">
<LogoHeader />
<AdminControls />
<InfoTabs />
{data.organization.series.length > 0 ? (
<SeriesSelector series={data.organization.series} />
@ -145,6 +152,37 @@ function LogoHeader() {
);
}
function AdminControls() {
const data = useLoaderData<typeof loader>();
const fetcher = useFetcher();
const isAdmin = useHasRole("ADMIN");
if (!isAdmin) return null;
const onChange = (isSelected: boolean) => {
fetcher.submit(
{ _action: "UPDATE_IS_ESTABLISHED", isEstablished: isSelected },
{ method: "post", encType: "application/json" },
);
};
return (
<div className="stack sm">
<div className="text-sm font-semi-bold">Admin Controls</div>
<div>
<SendouSwitch
defaultSelected={Boolean(data.organization.isEstablished)}
onChange={onChange}
isDisabled={fetcher.state !== "idle"}
data-testid="is-established-switch"
>
Is Established Organization
</SendouSwitch>
</div>
</div>
);
}
function InfoTabs() {
const { t } = useTranslation(["org"]);
const data = useLoaderData<typeof loader>();

View File

@ -118,7 +118,13 @@ export const unbanUserActionSchema = z.object({
userId: id,
});
export const updateIsEstablishedActionSchema = z.object({
_action: _action("UPDATE_IS_ESTABLISHED"),
isEstablished: z.boolean(),
});
export const orgPageActionSchema = z.union([
banUserActionSchema,
unbanUserActionSchema,
updateIsEstablishedActionSchema,
]);

Binary file not shown.

View File

@ -22,6 +22,10 @@ Name of the tournament.
Description of the tournament, shown when registering. Supports Markdown including embedding images.
### Organization
Which organization to host the tournament under. Note that if you do not have global tournament adder permissions (patron perk) you can only host tournaments for organizations that are "established". To host a tournament for such an organization you need either the Admin or Organizer role.
### Rules
Rules of the tournament. Supports Markdown including embedding images.

View File

@ -9,7 +9,11 @@ import {
selectUser,
submit,
} from "~/utils/playwright";
import { tournamentOrganizationPage, tournamentPage } from "~/utils/urls";
import {
TOURNAMENT_NEW_PAGE,
tournamentOrganizationPage,
tournamentPage,
} from "~/utils/urls";
const url = tournamentOrganizationPage({
organizationSlug: "sendouink",
@ -145,4 +149,41 @@ test.describe("Tournament Organization", () => {
await expect(page.getByText("Teams (1)")).toBeVisible();
});
test("allows member of established org to create tournament", async ({
page,
}) => {
const ORG_ADMIN_ID = 3; // 3 = org admin, but not site admin
await seed(page);
await impersonate(page, ORG_ADMIN_ID);
await navigate({
page,
url: TOURNAMENT_NEW_PAGE,
});
await expect(
page.getByText("No permissions to add tournaments"),
).toBeVisible();
await expect(page.getByText("New tournament")).not.toBeVisible();
await impersonate(page, ADMIN_ID);
await navigate({
page,
url: "/org/sendouink",
});
await page.getByTestId("is-established-switch").click();
await impersonate(page, ORG_ADMIN_ID);
await navigate({
page,
url: TOURNAMENT_NEW_PAGE,
});
await expect(
page.getByText("No permissions to add tournaments"),
).not.toBeVisible();
await expect(page.getByText("New tournament")).toBeVisible();
});
});

View File

@ -16,5 +16,7 @@
"q8": "Hvordan kan jeg finde mine X-kampresultater på min profil?",
"a8": "",
"q9": "Hvordan kan jeg uploade kunst, som jeg har bestilt",
"a9": "Du kan kun uploade kunst, som du selv har lavet. Det skal også være Splatoon-relateret. For bestilte kunstværker, så henvend dig til kunstneren, så De kan uploade det til hjemmesiden. De kan også knytte kunstværket til din profil, så det bliver vist på din profil"
"a9": "Du kan kun uploade kunst, som du selv har lavet. Det skal også være Splatoon-relateret. For bestilte kunstværker, så henvend dig til kunstneren, så De kan uploade det til hjemmesiden. De kan også knytte kunstværket til din profil, så det bliver vist på din profil",
"q10:": "",
"a10": ""
}

View File

@ -16,5 +16,7 @@
"q8": "",
"a8": "",
"q9": "",
"a9": ""
"a9": "",
"q10:": "",
"a10": ""
}

View File

@ -16,5 +16,7 @@
"q8": "How can I show X Battle results on my profile?",
"a8": "Find your player page from the Top Search page. Post the link to the helpdesk on our Discord saying you want it linked. Note that the linking is not possible if you have not finished a season in the Top 500.",
"q9": "How can I upload art that I commissioned?",
"a9": "You can only upload art that you made yourself. It also has to be Splatoon related. For commissioned work please ask the artist to upload it themselves to the site. They are able to link your user profile and that will make it show on your profile."
"a9": "You can only upload art that you made yourself. It also has to be Splatoon related. For commissioned work please ask the artist to upload it themselves to the site. They are able to link your user profile and that will make it show on your profile.",
"q10": "What are established tournament organizations?",
"a10": "These are organizations that have permissions to host tournaments on sendou.ink. To become an established organization you need 150 monthly active users in your tournaments over 6 month period (checked in June and December). This can be either on sendou.ink or another platform. In addition patrons (of Supporter tier or above) have the tournament permissions."
}

View File

@ -16,5 +16,7 @@
"q8": "¿Cómo puedo mostrar mis resultados de Combate X en mi perfil?",
"a8": "",
"q9": "¿Cómo puedo subir arte que he comisionado?",
"a9": "Solo puedes agregar arte que tú has creado. También tiene que tener relación con Splatoon. Para piezas comisionadas, por favor pide al artista que suba el arte. El artista puede enlazar tu perfil y así se podrá mostrar en tu perfil."
"a9": "Solo puedes agregar arte que tú has creado. También tiene que tener relación con Splatoon. Para piezas comisionadas, por favor pide al artista que suba el arte. El artista puede enlazar tu perfil y así se podrá mostrar en tu perfil.",
"q10:": "",
"a10": ""
}

View File

@ -16,5 +16,7 @@
"q8": "¿Cómo puedo mostrar mis resultados de Combate X en mi perfil?",
"a8": "",
"q9": "¿Cómo puedo subir arte que he comisionado?",
"a9": "Solo puedes agregar arte que tú has creado. También tiene que tener relación con Splatoon. Para piezas comisionadas, por favor pide al artista que suba el arte. El artista puede enlazar tu perfil y así se podrá mostrar en tu perfil."
"a9": "Solo puedes agregar arte que tú has creado. También tiene que tener relación con Splatoon. Para piezas comisionadas, por favor pide al artista que suba el arte. El artista puede enlazar tu perfil y así se podrá mostrar en tu perfil.",
"q10:": "",
"a10": ""
}

View File

@ -16,5 +16,7 @@
"q8": "Comment puis-je montrer mes résultats de Match X sur mon profil ?",
"a8": "",
"q9": "",
"a9": ""
"a9": "",
"q10:": "",
"a10": ""
}

View File

@ -16,5 +16,7 @@
"q8": "Comment puis-je montrer mes résultats de Match X sur mon profil ?",
"a8": "Trouvez votre page de joueur depuis la page Top 500. Postez le lien vers le channel helpdesk sur notre Discord en indiquant que vous souhaitez qu'il soit lié. Note: Le lien n'est pas possible si vous n'avez pas terminé une saison dans le Top 500.",
"q9": "Comment puis-je publier une œuvre que j'ai commandé ?",
"a9": "Vous pouvez seulement publier une œuvre que vous avez réalisé. Il doit également y avoir un rapport avec Splatoon. Veuillez demander à lartiste de la télécharger lui-même sur le site pour une œuvre commandée. Ils sont capables de lier votre profil d'utilisateur à leur œuvre et cela la fera apparaître sur votre profil."
"a9": "Vous pouvez seulement publier une œuvre que vous avez réalisé. Il doit également y avoir un rapport avec Splatoon. Veuillez demander à lartiste de la télécharger lui-même sur le site pour une œuvre commandée. Ils sont capables de lier votre profil d'utilisateur à leur œuvre et cela la fera apparaître sur votre profil.",
"q10:": "",
"a10": ""
}

View File

@ -16,5 +16,7 @@
"q8": "כיצד אוכל להציג תוצאות X Battle בפרופיל שלי?",
"a8": "",
"q9": "איך אני יכול להעלות ציור שאני ביקשתי?",
"a9": "ניתן להעלות ציור שהכנת בעצמך. הוא צריך להיות קשור ל-Splatoon. בשביל עבודה שביקשת נא לבקש מהאומן להעלות את הציור בכוחות עצמם לאתר. הם יכולים לקשר את הפרופיל שלך מה שיגרום לו להופיע שם."
"a9": "ניתן להעלות ציור שהכנת בעצמך. הוא צריך להיות קשור ל-Splatoon. בשביל עבודה שביקשת נא לבקש מהאומן להעלות את הציור בכוחות עצמם לאתר. הם יכולים לקשר את הפרופיל שלך מה שיגרום לו להופיע שם.",
"q10:": "",
"a10": ""
}

View File

@ -16,5 +16,7 @@
"q8": "Come faccio a mostrare i risultati delle Partite X sul mio profilo?",
"a8": "Cerca la tua pagina profilo sulla pagina Ricerca Top. Posta il link sull'helpdesk nel nostro Discord dicendo di volerla associata. L'associazione non è possibile se non hai concluso una stagione in Top 500.",
"q9": "Come faccio a caricare un'opera che ho commissionato?",
"a9": "Puoi caricare solo art che hai creato tu stesso/a. Deve anche essere relativa a Splatoon. Per opere commissionate, si prega di chiedere all'artista originale di caricarle. Possono collegare il tuo profilo e fare in modo che esse vengano mostrate lì."
"a9": "Puoi caricare solo art che hai creato tu stesso/a. Deve anche essere relativa a Splatoon. Per opere commissionate, si prega di chiedere all'artista originale di caricarle. Possono collegare il tuo profilo e fare in modo che esse vengano mostrate lì.",
"q10:": "",
"a10": ""
}

View File

@ -16,5 +16,7 @@
"q8": "X ランクのバトル結果を自分のプロファイルに表示するにはどうしたらよいですか?",
"a8": "",
"q9": "依頼した作品はどうやってアップロードできますか?",
"a9": "自分で作った作品のみアップロードできます(もちろんスプラトゥーン関連でないといけません)。依頼した作品の場合はその作品の作者からアップロードするように伝えてください。そうすることによって自分のプロファイルに作品が表示されます。"
"a9": "自分で作った作品のみアップロードできます(もちろんスプラトゥーン関連でないといけません)。依頼した作品の場合はその作品の作者からアップロードするように伝えてください。そうすることによって自分のプロファイルに作品が表示されます。",
"q10:": "",
"a10": ""
}

View File

@ -16,5 +16,7 @@
"q8": "",
"a8": "",
"q9": "",
"a9": ""
"a9": "",
"q10:": "",
"a10": ""
}

View File

@ -16,5 +16,7 @@
"q8": "",
"a8": "",
"q9": "",
"a9": ""
"a9": "",
"q10:": "",
"a10": ""
}

View File

@ -16,5 +16,7 @@
"q8": "",
"a8": "",
"q9": "",
"a9": ""
"a9": "",
"q10:": "",
"a10": ""
}

View File

@ -16,5 +16,7 @@
"q8": "Como posso mostrar meus resultados do X Battle no meu perfil?",
"a8": "",
"q9": "Como posso fazer o upload de uma arte da qual fiz a comissão?",
"a9": "Você só pode fazer upload de arte que você mesmo(a) fez. Ela também tem que ser relacionada ao Splatoon. Para trabalhos comissionados, por favor peça o artista para fazer o upload no site. Eles são capazes de conectar a arte ao seu perfil de usuário e isso fará que ela apareça no seu perfil."
"a9": "Você só pode fazer upload de arte que você mesmo(a) fez. Ela também tem que ser relacionada ao Splatoon. Para trabalhos comissionados, por favor peça o artista para fazer o upload no site. Eles são capazes de conectar a arte ao seu perfil de usuário e isso fará que ela apareça no seu perfil.",
"q10:": "",
"a10": ""
}

View File

@ -16,5 +16,7 @@
"q8": "Как разместить результаты Боёв X на моём профиле?",
"a8": "Найдите вашу пользовательскую страницу в секции Топ по режиму. Отправьте сообщение в helpdesk канал на Discord сервере sendou.ink с просьбой привязать эту ссылку к вашему профилю. Обратите внимание, что это можно сделать только если вы закончили сезон в Топ 500.",
"q9": "Как разместить арт, который я заказал?",
"a9": "Вы можете разместить только арт, созданный непосредственно вами. Он также должен быть по тематике Splatoon. Для артов по заказу попросите художника-автора загрузить их на свою страницу. После того, как художник загрузит работу и отметит вас в ней, арт появится на вашем профиле."
"a9": "Вы можете разместить только арт, созданный непосредственно вами. Он также должен быть по тематике Splatoon. Для артов по заказу попросите художника-автора загрузить их на свою страницу. После того, как художник загрузит работу и отметит вас в ней, арт появится на вашем профиле.",
"q10:": "",
"a10": ""
}

View File

@ -16,5 +16,7 @@
"q8": "我应该如何在我的资料中显示X比赛结果",
"a8": "",
"q9": "我应该如何上传委托他人创作的艺术作品?",
"a9": "您只能上传您自己创作的艺术作品。并且需要与斯普拉遁相关。对于委托他人创作的作品,请联系作者亲自上传。他们可以将作品链接到您的个人信息,并展示在您的个人信息页。"
"a9": "您只能上传您自己创作的艺术作品。并且需要与斯普拉遁相关。对于委托他人创作的作品,请联系作者亲自上传。他们可以将作品链接到您的个人信息,并展示在您的个人信息页。",
"q10:": "",
"a10": ""
}

View File

@ -0,0 +1,8 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/* sql */ `alter table "TournamentOrganization" add "isEstablished" integer not null default 0`,
).run();
db.prepare(/* sql */ `update "User" set "isTournamentOrganizer" = 0`).run();
})();
}