diff --git a/src/nft/dto/nft-swagger.dto.ts b/src/nft/dto/nft-swagger.dto.ts index f4de19cb..63d29477 100644 --- a/src/nft/dto/nft-swagger.dto.ts +++ b/src/nft/dto/nft-swagger.dto.ts @@ -229,6 +229,30 @@ export class NftPrepareMintBadRequestDto { error: string; } +/** + * Response after uploading clip NFT metadata to IPFS (before minting). + * Swagger documents metadataUri, IPFS CID, and a full example response. + */ +export class NftUploadMetadataResponseDto { + @ApiProperty({ + description: 'Clip ID whose metadata was uploaded', + example: 42, + }) + clipId: number; + + @ApiProperty({ + description: 'IPFS content identifier (CID) for the pinned metadata JSON', + example: 'QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG', + }) + cid: string; + + @ApiProperty({ + description: 'IPFS metadata URI persisted on the clip (ipfs://)', + example: 'ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG', + }) + metadataUri: string; +} + export class NftMetadataAttributeDto { @ApiProperty({ example: 'Virality Score' }) trait_type: string; diff --git a/src/nft/dto/upload-metadata.dto.ts b/src/nft/dto/upload-metadata.dto.ts new file mode 100644 index 00000000..c01f7d28 --- /dev/null +++ b/src/nft/dto/upload-metadata.dto.ts @@ -0,0 +1,16 @@ +import { IsInt, Min } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; + +/** Body for POST /nfts/upload-metadata — upload clip NFT metadata to IPFS before minting. */ +export class UploadClipMetadataDto { + @ApiProperty({ + description: 'Clip ID whose metadata should be uploaded to IPFS', + example: 42, + minimum: 1, + }) + @IsInt() + @Min(1) + @Type(() => Number) + clipId: number; +} diff --git a/src/nft/nft.controller.ts b/src/nft/nft.controller.ts index 5b6afbef..d75a5584 100644 --- a/src/nft/nft.controller.ts +++ b/src/nft/nft.controller.ts @@ -38,6 +38,7 @@ import { NftService, MintResult } from './nft.service'; import { MintNftDto } from './dto/mint-nft.dto'; import { CreateMintPreparationDto } from './dto/prepare-mint.dto'; import { ConfirmMintDto } from './dto/confirm-mint.dto'; +import { UploadClipMetadataDto } from './dto/upload-metadata.dto'; import { BatchMintDto, BatchMintResponseDto } from './dto/batch-mint.dto'; import { UpdateTokenUriDto, @@ -49,6 +50,7 @@ import { NftMetadataResponseDto, NftOwnershipResultDto, NftPrepareMintResponseDto, + NftUploadMetadataResponseDto, NftMintConflictDto, NftMintNotFoundDto, NftPrepareMintBadRequestDto, @@ -170,6 +172,51 @@ export class NftController { }; } + /** + * POST /nfts/upload-metadata + * Builds OpenSea-compatible metadata, uploads to IPFS, and persists metadataUri on the clip. + */ + @UseGuards(LoginGuard) + @Post('upload-metadata') + @HttpCode(HttpStatus.CREATED) + @Throttle({ nftMint: { limit: 5, ttl: 60000 } }) + @ApiBearerAuth('access-token') + @ApiOperation({ + summary: 'Upload clip NFT metadata to IPFS before minting', + description: + 'Builds metadata from the clip, uploads it to IPFS (Pinata or nft.storage), ' + + 'persists the metadata URI on the clip, and returns the IPFS CID and URI.', + }) + @ApiBody({ type: UploadClipMetadataDto }) + @ApiResponse({ + status: 201, + description: 'Metadata uploaded to IPFS and saved on the clip', + type: NftUploadMetadataResponseDto, + schema: { + example: { + clipId: 42, + cid: 'QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG', + metadataUri: 'ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG', + }, + }, + }) + @ApiBadRequestResponse({ + description: 'Clip is not ready for metadata upload (e.g. missing clipUrl)', + }) + @ApiUnauthorizedResponse({ + description: 'Unauthorized — Bearer JWT required', + }) + @ApiForbiddenResponse({ description: 'Caller does not own the clip' }) + @ApiNotFoundResponse({ description: 'Clip not found' }) + async uploadMetadata( + @Body() dto: UploadClipMetadataDto, + @Req() req: Request, + ): Promise { + const userId = Number((req as any).user?.id ?? 0); + await this.nftMintService.validateClipOwner(dto.clipId, userId); + return this.nftMintService.uploadMetadataToIPFS(dto.clipId); + } + @UseGuards(NftMintGuard) @Post('mint') @HttpCode(HttpStatus.CREATED) diff --git a/test/nft.e2e-spec.ts b/test/nft.e2e-spec.ts index ffb83e27..556a3ad7 100644 --- a/test/nft.e2e-spec.ts +++ b/test/nft.e2e-spec.ts @@ -119,6 +119,27 @@ describe('NFT mint preparation (e2e)', () => { expect(nftMintService.prepareMintTx).not.toHaveBeenCalled(); }); + it('uploads clip metadata to IPFS and returns cid + metadataUri', async () => { + nftMintService.uploadMetadataToIPFS.mockResolvedValue({ + clipId: 42, + cid: 'QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG', + metadataUri: 'ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG', + }); + + const response = await request(app.getHttpServer()) + .post('/nfts/upload-metadata') + .send({ clipId: 42 }) + .expect(201); + + expect(nftMintService.validateClipOwner).toHaveBeenCalledWith(42, 7); + expect(nftMintService.uploadMetadataToIPFS).toHaveBeenCalledWith(42); + expect(response.body).toEqual({ + clipId: 42, + cid: 'QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG', + metadataUri: 'ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG', + }); + }); + it('publishes the mint preparation contract in OpenAPI', () => { const document = SwaggerModule.createDocument( app, @@ -152,4 +173,38 @@ describe('NFT mint preparation (e2e)', () => { operation?.responses?.['201']?.content?.['application/json']?.schema; expect(created).toBeDefined(); }); + + it('publishes upload-metadata OpenAPI with metadataUri and IPFS CID', () => { + const document = SwaggerModule.createDocument( + app, + new DocumentBuilder() + .setTitle('Clips API') + .addBearerAuth( + { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, + 'access-token', + ) + .build(), + ); + const operation = document.paths['/nfts/upload-metadata']?.post; + expect(operation?.summary).toBe( + 'Upload clip NFT metadata to IPFS before minting', + ); + + const schemaName = 'NftUploadMetadataResponseDto'; + const schema = document.components?.schemas?.[schemaName] as + | { properties?: Record; example?: unknown } + | undefined; + expect(schema?.properties).toEqual( + expect.objectContaining({ + metadataUri: expect.any(Object), + cid: expect.any(Object), + clipId: expect.any(Object), + }), + ); + + const created = + operation?.responses?.['201']?.content?.['application/json']; + expect(created?.schema).toBeDefined(); + expect(created?.example ?? schema).toBeDefined(); + }); });