diff --git a/.agents/skills/conventional-commit/SKILL.md b/.agents/skills/conventional-commit/SKILL.md
new file mode 100644
index 0000000..3884217
--- /dev/null
+++ b/.agents/skills/conventional-commit/SKILL.md
@@ -0,0 +1,72 @@
+---
+name: conventional-commit
+description: 'Prompt and workflow for generating conventional commit messages using a structured XML format. Guides users to create standardized, descriptive commit messages in line with the Conventional Commits specification, including instructions, examples, and validation.'
+---
+
+### Instructions
+
+```xml
+ This file contains a prompt template for generating conventional commit messages. It provides instructions, examples, and formatting guidelines to help users write standardized, descriptive commit messages in accordance with the Conventional Commits specification.
+```
+
+### Workflow
+
+**Follow these steps:**
+
+1. Run `git status` to review changed files.
+2. Run `git diff` or `git diff --cached` to inspect changes.
+3. Stage your changes with `git add `.
+4. Construct your commit message using the following XML structure.
+5. After generating your commit message, Copilot will automatically run the following command in your integrated terminal (no confirmation needed):
+
+```bash
+git commit -m "type(scope): description"
+```
+
+6. Just execute this prompt and Copilot will handle the commit for you in the terminal.
+
+### Commit Message Structure
+
+```xml
+
+ feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert
+ ()
+ A short, imperative summary of the change
+ (optional: more detailed explanation)
+
+
+```
+
+### Examples
+
+```xml
+
+ feat(parser): add ability to parse arrays
+ fix(ui): correct button alignment
+ docs: update README with usage instructions
+ refactor: improve performance of data processing
+ chore: update dependencies
+ feat!: send email on registration (BREAKING CHANGE: email service required)
+
+```
+
+### Validation
+
+```xml
+
+ Must be one of the allowed types. See https://www.conventionalcommits.org/en/v1.0.0/#specification
+ Optional, but recommended for clarity.
+ Required. Use the imperative mood (e.g., "add", not "added").
+ Optional. Use for additional context.
+
+
+```
+
+### Final Step
+
+```xml
+
+ git commit -m "type(scope): description"
+ Replace with your constructed message. Include body and footer if needed.
+
+```
diff --git a/.claude/skills/conventional-commit b/.claude/skills/conventional-commit
new file mode 120000
index 0000000..6479df2
--- /dev/null
+++ b/.claude/skills/conventional-commit
@@ -0,0 +1 @@
+../../.agents/skills/conventional-commit
\ No newline at end of file
diff --git a/skills-lock.json b/skills-lock.json
new file mode 100644
index 0000000..2aca4c4
--- /dev/null
+++ b/skills-lock.json
@@ -0,0 +1,10 @@
+{
+ "version": 1,
+ "skills": {
+ "conventional-commit": {
+ "source": "github/awesome-copilot",
+ "sourceType": "github",
+ "computedHash": "f29c9486cede6c7b2df0cfb0a2e4aa67446552b991bcf17d1b309e903171f03d"
+ }
+ }
+}
diff --git a/src/modules/event/dto/match/index.ts b/src/modules/event/dto/match/index.ts
index 798e27f..985236d 100644
--- a/src/modules/event/dto/match/index.ts
+++ b/src/modules/event/dto/match/index.ts
@@ -1,3 +1,4 @@
export * from './create-match.dto';
export * from './update-match.dto';
export * from './update-score.dto';
+export * from './upsert-match-score.dto';
diff --git a/src/modules/event/dto/match/upsert-match-score.dto.ts b/src/modules/event/dto/match/upsert-match-score.dto.ts
new file mode 100644
index 0000000..4673842
--- /dev/null
+++ b/src/modules/event/dto/match/upsert-match-score.dto.ts
@@ -0,0 +1,29 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { IsNumber, IsNotEmpty, IsOptional, IsObject, Min } from 'class-validator';
+
+export class UpsertMatchScoreDto {
+ @ApiProperty({ description: 'Team 1 points in this set', example: 21 })
+ @IsNumber()
+ @IsNotEmpty()
+ @Min(0)
+ team1Points: number;
+
+ @ApiProperty({ description: 'Team 2 points in this set', example: 19 })
+ @IsNumber()
+ @IsNotEmpty()
+ @Min(0)
+ team2Points: number;
+
+ @ApiPropertyOptional({ description: 'Winner team for this set (1 or 2)', example: 1 })
+ @IsNumber()
+ @IsOptional()
+ winnerTeam?: number;
+
+ @ApiPropertyOptional({
+ description: 'Point-by-point details',
+ example: { points: [{ team: 1, score: [1, 0] }] },
+ })
+ @IsObject()
+ @IsOptional()
+ details?: Record;
+}
diff --git a/src/modules/event/event.module.ts b/src/modules/event/event.module.ts
index 9d5d194..22ae441 100644
--- a/src/modules/event/event.module.ts
+++ b/src/modules/event/event.module.ts
@@ -17,6 +17,7 @@ import { Stage } from './entities/stage.entity';
import { EventController } from './event.controller';
import { PublicEventController } from './public-event.controller';
import { MatchController } from './match.controller';
+import { MatchScoreController } from './match-score.controller';
import { ParticipantController } from './participant.controller';
import { StageController } from './stage.controller';
@@ -51,6 +52,7 @@ import { FlexStrategy } from './strategies/flex.strategy';
EventController,
PublicEventController,
MatchController,
+ MatchScoreController,
ParticipantController,
StageController,
],
diff --git a/src/modules/event/match-score.controller.ts b/src/modules/event/match-score.controller.ts
new file mode 100644
index 0000000..43f130e
--- /dev/null
+++ b/src/modules/event/match-score.controller.ts
@@ -0,0 +1,46 @@
+import { Body, Controller, Get, Param, ParseIntPipe, ParseUUIDPipe, Put } from '@nestjs/common';
+import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
+import { MatchService } from './match.service';
+import { UpsertMatchScoreDto } from './dto/match';
+import { MatchScore } from './entities';
+
+@ApiTags('match-scores')
+@Controller('matches/:matchId/scores')
+export class MatchScoreController {
+ constructor(private readonly matchService: MatchService) {}
+
+ @Get()
+ @ApiOperation({ summary: 'Get all scores of a match' })
+ @ApiParam({ name: 'matchId', description: 'Match ID' })
+ @ApiResponse({
+ status: 200,
+ description: 'Returns all scores for the match ordered by set number',
+ type: [MatchScore],
+ })
+ async findAll(
+ @Param('matchId', ParseUUIDPipe) matchId: string,
+ ): Promise {
+ return await this.matchService.getScores(matchId);
+ }
+
+ @Put(':setNumber')
+ @ApiOperation({
+ summary: 'Create or update a match score',
+ description:
+ 'Creates or updates the score for a specific set. Match must be IN_PROGRESS. ' +
+ 'Set N can only be created after set N-1 exists.',
+ })
+ @ApiParam({ name: 'matchId', description: 'Match ID' })
+ @ApiParam({ name: 'setNumber', description: 'Set number (1-based)', example: 1 })
+ @ApiBody({ type: UpsertMatchScoreDto })
+ @ApiResponse({ status: 200, description: 'Score created or updated', type: MatchScore })
+ @ApiResponse({ status: 400, description: 'Match not IN_PROGRESS or previous set missing' })
+ @ApiResponse({ status: 404, description: 'Match not found' })
+ async upsert(
+ @Param('matchId', ParseUUIDPipe) matchId: string,
+ @Param('setNumber', ParseIntPipe) setNumber: number,
+ @Body() dto: UpsertMatchScoreDto,
+ ): Promise {
+ return await this.matchService.upsertScore(matchId, setNumber, dto);
+ }
+}
diff --git a/src/modules/event/match.service.ts b/src/modules/event/match.service.ts
index e846e0d..664145e 100644
--- a/src/modules/event/match.service.ts
+++ b/src/modules/event/match.service.ts
@@ -2,7 +2,7 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Match, MatchScore, EventParticipant } from './entities';
-import { CreateMatchDto, UpdateMatchDto, UpdateScoreDto } from './dto/match';
+import { CreateMatchDto, UpdateMatchDto, UpdateScoreDto, UpsertMatchScoreDto } from './dto/match';
import { MatchStatus } from './entities/match.entity';
@Injectable()
@@ -144,6 +144,40 @@ export class MatchService {
await this.matchRepository.save(match);
}
+ async upsertScore(matchId: string, setNumber: number, dto: UpsertMatchScoreDto): Promise {
+ const match = await this.matchRepository.findOne({ where: { id: matchId } });
+ if (!match) {
+ throw new NotFoundException(`Match ${matchId} not found`);
+ }
+ if (match.status !== MatchStatus.IN_PROGRESS) {
+ throw new BadRequestException('Match must be IN_PROGRESS to modify scores');
+ }
+
+ const existing = await this.scoreRepository.findOne({ where: { matchId, setNumber } });
+
+ if (!existing && setNumber > 1) {
+ const prevSet = await this.scoreRepository.findOne({
+ where: { matchId, setNumber: setNumber - 1 },
+ });
+ if (!prevSet) {
+ throw new BadRequestException(
+ `Set ${setNumber - 1} must exist before creating set ${setNumber}`,
+ );
+ }
+ }
+
+ let score = existing;
+ if (score) {
+ Object.assign(score, dto);
+ } else {
+ score = this.scoreRepository.create({ matchId, setNumber, ...dto });
+ }
+
+ const savedScore = await this.scoreRepository.save(score);
+ await this.updateMatchAggregateScores(matchId);
+ return savedScore;
+ }
+
async getScores(matchId: string): Promise {
return await this.scoreRepository.find({
where: { matchId },