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/campaign/campaign-order.controller.ts b/src/modules/campaign/campaign-order.controller.ts
index 5d909ac..7526a5c 100644
--- a/src/modules/campaign/campaign-order.controller.ts
+++ b/src/modules/campaign/campaign-order.controller.ts
@@ -1,5 +1,19 @@
-import { Controller, Get, Post, Body, Param, Query, Res, UseGuards } from '@nestjs/common';
-import { ApiTags, ApiBearerAuth, ApiCreatedResponse, ApiQuery } from '@nestjs/swagger';
+import {
+ Controller,
+ Get,
+ Post,
+ Body,
+ Param,
+ Query,
+ Res,
+ UseGuards,
+} from '@nestjs/common';
+import {
+ ApiTags,
+ ApiBearerAuth,
+ ApiCreatedResponse,
+ ApiQuery,
+} from '@nestjs/swagger';
import { Response } from 'express';
import { CampaignOrderService } from './campaign-order.service';
import { CreateOrderDto } from './dto/create-order.dto';
@@ -25,10 +39,27 @@ export class CampaignOrderController {
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN, Role.ORGANIZER)
@ApiBearerAuth()
- findAll(@Param('campaignId') campaignId: string, @Query() query: OrderQueryDto) {
+ findAll(
+ @Param('campaignId') campaignId: string,
+ @Query() query: OrderQueryDto,
+ ) {
return this.orderService.findAll(campaignId, query);
}
+ @Post(':orderCode/resend-email')
+ @UseGuards(JwtAuthGuard, RolesGuard)
+ @Roles(Role.ADMIN, Role.ORGANIZER)
+ @ApiBearerAuth()
+ resendEmail(
+ @Param('campaignId') campaignId: string,
+ @Param('orderCode') orderCode: string,
+ ) {
+ return this.orderService.resendOrderConfirmationEmail(
+ campaignId,
+ orderCode,
+ );
+ }
+
@Get(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.ADMIN, Role.ORGANIZER)
@@ -49,9 +80,14 @@ export class CampaignOrderController {
@Query('toDate') toDate: string,
@Res() res: Response,
) {
- const buffer = await this.orderService.exportExcel(campaignId, fromDate, toDate);
+ const buffer = await this.orderService.exportExcel(
+ campaignId,
+ fromDate,
+ toDate,
+ );
res.set({
- 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'Content-Type':
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename=orders-${campaignId}.xlsx`,
});
res.end(buffer);
diff --git a/src/modules/campaign/campaign-order.service.ts b/src/modules/campaign/campaign-order.service.ts
index eaf81e2..13695ec 100644
--- a/src/modules/campaign/campaign-order.service.ts
+++ b/src/modules/campaign/campaign-order.service.ts
@@ -319,6 +319,26 @@ export class CampaignOrderService {
return workbook.xlsx.writeBuffer();
}
+ async resendOrderConfirmationEmail(campaignId: string, orderCode: string): Promise<{ success: true }> {
+ const order = await this.orderModel.findOne({
+ orderCode,
+ campaignId: new Types.ObjectId(campaignId),
+ });
+ if (!order) throw new NotFoundException('Order not found');
+
+ if (order.paymentStatus !== CampaignOrderStatus.PAID) {
+ throw new BadRequestException('Only paid orders can receive confirmation emails');
+ }
+
+ await this.sendOrderConfirmationEmail(
+ order,
+ order.paidAt?.toISOString(),
+ (order.paymentMetadata as any)?.paymentMethod,
+ );
+
+ return { success: true };
+ }
+
private async sendOrderConfirmationEmail(
order: CampaignOrderDocument,
transactionDate?: string,
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.controller.ts b/src/modules/event/match.controller.ts
index 9cb4299..c3529bc 100644
--- a/src/modules/event/match.controller.ts
+++ b/src/modules/event/match.controller.ts
@@ -75,9 +75,6 @@ export class MatchController {
}
@Post(':id/start')
- @UseGuards(JwtAuthGuard, RolesGuard)
- @Roles(Role.ADMIN, Role.ORGANIZER)
- @ApiBearerAuth()
@ApiOperation({ summary: 'Start match' })
@ApiParam({ name: 'eventId', description: 'Event ID' })
@ApiParam({ name: 'id', description: 'Match ID' })
@@ -91,9 +88,6 @@ export class MatchController {
}
@Post(':id/end')
- @UseGuards(JwtAuthGuard, RolesGuard)
- @Roles(Role.ADMIN, Role.ORGANIZER)
- @ApiBearerAuth()
@ApiOperation({ summary: 'End match' })
@ApiParam({ name: 'eventId', description: 'Event ID' })
@ApiParam({ name: 'id', description: 'Match ID' })
diff --git a/src/modules/event/match.service.spec.ts b/src/modules/event/match.service.spec.ts
index 6cfe79a..a224ebc 100644
--- a/src/modules/event/match.service.spec.ts
+++ b/src/modules/event/match.service.spec.ts
@@ -92,7 +92,7 @@ describe('MatchService', () => {
expect(result).toEqual(match);
expect(mockMatchRepository.findOne).toHaveBeenCalledWith({
where: { id: 'match-123' },
- relations: ['session', 'scores'],
+ relations: ['session', 'scores', 'team1Player1', 'team1Player2', 'team2Player1', 'team2Player2'],
});
});
diff --git a/src/modules/event/match.service.ts b/src/modules/event/match.service.ts
index e846e0d..2af9f2d 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()
@@ -32,7 +32,7 @@ export class MatchService {
async findOne(id: string): Promise {
const match = await this.matchRepository.findOne({
where: { id },
- relations: ['session', 'scores'],
+ relations: ['session', 'scores', 'team1Player1', 'team1Player2', 'team2Player1', 'team2Player2'],
});
if (!match) {
@@ -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 },
diff --git a/src/modules/mail/mail.service.ts b/src/modules/mail/mail.service.ts
index aafac8c..9eedaa2 100644
--- a/src/modules/mail/mail.service.ts
+++ b/src/modules/mail/mail.service.ts
@@ -101,12 +101,20 @@ export class MailService {
},
});
+ // Sanitize response to remove circular references before saving to BSON
+ let sanitizedResponse: any;
+ try {
+ sanitizedResponse = JSON.parse(JSON.stringify(response));
+ } catch {
+ sanitizedResponse = { raw: String(response) };
+ }
+
await this.notificationModel.updateOne(
{ _id: notification._id },
{
$set: {
status: EmailNotificationStatus.SENT,
- response,
+ response: sanitizedResponse,
sentAt: new Date(),
},
},