Move calendar new action to different file

This commit is contained in:
Kalle
2024-04-28 11:13:13 +03:00
parent 7755c28cfa
commit c11d8ef9f7
4 changed files with 269 additions and 257 deletions

View File

@@ -0,0 +1,256 @@
import type { ActionFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { z } from "zod";
import { CALENDAR_EVENT } from "~/constants";
import type { CalendarEventTag } from "~/db/types";
import { requireUser } from "~/features/auth/core/user.server";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
import {
FORMATS_SHORT,
TOURNAMENT,
} from "~/features/tournament/tournament-constants";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import { canEditCalendarEvent } from "~/permissions";
import {
databaseTimestampToDate,
dateToDatabaseTimestamp,
} from "~/utils/dates";
import {
badRequestIfFalsy,
parseRequestFormData,
validate,
} from "~/utils/remix";
import { calendarEventPage } from "~/utils/urls";
import {
actualNumber,
checkboxValueToBoolean,
date,
falsyToNull,
id,
processMany,
removeDuplicates,
safeJSONParse,
toArray,
} from "~/utils/zod";
import { canAddNewEvent, regClosesAtDate } from "../calendar-utils";
import {
canCreateTournament,
formValuesToBracketProgression,
} from "../calendar-utils.server";
import { REG_CLOSES_AT_OPTIONS } from "../calendar-constants";
import { calendarEventMaxDate, calendarEventMinDate } from "../calendar-utils";
export const action: ActionFunction = async ({ request }) => {
const user = await requireUser(request);
const data = await parseRequestFormData({
request,
schema: newCalendarEventActionSchema,
parseAsync: true,
});
validate(canAddNewEvent(user), "Not authorized", 401);
const startTimes = data.date.map((date) => dateToDatabaseTimestamp(date));
const commonArgs = {
name: data.name,
description: data.description,
rules: data.rules,
startTimes,
bracketUrl: data.bracketUrl,
discordInviteCode: data.discordInviteCode,
tags: data.tags
? data.tags
.sort(
(a, b) =>
CALENDAR_EVENT.TAGS.indexOf(a as CalendarEventTag) -
CALENDAR_EVENT.TAGS.indexOf(b as CalendarEventTag),
)
.join(",")
: data.tags,
badges: data.badges ?? [],
toToolsEnabled: canCreateTournament(user) ? Number(data.toToolsEnabled) : 0,
toToolsMode:
rankedModesShort.find((mode) => mode === data.toToolsMode) ?? null,
bracketProgression: formValuesToBracketProgression(data),
teamsPerGroup: data.teamsPerGroup ?? undefined,
thirdPlaceMatch: data.thirdPlaceMatch ?? undefined,
isRanked: data.isRanked ?? undefined,
enableNoScreenToggle: data.enableNoScreenToggle ?? undefined,
autoCheckInAll: data.autoCheckInAll ?? undefined,
autonomousSubs: data.autonomousSubs ?? undefined,
swissGroupCount: data.swissGroupCount ?? undefined,
swissRoundCount: data.swissRoundCount ?? undefined,
tournamentToCopyId: data.tournamentToCopyId,
regClosesAt: data.regClosesAt
? dateToDatabaseTimestamp(
regClosesAtDate({
startTime: databaseTimestampToDate(startTimes[0]),
closesAt: data.regClosesAt,
}),
)
: undefined,
};
validate(
!commonArgs.toToolsEnabled || commonArgs.bracketProgression,
"Bracket progression must be set for tournaments",
);
const deserializedMaps = (() => {
if (!data.pool) return;
return MapPool.toDbList(data.pool);
})();
if (data.eventToEditId) {
const eventToEdit = badRequestIfFalsy(
await CalendarRepository.findById({ id: data.eventToEditId }),
);
if (eventToEdit.tournamentId) {
const tournament = await tournamentFromDB({
tournamentId: eventToEdit.tournamentId,
user,
});
validate(!tournament.hasStarted, "Tournament has already started", 400);
}
validate(
canEditCalendarEvent({ user, event: eventToEdit }),
"Not authorized",
401,
);
await CalendarRepository.update({
eventId: data.eventToEditId,
mapPoolMaps: deserializedMaps,
...commonArgs,
});
throw redirect(calendarEventPage(data.eventToEditId));
} else {
const mapPickingStyle = () => {
if (data.toToolsMode === "TO") return "TO" as const;
if (data.toToolsMode) return `AUTO_${data.toToolsMode}` as const;
return "AUTO_ALL" as const;
};
const createdEventId = await CalendarRepository.create({
authorId: user.id,
mapPoolMaps: deserializedMaps,
isFullTournament: data.toToolsEnabled,
mapPickingStyle: mapPickingStyle(),
...commonArgs,
});
throw redirect(calendarEventPage(createdEventId));
}
};
export const newCalendarEventActionSchema = z
.object({
eventToEditId: z.preprocess(actualNumber, id.nullish()),
tournamentToCopyId: z.preprocess(actualNumber, id.nullish()),
name: z
.string()
.min(CALENDAR_EVENT.NAME_MIN_LENGTH)
.max(CALENDAR_EVENT.NAME_MAX_LENGTH),
description: z.preprocess(
falsyToNull,
z.string().max(CALENDAR_EVENT.DESCRIPTION_MAX_LENGTH).nullable(),
),
rules: z.preprocess(
falsyToNull,
z.string().max(CALENDAR_EVENT.RULES_MAX_LENGTH).nullable(),
),
date: z.preprocess(
toArray,
z
.array(
z.preprocess(
date,
z.date().min(calendarEventMinDate()).max(calendarEventMaxDate()),
),
)
.min(1)
.max(CALENDAR_EVENT.MAX_AMOUNT_OF_DATES),
),
bracketUrl: z
.string()
.url()
.max(CALENDAR_EVENT.BRACKET_URL_MAX_LENGTH)
.default("https://sendou.ink"),
discordInviteCode: z.preprocess(
falsyToNull,
z.string().max(CALENDAR_EVENT.DISCORD_INVITE_CODE_MAX_LENGTH).nullable(),
),
tags: z.preprocess(
processMany(safeJSONParse, removeDuplicates),
z
.array(
z
.string()
.refine((val) =>
CALENDAR_EVENT.TAGS.includes(val as CalendarEventTag),
),
)
.nullable(),
),
badges: z.preprocess(
processMany(safeJSONParse, removeDuplicates),
z.array(id).nullable(),
),
pool: z.string().optional(),
toToolsEnabled: z.preprocess(checkboxValueToBoolean, z.boolean()),
toToolsMode: z.enum(["ALL", "TO", "SZ", "TC", "RM", "CB"]).optional(),
isRanked: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
regClosesAt: z.enum(REG_CLOSES_AT_OPTIONS).nullish(),
enableNoScreenToggle: z.preprocess(
checkboxValueToBoolean,
z.boolean().nullish(),
),
autonomousSubs: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
//
// tournament format related fields
//
format: z.enum(FORMATS_SHORT).nullish(),
withUndergroundBracket: z.preprocess(checkboxValueToBoolean, z.boolean()),
thirdPlaceMatch: z.preprocess(
checkboxValueToBoolean,
z.boolean().nullish(),
),
autoCheckInAll: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
teamsPerGroup: z.coerce
.number()
.min(TOURNAMENT.MIN_GROUP_SIZE)
.max(TOURNAMENT.MAX_GROUP_SIZE)
.nullish(),
swissGroupCount: z.coerce.number().int().positive().nullish(),
swissRoundCount: z.coerce.number().int().positive().nullish(),
followUpBrackets: z.preprocess(
safeJSONParse,
z
.array(
z.object({
name: z.string(),
placements: z.array(z.number()),
}),
)
.min(1)
.nullish(),
),
})
.refine(
async (schema) => {
if (schema.eventToEditId) {
const eventToEdit = await CalendarRepository.findById({
id: schema.eventToEditId,
});
return schema.date.length === 1 || !eventToEdit?.tournamentId;
} else {
return schema.date.length === 1 || !schema.toToolsEnabled;
}
},
{
message: "Tournament must have exactly one date",
},
);

View File

@@ -1,130 +0,0 @@
import { z } from "zod";
import { CALENDAR_EVENT } from "~/constants";
import type { CalendarEventTag } from "~/db/types";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import {
FORMATS_SHORT,
TOURNAMENT,
} from "~/features/tournament/tournament-constants";
import {
actualNumber,
checkboxValueToBoolean,
date,
falsyToNull,
id,
processMany,
removeDuplicates,
safeJSONParse,
toArray,
} from "~/utils/zod";
import { calendarEventMaxDate, calendarEventMinDate } from "./calendar-utils";
import { REG_CLOSES_AT_OPTIONS } from "./calendar-constants";
export const newCalendarEventActionSchema = z
.object({
eventToEditId: z.preprocess(actualNumber, id.nullish()),
tournamentToCopyId: z.preprocess(actualNumber, id.nullish()),
name: z
.string()
.min(CALENDAR_EVENT.NAME_MIN_LENGTH)
.max(CALENDAR_EVENT.NAME_MAX_LENGTH),
description: z.preprocess(
falsyToNull,
z.string().max(CALENDAR_EVENT.DESCRIPTION_MAX_LENGTH).nullable(),
),
rules: z.preprocess(
falsyToNull,
z.string().max(CALENDAR_EVENT.RULES_MAX_LENGTH).nullable(),
),
date: z.preprocess(
toArray,
z
.array(
z.preprocess(
date,
z.date().min(calendarEventMinDate()).max(calendarEventMaxDate()),
),
)
.min(1)
.max(CALENDAR_EVENT.MAX_AMOUNT_OF_DATES),
),
bracketUrl: z
.string()
.url()
.max(CALENDAR_EVENT.BRACKET_URL_MAX_LENGTH)
.default("https://sendou.ink"),
discordInviteCode: z.preprocess(
falsyToNull,
z.string().max(CALENDAR_EVENT.DISCORD_INVITE_CODE_MAX_LENGTH).nullable(),
),
tags: z.preprocess(
processMany(safeJSONParse, removeDuplicates),
z
.array(
z
.string()
.refine((val) =>
CALENDAR_EVENT.TAGS.includes(val as CalendarEventTag),
),
)
.nullable(),
),
badges: z.preprocess(
processMany(safeJSONParse, removeDuplicates),
z.array(id).nullable(),
),
pool: z.string().optional(),
toToolsEnabled: z.preprocess(checkboxValueToBoolean, z.boolean()),
toToolsMode: z.enum(["ALL", "TO", "SZ", "TC", "RM", "CB"]).optional(),
isRanked: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
regClosesAt: z.enum(REG_CLOSES_AT_OPTIONS).nullish(),
enableNoScreenToggle: z.preprocess(
checkboxValueToBoolean,
z.boolean().nullish(),
),
autonomousSubs: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
//
// tournament format related fields
//
format: z.enum(FORMATS_SHORT).nullish(),
withUndergroundBracket: z.preprocess(checkboxValueToBoolean, z.boolean()),
thirdPlaceMatch: z.preprocess(
checkboxValueToBoolean,
z.boolean().nullish(),
),
autoCheckInAll: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
teamsPerGroup: z.coerce
.number()
.min(TOURNAMENT.MIN_GROUP_SIZE)
.max(TOURNAMENT.MAX_GROUP_SIZE)
.nullish(),
swissGroupCount: z.coerce.number().int().positive().nullish(),
swissRoundCount: z.coerce.number().int().positive().nullish(),
followUpBrackets: z.preprocess(
safeJSONParse,
z
.array(
z.object({
name: z.string(),
placements: z.array(z.number()),
}),
)
.min(1)
.nullish(),
),
})
.refine(
async (schema) => {
if (schema.eventToEditId) {
const eventToEdit = await CalendarRepository.findById({
id: schema.eventToEditId,
});
return schema.date.length === 1 || !eventToEdit?.tournamentId;
} else {
return schema.date.length === 1 || !schema.toToolsEnabled;
}
},
{
message: "Tournament must have exactly one date",
},
);

View File

@@ -2,8 +2,8 @@ import type { z } from "zod";
import { isAdmin } from "~/permissions";
import type { TournamentSettings } from "~/db/tables";
import { BRACKET_NAMES } from "../tournament/tournament-constants";
import type { newCalendarEventActionSchema } from "./calendar-schemas.server";
import { validateFollowUpBrackets } from "./calendar-utils";
import type { newCalendarEventActionSchema } from "./actions/calendar.new.server";
const usersWithTournamentPerms =
process.env["TOURNAMENT_PERMS"]?.split(",").map(Number) ?? [];

View File

@@ -1,5 +1,4 @@
import type {
ActionFunction,
LoaderFunctionArgs,
MetaFunction,
SerializeFrom,
@@ -20,6 +19,7 @@ import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { MapPoolSelector } from "~/components/MapPoolSelector";
import { Placement } from "~/components/Placement";
import { RequiredHiddenInput } from "~/components/RequiredHiddenInput";
import { SubmitButton } from "~/components/SubmitButton";
import { Toggle } from "~/components/Toggle";
@@ -40,49 +40,39 @@ import {
import { useIsMounted } from "~/hooks/useIsMounted";
import { i18next } from "~/modules/i18n/i18next.server";
import type { RankedModeShort } from "~/modules/in-game-lists";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import { canEditCalendarEvent } from "~/permissions";
import { isDefined, nullFilledArray } from "~/utils/arrays";
import {
databaseTimestampToDate,
dateToDatabaseTimestamp,
getDateAtNextFullHour,
getDateWithHoursOffset,
} from "~/utils/dates";
import {
badRequestIfFalsy,
parseRequestFormData,
validate,
type SendouRouteHandle,
} from "~/utils/remix";
import { validate, type SendouRouteHandle } from "~/utils/remix";
import { makeTitle, pathnameFromPotentialURL } from "~/utils/strings";
import { calendarEventPage, tournamentBracketsPage } from "~/utils/urls";
import { newCalendarEventActionSchema } from "../calendar-schemas.server";
import { tournamentBracketsPage } from "~/utils/urls";
import {
REG_CLOSES_AT_OPTIONS,
type RegClosesAtOption,
} from "../calendar-constants";
import type { FollowUpBracket } from "../calendar-types";
import {
bracketProgressionToShortTournamentFormat,
calendarEventMaxDate,
calendarEventMinDate,
canAddNewEvent,
datesToRegClosesAt,
regClosesAtDate,
regClosesAtToDisplayName,
validateFollowUpBrackets,
} from "../calendar-utils";
import {
canCreateTournament,
formValuesToBracketProgression,
} from "../calendar-utils.server";
import { canCreateTournament } from "../calendar-utils.server";
import { Tags } from "../components/Tags";
import { Placement } from "~/components/Placement";
import type { FollowUpBracket } from "../calendar-types";
import {
REG_CLOSES_AT_OPTIONS,
type RegClosesAtOption,
} from "../calendar-constants";
import "~/styles/calendar-new.css";
import "~/styles/maps.css";
import { action } from "../actions/calendar.new.server";
export { action };
export const meta: MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader> | null;
@@ -91,110 +81,6 @@ export const meta: MetaFunction = (args) => {
return [{ title: data.title }];
};
export const action: ActionFunction = async ({ request }) => {
const user = await requireUser(request);
const data = await parseRequestFormData({
request,
schema: newCalendarEventActionSchema,
parseAsync: true,
});
validate(canAddNewEvent(user), "Not authorized", 401);
const startTimes = data.date.map((date) => dateToDatabaseTimestamp(date));
const commonArgs = {
name: data.name,
description: data.description,
rules: data.rules,
startTimes,
bracketUrl: data.bracketUrl,
discordInviteCode: data.discordInviteCode,
tags: data.tags
? data.tags
.sort(
(a, b) =>
CALENDAR_EVENT.TAGS.indexOf(a as CalendarEventTag) -
CALENDAR_EVENT.TAGS.indexOf(b as CalendarEventTag),
)
.join(",")
: data.tags,
badges: data.badges ?? [],
toToolsEnabled: canCreateTournament(user) ? Number(data.toToolsEnabled) : 0,
toToolsMode:
rankedModesShort.find((mode) => mode === data.toToolsMode) ?? null,
bracketProgression: formValuesToBracketProgression(data),
teamsPerGroup: data.teamsPerGroup ?? undefined,
thirdPlaceMatch: data.thirdPlaceMatch ?? undefined,
isRanked: data.isRanked ?? undefined,
enableNoScreenToggle: data.enableNoScreenToggle ?? undefined,
autoCheckInAll: data.autoCheckInAll ?? undefined,
autonomousSubs: data.autonomousSubs ?? undefined,
swissGroupCount: data.swissGroupCount ?? undefined,
swissRoundCount: data.swissRoundCount ?? undefined,
tournamentToCopyId: data.tournamentToCopyId,
regClosesAt: data.regClosesAt
? dateToDatabaseTimestamp(
regClosesAtDate({
startTime: databaseTimestampToDate(startTimes[0]),
closesAt: data.regClosesAt,
}),
)
: undefined,
};
validate(
!commonArgs.toToolsEnabled || commonArgs.bracketProgression,
"Bracket progression must be set for tournaments",
);
const deserializedMaps = (() => {
if (!data.pool) return;
return MapPool.toDbList(data.pool);
})();
if (data.eventToEditId) {
const eventToEdit = badRequestIfFalsy(
await CalendarRepository.findById({ id: data.eventToEditId }),
);
if (eventToEdit.tournamentId) {
const tournament = await tournamentFromDB({
tournamentId: eventToEdit.tournamentId,
user,
});
validate(!tournament.hasStarted, "Tournament has already started", 400);
}
validate(
canEditCalendarEvent({ user, event: eventToEdit }),
"Not authorized",
401,
);
await CalendarRepository.update({
eventId: data.eventToEditId,
mapPoolMaps: deserializedMaps,
...commonArgs,
});
throw redirect(calendarEventPage(data.eventToEditId));
} else {
const mapPickingStyle = () => {
if (data.toToolsMode === "TO") return "TO" as const;
if (data.toToolsMode) return `AUTO_${data.toToolsMode}` as const;
return "AUTO_ALL" as const;
};
const createdEventId = await CalendarRepository.create({
authorId: user.id,
mapPoolMaps: deserializedMaps,
isFullTournament: data.toToolsEnabled,
mapPickingStyle: mapPickingStyle(),
...commonArgs,
});
throw redirect(calendarEventPage(createdEventId));
}
};
export const handle: SendouRouteHandle = {
i18n: ["calendar", "game-misc"],
};