diff --git a/api/db/migrations/1770757438463-eventTemplates.ts b/api/db/migrations/1770757438463-eventTemplates.ts new file mode 100644 index 00000000..ee803815 --- /dev/null +++ b/api/db/migrations/1770757438463-eventTemplates.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class EventTemplates1770757438463 implements MigrationInterface { + name = 'EventTemplates1770757438463' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "event_starter_templates" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "basePath" character varying NOT NULL, "myCoreBotDockerImage" character varying NOT NULL, "eventId" uuid, CONSTRAINT "PK_71a1105f3641c6830dc2ca59178" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "teams" ADD "starterTemplateId" uuid`); + await queryRunner.query(`ALTER TABLE "event_starter_templates" ADD CONSTRAINT "FK_6c691c5b56253870cacd70e0413" FOREIGN KEY ("eventId") REFERENCES "events"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "teams" ADD CONSTRAINT "FK_1d9ef4aae433eba4679f8c7671d" FOREIGN KEY ("starterTemplateId") REFERENCES "event_starter_templates"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "teams" DROP CONSTRAINT "FK_1d9ef4aae433eba4679f8c7671d"`); + await queryRunner.query(`ALTER TABLE "event_starter_templates" DROP CONSTRAINT "FK_6c691c5b56253870cacd70e0413"`); + await queryRunner.query(`ALTER TABLE "teams" DROP COLUMN "starterTemplateId"`); + await queryRunner.query(`DROP TABLE "event_starter_templates"`); + } + +} diff --git a/api/src/event/dtos/createEventStarterTemplateDto.ts b/api/src/event/dtos/createEventStarterTemplateDto.ts new file mode 100644 index 00000000..57eb8987 --- /dev/null +++ b/api/src/event/dtos/createEventStarterTemplateDto.ts @@ -0,0 +1,15 @@ +import { IsString, IsNotEmpty } from "class-validator"; + +export class CreateEventStarterTemplateDto { + @IsString() + @IsNotEmpty() + name: string; + + @IsString() + @IsNotEmpty() + basePath: string; + + @IsString() + @IsNotEmpty() + myCoreBotDockerImage: string; +} diff --git a/api/src/event/dtos/updateEventStarterTemplateDto.ts b/api/src/event/dtos/updateEventStarterTemplateDto.ts new file mode 100644 index 00000000..968248f3 --- /dev/null +++ b/api/src/event/dtos/updateEventStarterTemplateDto.ts @@ -0,0 +1,15 @@ +import { IsString, IsNotEmpty, IsOptional } from "class-validator"; + +export class UpdateEventStarterTemplateDto { + @IsString() + @IsOptional() + name?: string; + + @IsString() + @IsOptional() + basePath?: string; + + @IsString() + @IsOptional() + myCoreBotDockerImage?: string; +} diff --git a/api/src/event/entities/event-starter-template.entity.ts b/api/src/event/entities/event-starter-template.entity.ts new file mode 100644 index 00000000..4ac37491 --- /dev/null +++ b/api/src/event/entities/event-starter-template.entity.ts @@ -0,0 +1,24 @@ +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; +import { EventEntity } from "./event.entity"; +import { Exclude } from "class-transformer"; + +@Entity("event_starter_templates") +export class EventStarterTemplateEntity { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column() + name: string; + + @Column() + basePath: string; + + @Column() + myCoreBotDockerImage: string; + + @Exclude() + @ManyToOne(() => EventEntity, (event) => event.starterTemplates, { + onDelete: "CASCADE", + }) + event: EventEntity; +} diff --git a/api/src/event/entities/event.entity.ts b/api/src/event/entities/event.entity.ts index cf34534f..971a8a27 100644 --- a/api/src/event/entities/event.entity.ts +++ b/api/src/event/entities/event.entity.ts @@ -14,6 +14,7 @@ import { } from "../../user/entities/user.entity"; import { TeamEntity } from "../../team/entities/team.entity"; import { Exclude } from "class-transformer"; +import { EventStarterTemplateEntity } from "./event-starter-template.entity"; @Entity("events") export class EventEntity { @@ -115,4 +116,9 @@ export class EventEntity { }, ) permissions: UserEventPermissionEntity[]; + + @OneToMany(() => EventStarterTemplateEntity, (template) => template.event, { + cascade: true, + }) + starterTemplates: EventStarterTemplateEntity[]; } diff --git a/api/src/event/event.controller.ts b/api/src/event/event.controller.ts index d1dec006..3cbf5ac8 100644 --- a/api/src/event/event.controller.ts +++ b/api/src/event/event.controller.ts @@ -17,6 +17,8 @@ import { UserService } from "../user/user.service"; import { CreateEventDto } from "./dtos/createEventDto"; import { SetLockTeamsDateDto } from "./dtos/setLockTeamsDateDto"; import { UpdateEventSettingsDto } from "./dtos/updateEventSettingsDto"; +import { CreateEventStarterTemplateDto } from "./dtos/createEventStarterTemplateDto"; +import { UpdateEventStarterTemplateDto } from "./dtos/updateEventStarterTemplateDto"; import { JwtAuthGuard } from "../auth/jwt-auth.guard"; import { UserId } from "../guards/UserGuard"; @@ -26,7 +28,7 @@ export class EventController { private readonly eventService: EventService, private readonly teamService: TeamService, private readonly userService: UserService, - ) { } + ) {} @UseGuards(JwtAuthGuard) @Get("my") @@ -49,6 +51,14 @@ export class EventController { return await this.eventService.getEventVersion(id); } + @Get(":id/templates/:templateId/version") + async getStarterTemplateVersion( + @Param("id", new ParseUUIDPipe()) id: string, + @Param("templateId", new ParseUUIDPipe()) templateId: string, + ) { + return await this.eventService.getTemplateVersion(id, templateId); + } + @Get(":id/game-config") async getEventGameConfig(@Param("id", new ParseUUIDPipe()) id: string) { return await this.eventService.getEventGameConfig(id); @@ -258,4 +268,55 @@ export class EventController { } return this.eventService.removeEventAdmin(eventId, adminIdToRemove); } + + @UseGuards(JwtAuthGuard) + @Get(":id/templates") + async getStarterTemplates(@Param("id", new ParseUUIDPipe()) eventId: string) { + return this.eventService.getStarterTemplates(eventId); + } + + @UseGuards(JwtAuthGuard) + @Post(":id/templates") + async createStarterTemplate( + @Param("id", new ParseUUIDPipe()) eventId: string, + @UserId() userId: string, + @Body() body: CreateEventStarterTemplateDto, + ) { + if (!(await this.eventService.isEventAdmin(eventId, userId))) { + throw new UnauthorizedException("You are not an admin of this event"); + } + return this.eventService.createStarterTemplate( + eventId, + body.name, + body.basePath, + body.myCoreBotDockerImage, + ); + } + + @UseGuards(JwtAuthGuard) + @Put(":id/templates/:templateId") + async updateStarterTemplate( + @Param("id", new ParseUUIDPipe()) eventId: string, + @Param("templateId", new ParseUUIDPipe()) templateId: string, + @UserId() userId: string, + @Body() body: UpdateEventStarterTemplateDto, + ) { + if (!(await this.eventService.isEventAdmin(eventId, userId))) { + throw new UnauthorizedException("You are not an admin of this event"); + } + return this.eventService.updateStarterTemplate(eventId, templateId, body); + } + + @UseGuards(JwtAuthGuard) + @Delete(":id/templates/:templateId") + async deleteStarterTemplate( + @Param("id", new ParseUUIDPipe()) eventId: string, + @Param("templateId", new ParseUUIDPipe()) templateId: string, + @UserId() userId: string, + ) { + if (!(await this.eventService.isEventAdmin(eventId, userId))) { + throw new UnauthorizedException("You are not an admin of this event"); + } + return this.eventService.deleteStarterTemplate(eventId, templateId); + } } diff --git a/api/src/event/event.module.ts b/api/src/event/event.module.ts index e27f80f5..799c1896 100644 --- a/api/src/event/event.module.ts +++ b/api/src/event/event.module.ts @@ -8,9 +8,15 @@ import { UserModule } from "../user/user.module"; import { UserEventPermissionEntity } from "../user/entities/user.entity"; import { CheckController } from "./check.controller"; +import { EventStarterTemplateEntity } from "./entities/event-starter-template.entity"; + @Module({ imports: [ - TypeOrmModule.forFeature([EventEntity, UserEventPermissionEntity]), + TypeOrmModule.forFeature([ + EventEntity, + UserEventPermissionEntity, + EventStarterTemplateEntity, + ]), UserModule, forwardRef(() => TeamModule), ], @@ -18,4 +24,4 @@ import { CheckController } from "./check.controller"; providers: [EventService], exports: [EventService], }) -export class EventModule { } +export class EventModule {} diff --git a/api/src/event/event.service.ts b/api/src/event/event.service.ts index 7c2fad7c..649550c3 100644 --- a/api/src/event/event.service.ts +++ b/api/src/event/event.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ConflictException, forwardRef, Inject, Injectable, @@ -8,6 +9,7 @@ import { } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { EventEntity } from "./entities/event.entity"; +import { EventStarterTemplateEntity } from "./entities/event-starter-template.entity"; import { DataSource, IsNull, @@ -35,6 +37,8 @@ export class EventService { private readonly eventRepository: Repository, @InjectRepository(UserEventPermissionEntity) private readonly permissionRepository: Repository, + @InjectRepository(EventStarterTemplateEntity) + private readonly templateRepository: Repository, private readonly configService: ConfigService, @Inject(forwardRef(() => TeamService)) private readonly teamService: TeamService, @@ -176,6 +180,48 @@ export class EventService { }; } + async getStarterTemplateVersions(id: string) { + const templates = await this.templateRepository.find({ + where: { event: { id } }, + select: { + name: true, + myCoreBotDockerImage: true, + }, + }); + + return templates.map((t) => ({ + name: t.name, + version: t.myCoreBotDockerImage, + })); + } + + async getTemplateVersion( + eventId: string, + templateId: string, + ): Promise { + const event = await this.eventRepository.findOneOrFail({ + where: { id: eventId }, + select: { + gameServerDockerImage: true, + visualizerDockerImage: true, + }, + }); + + const template = await this.templateRepository.findOneOrFail({ + where: { id: templateId, event: { id: eventId } }, + select: { + id: true, + myCoreBotDockerImage: true, + }, + }); + + return { + gameServerVersion: event.gameServerDockerImage, + myCoreBotVersion: template.myCoreBotDockerImage, + visualizerVersion: event.visualizerDockerImage, + }; + } + async getEventGameConfig(id: string): Promise { const event = await this.eventRepository.findOneOrFail({ where: { id }, @@ -261,6 +307,71 @@ export class EventService { }); } + async getStarterTemplates(eventId: string) { + return this.templateRepository.find({ + where: { event: { id: eventId } }, + }); + } + + isStarterTemplateInEvent( + templateId: string, + eventId: string, + ): Promise { + return this.templateRepository.existsBy({ + id: templateId, + event: { id: eventId }, + }); + } + + async createStarterTemplate( + eventId: string, + name: string, + basePath: string, + myCoreBotDockerImage: string, + ) { + const event = await this.getEventById(eventId); + const template = this.templateRepository.create({ + name, + basePath, + myCoreBotDockerImage, + event, + }); + return this.templateRepository.save(template); + } + + async updateStarterTemplate( + eventId: string, + templateId: string, + data: { name?: string; basePath?: string; myCoreBotDockerImage?: string }, + ) { + const template = await this.templateRepository.findOneOrFail({ + where: { id: templateId, event: { id: eventId } }, + }); + + if (data.name) template.name = data.name; + if (data.basePath) template.basePath = data.basePath; + if (data.myCoreBotDockerImage) + template.myCoreBotDockerImage = data.myCoreBotDockerImage; + + return this.templateRepository.save(template); + } + + async deleteStarterTemplate(eventId: string, templateId: string) { + try { + await this.templateRepository.delete({ + id: templateId, + event: { id: eventId }, + }); + } catch (e: any) { + if (e.code === "23503") { + throw new ConflictException( + "Cannot delete template because it is still in use by one or more teams.", + ); + } + throw e; + } + } + increaseEventRound(eventId: string): Promise { return this.eventRepository.increment({ id: eventId }, "currentRound", 1); } diff --git a/api/src/github-api/github-api.service.ts b/api/src/github-api/github-api.service.ts index f9f68343..a55f0d73 100644 --- a/api/src/github-api/github-api.service.ts +++ b/api/src/github-api/github-api.service.ts @@ -118,6 +118,7 @@ export class GithubApiService { basePath: string, gameConfig: string, serverConfig: string, + starterTemplateId?: string, ) { this.githubClient.emit("create_team_repository", { name, @@ -134,6 +135,7 @@ export class GithubApiService { basePath, gameConfig, serverConfig, + starterTemplateId, apiBaseUrl: this.configService.getOrThrow("API_BASE_URL"), }); } diff --git a/api/src/match/match.service.ts b/api/src/match/match.service.ts index 9a3ceab2..d19f1daf 100644 --- a/api/src/match/match.service.ts +++ b/api/src/match/match.service.ts @@ -304,6 +304,7 @@ export class MatchService { relations: { teams: { event: true, + starterTemplate: true, }, winner: true, }, @@ -371,13 +372,17 @@ export class MatchService { bots: [ { id: match.teams[0].id, - image: event.myCoreBotDockerImage, + image: + match.teams[0].starterTemplate?.myCoreBotDockerImage ?? + event.myCoreBotDockerImage, repoURL: repoPrefix + match.teams[0].repo, name: match.teams[0].name, }, { id: match.teams[1].id, - image: event.myCoreBotDockerImage, + image: + match.teams[1].starterTemplate?.myCoreBotDockerImage ?? + event.myCoreBotDockerImage, repoURL: repoPrefix + match.teams[1].repo, name: match.teams[1].name, }, diff --git a/api/src/team/dtos/createTeamDto.ts b/api/src/team/dtos/createTeamDto.ts index 344585b7..91695b4f 100644 --- a/api/src/team/dtos/createTeamDto.ts +++ b/api/src/team/dtos/createTeamDto.ts @@ -1,5 +1,11 @@ -import { IsNotEmpty, IsString, Matches } from "class-validator"; -import { ApiProperty } from "@nestjs/swagger"; +import { + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + Matches, +} from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; export class CreateTeamDto { @ApiProperty() @@ -7,7 +13,12 @@ export class CreateTeamDto { @IsNotEmpty() @Matches(/^[A-Za-z0-9_.-]{4,30}$/, { message: - "Name can only contain letters, numbers, underscores, dots, and hyphens. Must be between 5 and 30 characters.", + "Name can only contain letters, numbers, underscores, dots, and hyphens. Must be between 4 and 30 characters.", }) name: string; + + @ApiPropertyOptional() + @IsUUID() + @IsOptional() + starterTemplateId?: string; } diff --git a/api/src/team/entities/team.entity.ts b/api/src/team/entities/team.entity.ts index ddfc8a5c..9547e4bc 100644 --- a/api/src/team/entities/team.entity.ts +++ b/api/src/team/entities/team.entity.ts @@ -12,6 +12,8 @@ import { import { EventEntity } from "../../event/entities/event.entity"; import { UserEntity } from "../../user/entities/user.entity"; import { MatchEntity } from "../../match/entites/match.entity"; +import { EventStarterTemplateEntity } from "../../event/entities/event-starter-template.entity"; +import { Exclude } from "class-transformer"; @Entity("teams") export class TeamEntity { @@ -27,7 +29,7 @@ export class TeamEntity { @Column({ nullable: true }) repo: string; - @Column({nullable: true, type: "timestamp" }) + @Column({ nullable: true, type: "timestamp" }) startedRepoCreationAt: Date | null; @Column({ default: 0 }) @@ -42,9 +44,13 @@ export class TeamEntity { @Column({ default: false }) inQueue: boolean; + @Exclude() @ManyToOne(() => EventEntity, (event) => event.teams) event: EventEntity; + @ManyToOne(() => EventStarterTemplateEntity, { nullable: true }) + starterTemplate: EventStarterTemplateEntity; + @JoinTable({ name: "teams_users" }) @ManyToMany(() => UserEntity, (user) => user.teams) users: UserEntity[]; diff --git a/api/src/team/team.controller.ts b/api/src/team/team.controller.ts index ff7ef50f..98fac731 100644 --- a/api/src/team/team.controller.ts +++ b/api/src/team/team.controller.ts @@ -35,7 +35,7 @@ export class TeamController { private readonly teamService: TeamService, private readonly userService: UserService, private readonly eventService: EventService, - ) { } + ) {} @Get(":id") getTeamById(@Param("id", new ParseUUIDPipe()) id: string) { @@ -84,7 +84,12 @@ export class TeamController { "A team with this name already exists for this event.", ); - return this.teamService.createTeam(createTeamDto.name, userId, eventId); + return this.teamService.createTeam( + createTeamDto.name, + userId, + eventId, + createTeamDto.starterTemplateId, + ); } @UseGuards(JwtAuthGuard, MyTeamGuards, TeamNotLockedGuard) @@ -134,15 +139,13 @@ export class TeamController { @EventId eventId: string, @Body() inviteUserDto: InviteUserDto, @Team() team: TeamEntity, - @UserId() userId: string + @UserId() userId: string, ) { - if(userId === inviteUserDto.userToInviteId) - throw new BadRequestException( - "You cannot invite yourself to a team.", - ) + if (userId === inviteUserDto.userToInviteId) + throw new BadRequestException("You cannot invite yourself to a team."); - if(await this.teamService.isTeamFull(team.id)) - throw new BadRequestException("This team is full."); + if (await this.teamService.isTeamFull(team.id)) + throw new BadRequestException("This team is full."); if ( await this.teamService.getTeamOfUserForEvent( eventId, @@ -175,9 +178,14 @@ export class TeamController { @EventId eventId: string, @Param("searchQuery") searchQuery: string, @Team() team: TeamEntity, - @UserId() userId: string + @UserId() userId: string, ) { - return this.userService.searchUsersForInvite(eventId, searchQuery, team.id, userId); + return this.userService.searchUsersForInvite( + eventId, + searchQuery, + team.id, + userId, + ); } @UseGuards(JwtAuthGuard) @@ -203,8 +211,8 @@ export class TeamController { if (!(await this.teamService.isUserInvitedToTeam(userId, teamId))) throw new BadRequestException("You are not invited to this team."); - if(await this.teamService.isTeamFull(teamId)) - throw new BadRequestException("This team is full."); + if (await this.teamService.isTeamFull(teamId)) + throw new BadRequestException("This team is full."); return this.teamService.acceptTeamInvite(userId, teamId); } diff --git a/api/src/team/team.service.ts b/api/src/team/team.service.ts index 4e3b7366..ec7fc610 100644 --- a/api/src/team/team.service.ts +++ b/api/src/team/team.service.ts @@ -1,4 +1,10 @@ -import { forwardRef, Inject, Injectable, Logger } from "@nestjs/common"; +import { + BadRequestException, + forwardRef, + Inject, + Injectable, + Logger, +} from "@nestjs/common"; import { InjectDataSource, InjectRepository } from "@nestjs/typeorm"; import { TeamEntity } from "./entities/team.entity"; import { @@ -29,7 +35,7 @@ export class TeamService { private readonly matchService: MatchService, @InjectDataSource() private readonly dataSource: DataSource, - ) { } + ) {} logger = new Logger("TeamService"); @@ -149,6 +155,7 @@ export class TeamService { relations: { users: true, event: true, + starterTemplate: true, }, }); @@ -161,6 +168,14 @@ export class TeamService { const repoName = team.event.name + "-" + team.name + "-" + team.id; + // Determine values to use: template or event default + const basePath = team.starterTemplate + ? team.starterTemplate.basePath + : team.event.basePath; + const myCoreBotDockerImage = team.starterTemplate + ? team.starterTemplate.myCoreBotDockerImage + : team.event.myCoreBotDockerImage; + await this.githubApiService.createTeamRepository( repoName, team.name, @@ -174,12 +189,13 @@ export class TeamService { team.id, team.event.monorepoUrl, team.event.monorepoVersion, - team.event.myCoreBotDockerImage, + myCoreBotDockerImage, team.event.visualizerDockerImage, team.event.id, - team.event.basePath, + basePath, team.event.gameConfig ?? "", team.event.serverConfig ?? "", + team.starterTemplate?.id, ); await teamRepository.update(teamId, { @@ -187,14 +203,34 @@ export class TeamService { }); } - async createTeam(name: string, userId: string, eventId: string) { + async createTeam( + name: string, + userId: string, + eventId: string, + starterTemplateId?: string, + ) { return await this.dataSource.transaction(async (entityManager) => { + if ( + starterTemplateId && + !(await this.eventService.isStarterTemplateInEvent( + starterTemplateId, + eventId, + )) + ) { + throw new BadRequestException( + "Starter template does not belong to this event.", + ); + } + const teamRepository = entityManager.getRepository(TeamEntity); const newTeam = await teamRepository.save({ name, event: { id: eventId }, users: [{ id: userId }], + starterTemplate: starterTemplateId + ? { id: starterTemplateId } + : undefined, }); if (await this.eventService.hasEventStarted(eventId)) @@ -491,22 +527,22 @@ export class TeamService { }); } - async isTeamFull(teamId: string) { - const team = await this.teamRepository.findOne({ - where: { - id: teamId, - }, - relations: { - event: true, - users: true - } - }); - const maxUsers = team?.event.maxTeamSize; - if (!maxUsers) return true; - if (!team?.users) return true; - - return team?.users.length >= maxUsers; - } + async isTeamFull(teamId: string) { + const team = await this.teamRepository.findOne({ + where: { + id: teamId, + }, + relations: { + event: true, + users: true, + }, + }); + const maxUsers = team?.event.maxTeamSize; + if (!maxUsers) return true; + if (!team?.users) return true; + + return team?.users.length >= maxUsers; + } async leaveQueue(teamId: string) { return this.teamRepository.update(teamId, { inQueue: false }); @@ -536,12 +572,10 @@ export class TeamService { }); for (const team of teams) { - if (!team.repo || !team.event) - continue; + if (!team.repo || !team.event) continue; for (const user of team.users) { - if (!user.username) - continue; + if (!user.username) continue; await this.githubApiService.addWritePermissions( user.username, diff --git a/api/src/user/entities/user.entity.ts b/api/src/user/entities/user.entity.ts index 0458bbad..fed200f3 100644 --- a/api/src/user/entities/user.entity.ts +++ b/api/src/user/entities/user.entity.ts @@ -43,12 +43,15 @@ export class UserEntity { @UpdateDateColumn() updatedAt: Date; + @Exclude() @ManyToMany(() => TeamEntity, (team) => team.users) teams: TeamEntity[]; + @Exclude() @ManyToMany(() => TeamEntity, (team) => team.teamInvites) teamInvites: TeamEntity[]; + @Exclude() @ManyToMany(() => EventEntity, (event) => event.users) events: EventEntity[]; @@ -85,6 +88,7 @@ export class UserEventPermissionEntity { }) user: UserEntity; + @Exclude() @ManyToOne(() => EventEntity, (event: EventEntity) => event.permissions, { onDelete: "CASCADE", }) diff --git a/frontend/app/actions/event.ts b/frontend/app/actions/event.ts index f6229cf8..f0853b80 100644 --- a/frontend/app/actions/event.ts +++ b/frontend/app/actions/event.ts @@ -31,6 +31,14 @@ export interface Event { serverConfig?: string; isPrivate: boolean; githubOrgSecret?: string; + starterTemplates?: EventStarterTemplate[]; +} + +export interface EventStarterTemplate { + id: string; + name: string; + basePath: string; + myCoreBotDockerImage: string; } export async function getEventById( @@ -121,8 +129,7 @@ export async function createEvent( export async function canUserCreateEvent(): Promise { try { return (await axiosInstance.get("user/canCreateEvent")).data; - } - catch { + } catch { return false; } } @@ -170,7 +177,9 @@ export async function updateEventSettings( export async function getEventAdmins( eventId: string, -): Promise> { +): Promise< + ServerActionResponse<{ id: string; username: string; name: string }[]> +> { return await handleError(axiosInstance.get(`event/${eventId}/admins`)); } @@ -178,21 +187,58 @@ export async function addEventAdmin( eventId: string, userId: string, ): Promise> { - return await handleError(axiosInstance.post(`event/${eventId}/admins/${userId}`)); + return await handleError( + axiosInstance.post(`event/${eventId}/admins/${userId}`), + ); } export async function removeEventAdmin( eventId: string, userId: string, ): Promise> { - return await handleError(axiosInstance.delete(`event/${eventId}/admins/${userId}`)); + return await handleError( + axiosInstance.delete(`event/${eventId}/admins/${userId}`), + ); } export async function getMyEvents(): Promise { try { return (await axiosInstance.get("event/my")).data as Event[]; - } - catch { + } catch { return []; } } + +export async function getStarterTemplates( + eventId: string, +): Promise> { + return await handleError(axiosInstance.get(`event/${eventId}/templates`)); +} + +export async function createStarterTemplate( + eventId: string, + data: { name: string; basePath: string; myCoreBotDockerImage: string }, +): Promise> { + return await handleError( + axiosInstance.post(`event/${eventId}/templates`, data), + ); +} + +export async function updateStarterTemplate( + eventId: string, + templateId: string, + data: { name?: string; basePath?: string; myCoreBotDockerImage?: string }, +): Promise> { + return await handleError( + axiosInstance.put(`event/${eventId}/templates/${templateId}`, data), + ); +} + +export async function deleteStarterTemplate( + eventId: string, + templateId: string, +): Promise> { + return await handleError( + axiosInstance.delete(`event/${eventId}/templates/${templateId}`), + ); +} diff --git a/frontend/app/events/EventTable.tsx b/frontend/app/events/EventTable.tsx index 8c08170e..ea018df7 100644 --- a/frontend/app/events/EventTable.tsx +++ b/frontend/app/events/EventTable.tsx @@ -22,7 +22,6 @@ export default function EventsTable({ events }: Readonly<{ events: Event[] }>) { text: string; variant: "default" | "secondary" | "destructive"; } => { - console.log("try to format state for event", event); const hasStarted = Date.now() >= new Date(event.startDate).getTime(); if (!hasStarted) { diff --git a/frontend/app/events/[id]/dashboard/components/StarterTemplatesManagement.tsx b/frontend/app/events/[id]/dashboard/components/StarterTemplatesManagement.tsx new file mode 100644 index 00000000..253b4c12 --- /dev/null +++ b/frontend/app/events/[id]/dashboard/components/StarterTemplatesManagement.tsx @@ -0,0 +1,415 @@ +"use client"; + +import type { EventStarterTemplate } from "@/app/actions/event"; +import { useForm } from "@tanstack/react-form"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Check, Loader2, Pencil, Trash2, X } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import * as z from "zod"; +import { isActionError } from "@/app/actions/errors"; +import { + createStarterTemplate, + deleteStarterTemplate, + getStarterTemplates, + updateStarterTemplate, +} from "@/app/actions/event"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +const formSchema = z.object({ + name: z.string().min(1, "Name is required"), + basePath: z.string().min(1, "Base path is required"), + myCoreBotDockerImage: z.string().min(1, "Bot docker image is required"), +}); + +interface StarterTemplatesManagementProps { + eventId: string; +} + +export function StarterTemplatesManagement({ + eventId, +}: StarterTemplatesManagementProps) { + const queryClient = useQueryClient(); + const [editingTemplate, setEditingTemplate] = + useState(null); + + const { data: templates = [], isLoading } = useQuery({ + queryKey: ["event", eventId, "templates"], + queryFn: async () => { + const result = await getStarterTemplates(eventId); + if (isActionError(result)) throw new Error(result.error); + return result; + }, + }); + + const createMutation = useMutation({ + mutationFn: async (data: { + name: string; + basePath: string; + myCoreBotDockerImage: string; + }) => { + const result = await createStarterTemplate(eventId, data); + if (isActionError(result)) throw new Error(result.error); + return result; + }, + onSuccess: () => { + toast.success("Template created"); + queryClient.invalidateQueries({ + queryKey: ["event", eventId, "templates"], + }); + }, + onError: (e: any) => toast.error(e.message), + }); + + const form = useForm({ + defaultValues: { + name: "", + basePath: "", + myCoreBotDockerImage: "", + }, + validators: { + onChange: formSchema, + }, + onSubmit: async ({ value }) => { + await createMutation.mutateAsync(value); + form.reset(); + }, + }); + + const updateMutation = useMutation({ + mutationFn: async (data: { + id: string; + name: string; + basePath: string; + myCoreBotDockerImage: string; + }) => { + const result = await updateStarterTemplate(eventId, data.id, { + name: data.name, + basePath: data.basePath, + myCoreBotDockerImage: data.myCoreBotDockerImage, + }); + if (isActionError(result)) throw new Error(result.error); + return result; + }, + onSuccess: () => { + toast.success("Template updated"); + setEditingTemplate(null); + queryClient.invalidateQueries({ + queryKey: ["event", eventId, "templates"], + }); + }, + onError: (e: any) => toast.error(e.message), + }); + + const deleteMutation = useMutation({ + mutationFn: async (id: string) => { + const result = await deleteStarterTemplate(eventId, id); + if (isActionError(result)) throw new Error(result.error); + return result; + }, + onSuccess: () => { + toast.success("Template deleted"); + queryClient.invalidateQueries({ + queryKey: ["event", eventId, "templates"], + }); + }, + onError: (e: any) => toast.error(e.message), + }); + + return ( + + +
+
+ Starter Templates + + Manage starter templates for teams. + +
+
+
+ + {isLoading ? ( +
+ +
+ ) : ( + + + + ID + Name + Base Path + Bot Image + Actions + + + + {templates.length === 0 ? ( + + + No templates found. Enter details below to create your first + template. + + + ) : ( + templates.map((template) => ( + + {editingTemplate?.id === template.id ? ( + <> + + {template.id} + + + + setEditingTemplate({ + ...editingTemplate, + name: e.target.value, + }) + } + className="h-8" + /> + + + + setEditingTemplate({ + ...editingTemplate, + basePath: e.target.value, + }) + } + className="h-8" + /> + + + + setEditingTemplate({ + ...editingTemplate, + myCoreBotDockerImage: e.target.value, + }) + } + className="h-8" + /> + + +
+ + +
+
+ + ) : ( + <> + + {template.id} + + {template.name} + {template.basePath} + + {template.myCoreBotDockerImage} + + +
+ + +
+
+ + )} +
+ )) + )} + + + + + ( +
+ field.handleChange(e.target.value)} + placeholder="New Template Name..." + className={`h-8 bg-background ${ + field.state.meta.errors.length > 0 + ? "border-destructive focus-visible:ring-destructive" + : "" + }`} + /> + {field.state.meta.errors.length > 0 && ( +

+ {field.state.meta.errors + .map((err: any) => + typeof err === "object" && err?.message + ? err.message + : String(err), + ) + .join(", ")} +

+ )} +
+ )} + /> +
+ + ( +
+ field.handleChange(e.target.value)} + placeholder="bots/c/softcore" + className={`h-8 bg-background ${ + field.state.meta.errors.length > 0 + ? "border-destructive focus-visible:ring-destructive" + : "" + }`} + /> + {field.state.meta.errors.length > 0 && ( +

+ {field.state.meta.errors + .map((err: any) => + typeof err === "object" && err?.message + ? err.message + : String(err), + ) + .join(", ")} +

+ )} +
+ )} + /> +
+ + ( +
+ field.handleChange(e.target.value)} + placeholder="ghcr.io/42core-team/my-core-bot:dev" + className={`h-8 bg-background ${ + field.state.meta.errors.length > 0 + ? "border-destructive focus-visible:ring-destructive" + : "" + }`} + /> + {field.state.meta.errors.length > 0 && ( +

+ {field.state.meta.errors + .map((err: any) => + typeof err === "object" && err?.message + ? err.message + : String(err), + ) + .join(", ")} +

+ )} +
+ )} + /> +
+ + [state.canSubmit, state.isSubmitting]} + children={([canSubmit, isSubmitting]) => ( + + )} + /> + +
+
+
+ )} +
+
+ ); +} diff --git a/frontend/app/events/[id]/dashboard/dashboard.tsx b/frontend/app/events/[id]/dashboard/dashboard.tsx index 8a9af351..63359a23 100644 --- a/frontend/app/events/[id]/dashboard/dashboard.tsx +++ b/frontend/app/events/[id]/dashboard/dashboard.tsx @@ -9,6 +9,7 @@ import { getEventAdmins, getEventById, getParticipantsCountForEvent, + getStarterTemplates, getTeamsCountForEvent, isEventAdmin, removeEventAdmin, @@ -47,16 +48,25 @@ import { Search, Trash2, UserPlus, - Wand2, } from "lucide-react"; import { searchUsers, type UserSearchResult } from "@/app/actions/user"; import { Calendar } from "@/components/ui/calendar"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { toast } from "sonner"; import { Switch } from "@/components/ui/switch"; +import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; +import { StarterTemplatesManagement } from "./components/StarterTemplatesManagement"; interface DashboardPageProps { eventId: string; @@ -65,6 +75,16 @@ interface DashboardPageProps { export function DashboardPage({ eventId }: DashboardPageProps) { const session = useSession(); const queryClient = useQueryClient(); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const currentTab = searchParams.get("tab") || "overview"; + + const handleTabChange = (value: string) => { + const params = new URLSearchParams(searchParams.toString()); + params.set("tab", value); + router.push(`${pathname}?${params.toString()}`, { scroll: false }); + }; const [teamAutoLockTime, setTeamAutoLockTime] = useState(""); const [userSearchQuery, setUserSearchQuery] = useState(""); @@ -127,6 +147,16 @@ export function DashboardPage({ eventId }: DashboardPageProps) { enabled: isAdmin, }); + const { data: starterTemplates = [] } = useQuery({ + queryKey: ["event", eventId, "templates"], + queryFn: async () => { + const result = await getStarterTemplates(eventId); + if (isActionError(result)) throw new Error(result.error); + return result; + }, + enabled: !!eventId, + }); + const lockEventMutation = useMutation({ mutationFn: async () => await lockEvent(eventId), onSuccess: async () => { @@ -273,53 +303,11 @@ export function DashboardPage({ eventId }: DashboardPageProps) { return () => clearTimeout(delayDebounceFn); }, [userSearchQuery]); - const formatConfig = (field: "gameConfig" | "serverConfig") => { - try { - const current = pendingSettings[field]; - if (!current) return; - const parsed = JSON.parse(current); - const formatted = JSON.stringify(parsed, null, 2); - setPendingSettings({ - ...pendingSettings, - [field]: formatted, - }); - toast.success(`${field} formatted`); - } catch (e) { - toast.error(`Invalid JSON in ${field}`); - } - }; - const handleSaveSettings = () => { const updates: any = {}; - // Auto-format JSON fields before saving - let gameConfig = pendingSettings.gameConfig; - let serverConfig = pendingSettings.serverConfig; - - if (gameConfig) { - try { - gameConfig = JSON.stringify(JSON.parse(gameConfig), null, 2); - } catch (e) { - toast.warning( - "Invalid JSON in Game Config. Proceeding without formatting.", - ); - } - } - - if (serverConfig) { - try { - serverConfig = JSON.stringify(JSON.parse(serverConfig), null, 2); - } catch (e) { - toast.warning( - "Invalid JSON in Server Config. Proceeding without formatting.", - ); - } - } - const finalSettings = { ...pendingSettings, - gameConfig, - serverConfig, }; const fields = [ @@ -340,6 +328,7 @@ export function DashboardPage({ eventId }: DashboardPageProps) { "gameConfig", "serverConfig", "githubOrg", + "githubOrgSecret", "startDate", "endDate", ]; @@ -406,7 +395,11 @@ export function DashboardPage({ eventId }: DashboardPageProps) { )} - + Overview Operation @@ -526,7 +519,7 @@ export function DashboardPage({ eventId }: DashboardPageProps) {

{event.myCoreBotDockerImage} @@ -541,6 +534,42 @@ export function DashboardPage({ eventId }: DashboardPageProps) {

+ + {starterTemplates && starterTemplates.length > 0 && ( +
+ + + + + ID + Name + Path + Docker Image + + + + {starterTemplates.map((template) => ( + + + {template.id} + + + {template.name} + + + {template.basePath} + + + {template.myCoreBotDockerImage} + + + ))} + +
+
+ )} @@ -717,10 +746,8 @@ export function DashboardPage({ eventId }: DashboardPageProps) { - Event Settings - - Configure the core details of your event. - + General Information + Basic details about the event.
@@ -805,8 +832,18 @@ export function DashboardPage({ eventId }: DashboardPageProps) { />
+
+
-
+ + + Participation & Privacy + + Manage who can join and strictness of the event. + + + +
-

- Visibility & Permissions -

+

Toggles

+ + -
-

- Technical / Docker Images -

-
-
- - - setPendingSettings({ - ...pendingSettings, - monorepoUrl: e.target.value, - }) - } - /> -
-
-
- - - setPendingSettings({ - ...pendingSettings, - monorepoVersion: e.target.value, - }) - } - /> -
-
- - - setPendingSettings({ - ...pendingSettings, - basePath: e.target.value, - }) - } - /> -
-
-
- - - setPendingSettings({ - ...pendingSettings, - githubOrg: e.target.value, - }) - } - /> -
-
- - - setPendingSettings({ - ...pendingSettings, - gameServerDockerImage: e.target.value, - }) - } - /> -
+ + + Technical Configuration + + Docker images and repository settings. + + + +
+
+ + + setPendingSettings({ + ...pendingSettings, + monorepoUrl: e.target.value, + }) + } + /> +
+
- + setPendingSettings({ ...pendingSettings, - myCoreBotDockerImage: e.target.value, + monorepoVersion: e.target.value, }) } />
- + setPendingSettings({ ...pendingSettings, - visualizerDockerImage: e.target.value, + basePath: e.target.value, }) } />
-
-
-

- Secret Configurations -

- + setPendingSettings({ ...pendingSettings, - githubOrgSecret: e.target.value, + gameServerDockerImage: e.target.value, }) } />
-
- -
- - -
-
-