diff --git a/backend/migrations/1769950000000-AddIdempotencyKeyToTips.ts b/backend/migrations/1769950000000-AddIdempotencyKeyToTips.ts new file mode 100644 index 0000000..504e0b6 --- /dev/null +++ b/backend/migrations/1769950000000-AddIdempotencyKeyToTips.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner, TableColumn, TableIndex } from 'typeorm'; + +export class AddIdempotencyKeyToTips1769950000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + 'tips', + new TableColumn({ + name: 'idempotencyKey', + type: 'varchar', + length: '128', + isNullable: true, + isUnique: true, + }), + ); + + await queryRunner.createIndex( + 'tips', + new TableIndex({ + name: 'IDX_tips_idempotencyKey', + columnNames: ['idempotencyKey'], + isUnique: true, + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropIndex('tips', 'IDX_tips_idempotencyKey'); + await queryRunner.dropColumn('tips', 'idempotencyKey'); + } +} diff --git a/backend/src/tips/create-tips.dto.ts b/backend/src/tips/create-tips.dto.ts index bc2922d..677bb02 100644 --- a/backend/src/tips/create-tips.dto.ts +++ b/backend/src/tips/create-tips.dto.ts @@ -1,4 +1,4 @@ -import { IsUUID, IsOptional, IsString } from 'class-validator'; +import { IsUUID, IsOptional, IsString, MaxLength } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { SanitiseAsPlainText } from '../common/utils/sanitise.util'; @@ -26,4 +26,13 @@ export class CreateTipDto { @IsString() @SanitiseAsPlainText() message?: string; + + @ApiPropertyOptional({ + description: 'Client-supplied idempotency key (UUID or opaque string, max 128 chars). Re-submitting with the same key returns the original tip instead of creating a duplicate.', + example: '550e8400-e29b-41d4-a716-446655440099', + }) + @IsOptional() + @IsString() + @MaxLength(128) + idempotencyKey?: string; } diff --git a/backend/src/tips/entities/tip.entity.ts b/backend/src/tips/entities/tip.entity.ts index 84658ab..bb6820d 100644 --- a/backend/src/tips/entities/tip.entity.ts +++ b/backend/src/tips/entities/tip.entity.ts @@ -47,6 +47,10 @@ export class Tip { @Column({ length: 64, unique: true }) stellarTxHash: string; + @Column({ length: 128, nullable: true, unique: true }) + @Index() + idempotencyKey?: string; + @Column({ length: 56 }) senderAddress: string; diff --git a/backend/src/tips/tips.controller.ts b/backend/src/tips/tips.controller.ts index cd3383b..2cf46d6 100644 --- a/backend/src/tips/tips.controller.ts +++ b/backend/src/tips/tips.controller.ts @@ -40,6 +40,11 @@ export class TipsController { description: 'User ID of the tipper', required: true, }) + @ApiHeader({ + name: 'Idempotency-Key', + description: 'Optional client-generated key (UUID recommended). Re-submitting with the same key returns the original tip without creating a duplicate.', + required: false, + }) @ApiResponse({ status: HttpStatus.CREATED, description: 'Tip successfully created', @@ -56,11 +61,15 @@ export class TipsController { async create( @Body(ModerateMessagePipe) createTipDto: CreateTipDto, @Headers('x-user-id') userId: string, + @Headers('idempotency-key') idempotencyKeyHeader?: string, ): Promise { if (!userId) { throw new BadRequestException('User ID header (x-user-id) is required'); } - // Simple validation, in real app use AuthGuard + // Header takes precedence over body field; merge into DTO + if (idempotencyKeyHeader) { + createTipDto.idempotencyKey = idempotencyKeyHeader; + } return this.tipsService.create(userId, createTipDto); } diff --git a/backend/src/tips/tips.service.ts b/backend/src/tips/tips.service.ts index 507de4c..021dad4 100644 --- a/backend/src/tips/tips.service.ts +++ b/backend/src/tips/tips.service.ts @@ -65,7 +65,20 @@ export class TipsService { ) {} async create(userId: string, createTipDto: CreateTipDto): Promise { - const { artistId, trackId, stellarTxHash, message } = createTipDto; + const { artistId, trackId, stellarTxHash, message, idempotencyKey } = createTipDto; + + // --- Idempotency key check: replay the original response if key already seen --- + if (idempotencyKey) { + const existing = await this.tipRepository.findOne({ + where: { idempotencyKey }, + }); + if (existing) { + this.logger.log( + `Idempotency replay for key=${idempotencyKey}, tipId=${existing.id}`, + ); + return existing; + } + } const existingTip = await this.tipRepository.findOne({ where: { stellarTxHash }, @@ -166,6 +179,7 @@ export class TipsService { status: TipStatus.VERIFIED, verifiedAt: new Date(), stellarTimestamp: new Date(txDetails.created_at), + ...(idempotencyKey ? { idempotencyKey } : {}), }); const savedTip = await this.tipRepository.save(newTip);