Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/nft/dto/nft-swagger.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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://<cid>)',
example: 'ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG',
})
metadataUri: string;
}

export class NftMetadataAttributeDto {
@ApiProperty({ example: 'Virality Score' })
trait_type: string;
Expand Down
16 changes: 16 additions & 0 deletions src/nft/dto/upload-metadata.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
47 changes: 47 additions & 0 deletions src/nft/nft.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -49,6 +50,7 @@ import {
NftMetadataResponseDto,
NftOwnershipResultDto,
NftPrepareMintResponseDto,
NftUploadMetadataResponseDto,
NftMintConflictDto,
NftMintNotFoundDto,
NftPrepareMintBadRequestDto,
Expand Down Expand Up @@ -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<NftUploadMetadataResponseDto> {
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)
Expand Down
55 changes: 55 additions & 0 deletions test/nft.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown>; 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();
});
});
Loading