Skip to content

Commit de54764

Browse files
authored
Merge pull request #216 from jerrymusaga/feat/username-marketplace
feat: implement username marketplace with bidding (#209)
2 parents 1281bb9 + 25a8d1d commit de54764

14 files changed

Lines changed: 669 additions & 2 deletions

app/backend/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { CorrelationIdMiddleware } from "./common/middleware/correlation-id.midd
2828
import { NotificationsModule } from "./notifications/notifications.module";
2929
import { IngestionModule } from "./ingestion/ingestion.module";
3030
import { ApiKeysModule } from "./api-keys/api-keys.module";
31+
import { MarketplaceModule } from "./marketplace/marketplace.module";
3132

3233
type AppImport =
3334
| Type<unknown>
@@ -62,6 +63,7 @@ type AppImport =
6263
PaymentsModule,
6364
IngestionModule,
6465
ApiKeysModule,
66+
MarketplaceModule,
6567
];
6668

6769
// In development, if SUPABASE_URL points to a localhost placeholder (i.e. you don't
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsNotEmpty, IsString } from 'class-validator';
3+
import { IsStellarPublicKey } from '../../dto/validators';
4+
5+
export class AcceptBidDto {
6+
@ApiProperty({ example: 'GBXGQ55JMQ4L2B6E7S8Y9Z0A1B2C3D4E5F6G7H8I7YWR' })
7+
@IsString()
8+
@IsNotEmpty()
9+
@IsStellarPublicKey({ message: 'Public key must be a valid Stellar public key' })
10+
sellerPublicKey!: string;
11+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsNotEmpty, IsString } from 'class-validator';
3+
import { IsStellarPublicKey } from '../../dto/validators';
4+
5+
export class CancelListingDto {
6+
@ApiProperty({ example: 'GBXGQ55JMQ4L2B6E7S8Y9Z0A1B2C3D4E5F6G7H8I7YWR' })
7+
@IsString()
8+
@IsNotEmpty()
9+
@IsStellarPublicKey({ message: 'Public key must be a valid Stellar public key' })
10+
sellerPublicKey!: string;
11+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './list-username.dto';
2+
export * from './place-bid.dto';
3+
export * from './accept-bid.dto';
4+
export * from './cancel-listing.dto';
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
3+
import { IsStellarPublicKey, IsUsername } from '../../dto/validators';
4+
5+
export class ListUsernameDto {
6+
@ApiProperty({ example: 'alice_123' })
7+
@IsString()
8+
@IsNotEmpty()
9+
@IsUsername({ message: 'Username must contain only lowercase letters, numbers, and underscores' })
10+
username!: string;
11+
12+
@ApiProperty({ example: 'GBXGQ55JMQ4L2B6E7S8Y9Z0A1B2C3D4E5F6G7H8I7YWR' })
13+
@IsString()
14+
@IsNotEmpty()
15+
@IsStellarPublicKey({ message: 'Public key must be a valid Stellar public key' })
16+
sellerPublicKey!: string;
17+
18+
@ApiProperty({ description: 'Asking price in XLM', example: 100.5 })
19+
@IsNumber()
20+
@Min(0.0000001)
21+
askingPrice!: number;
22+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
3+
import { IsStellarPublicKey } from '../../dto/validators';
4+
5+
export class PlaceBidDto {
6+
@ApiProperty({ example: 'GBXGQ55JMQ4L2B6E7S8Y9Z0A1B2C3D4E5F6G7H8I7YWR' })
7+
@IsString()
8+
@IsNotEmpty()
9+
@IsStellarPublicKey({ message: 'Public key must be a valid Stellar public key' })
10+
bidderPublicKey!: string;
11+
12+
@ApiProperty({ description: 'Bid amount in XLM', example: 90.0 })
13+
@IsNumber()
14+
@Min(0.0000001)
15+
bidAmount!: number;
16+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './marketplace-errors';
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export enum MarketplaceErrorCode {
2+
LISTING_NOT_FOUND = 'LISTING_NOT_FOUND',
3+
BID_NOT_FOUND = 'BID_NOT_FOUND',
4+
LISTING_NOT_ACTIVE = 'LISTING_NOT_ACTIVE',
5+
BID_NOT_PENDING = 'BID_NOT_PENDING',
6+
UNAUTHORIZED = 'MARKETPLACE_UNAUTHORIZED',
7+
USERNAME_NOT_OWNED = 'USERNAME_NOT_OWNED',
8+
ALREADY_LISTED = 'USERNAME_ALREADY_LISTED',
9+
SELF_BID = 'MARKETPLACE_SELF_BID',
10+
INVALID_PRICE = 'MARKETPLACE_INVALID_PRICE',
11+
}
12+
13+
export class MarketplaceError extends Error {
14+
constructor(
15+
public readonly code: MarketplaceErrorCode,
16+
message: string,
17+
) {
18+
super(message);
19+
this.name = 'MarketplaceError';
20+
}
21+
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import {
2+
Body,
3+
Controller,
4+
Delete,
5+
Get,
6+
NotFoundException,
7+
Param,
8+
Post,
9+
Query,
10+
BadRequestException,
11+
ForbiddenException,
12+
ConflictException,
13+
} from '@nestjs/common';
14+
import {
15+
ApiBody,
16+
ApiOperation,
17+
ApiParam,
18+
ApiQuery,
19+
ApiResponse,
20+
ApiTags,
21+
} from '@nestjs/swagger';
22+
23+
import { MarketplaceService } from './marketplace.service';
24+
import { ListUsernameDto, PlaceBidDto, AcceptBidDto, CancelListingDto } from './dto';
25+
import { MarketplaceError, MarketplaceErrorCode } from './errors';
26+
27+
@ApiTags('marketplace')
28+
@Controller('marketplace')
29+
export class MarketplaceController {
30+
constructor(private readonly marketplaceService: MarketplaceService) {}
31+
32+
@Post('list')
33+
@ApiOperation({ summary: 'List a username for sale' })
34+
@ApiBody({ type: ListUsernameDto })
35+
@ApiResponse({ status: 201, description: 'Listing created' })
36+
@ApiResponse({ status: 400, description: 'Invalid input' })
37+
@ApiResponse({ status: 403, description: 'Username not owned by this wallet' })
38+
@ApiResponse({ status: 409, description: 'Username already listed' })
39+
async listUsername(@Body() body: ListUsernameDto) {
40+
try {
41+
const listing = await this.marketplaceService.listUsername(
42+
body.username,
43+
body.sellerPublicKey,
44+
body.askingPrice,
45+
);
46+
return { listing };
47+
} catch (err) {
48+
if (err instanceof MarketplaceError) {
49+
this.throwHttp(err);
50+
}
51+
throw err;
52+
}
53+
}
54+
55+
@Get()
56+
@ApiOperation({ summary: 'Get all active listings' })
57+
@ApiQuery({ name: 'limit', required: false, example: 20 })
58+
@ApiQuery({ name: 'offset', required: false, example: 0 })
59+
@ApiResponse({ status: 200, description: 'List of active listings' })
60+
async getActiveListings(
61+
@Query('limit') limit = 20,
62+
@Query('offset') offset = 0,
63+
) {
64+
const { listings, total } = await this.marketplaceService.getActiveListings(
65+
Number(limit),
66+
Number(offset),
67+
);
68+
return { listings, total };
69+
}
70+
71+
@Get(':listingId')
72+
@ApiOperation({ summary: 'Get a specific listing' })
73+
@ApiParam({ name: 'listingId', description: 'Listing UUID' })
74+
@ApiResponse({ status: 200, description: 'Listing details' })
75+
@ApiResponse({ status: 404, description: 'Listing not found' })
76+
async getListing(@Param('listingId') listingId: string) {
77+
try {
78+
const listing = await this.marketplaceService.getListing(listingId);
79+
return { listing };
80+
} catch (err) {
81+
if (err instanceof MarketplaceError) {
82+
this.throwHttp(err);
83+
}
84+
throw err;
85+
}
86+
}
87+
88+
@Delete(':listingId')
89+
@ApiOperation({ summary: 'Cancel a listing' })
90+
@ApiParam({ name: 'listingId', description: 'Listing UUID' })
91+
@ApiBody({ type: CancelListingDto })
92+
@ApiResponse({ status: 200, description: 'Listing cancelled' })
93+
@ApiResponse({ status: 403, description: 'Not the seller' })
94+
@ApiResponse({ status: 404, description: 'Listing not found' })
95+
async cancelListing(
96+
@Param('listingId') listingId: string,
97+
@Body() body: CancelListingDto,
98+
) {
99+
try {
100+
await this.marketplaceService.cancelListing(listingId, body.sellerPublicKey);
101+
return { ok: true };
102+
} catch (err) {
103+
if (err instanceof MarketplaceError) {
104+
this.throwHttp(err);
105+
}
106+
throw err;
107+
}
108+
}
109+
110+
@Post(':listingId/bid')
111+
@ApiOperation({ summary: 'Place a bid on a listing' })
112+
@ApiParam({ name: 'listingId', description: 'Listing UUID' })
113+
@ApiBody({ type: PlaceBidDto })
114+
@ApiResponse({ status: 201, description: 'Bid placed' })
115+
@ApiResponse({ status: 400, description: 'Seller cannot bid on own listing' })
116+
@ApiResponse({ status: 404, description: 'Listing not found' })
117+
async placeBid(
118+
@Param('listingId') listingId: string,
119+
@Body() body: PlaceBidDto,
120+
) {
121+
try {
122+
const bid = await this.marketplaceService.placeBid(
123+
listingId,
124+
body.bidderPublicKey,
125+
body.bidAmount,
126+
);
127+
return { bid };
128+
} catch (err) {
129+
if (err instanceof MarketplaceError) {
130+
this.throwHttp(err);
131+
}
132+
throw err;
133+
}
134+
}
135+
136+
@Get(':listingId/bids')
137+
@ApiOperation({ summary: 'Get all bids for a listing' })
138+
@ApiParam({ name: 'listingId', description: 'Listing UUID' })
139+
@ApiResponse({ status: 200, description: 'List of bids' })
140+
@ApiResponse({ status: 404, description: 'Listing not found' })
141+
async getBids(@Param('listingId') listingId: string) {
142+
try {
143+
const bids = await this.marketplaceService.getBids(listingId);
144+
return { bids };
145+
} catch (err) {
146+
if (err instanceof MarketplaceError) {
147+
this.throwHttp(err);
148+
}
149+
throw err;
150+
}
151+
}
152+
153+
@Post(':listingId/accept-bid/:bidId')
154+
@ApiOperation({ summary: 'Accept a bid — atomically transfers username ownership' })
155+
@ApiParam({ name: 'listingId', description: 'Listing UUID' })
156+
@ApiParam({ name: 'bidId', description: 'Bid UUID' })
157+
@ApiBody({ type: AcceptBidDto })
158+
@ApiResponse({ status: 200, description: 'Bid accepted and ownership transferred' })
159+
@ApiResponse({ status: 400, description: 'Bid not pending or listing not active' })
160+
@ApiResponse({ status: 403, description: 'Not the seller' })
161+
@ApiResponse({ status: 404, description: 'Listing or bid not found' })
162+
async acceptBid(
163+
@Param('listingId') listingId: string,
164+
@Param('bidId') bidId: string,
165+
@Body() body: AcceptBidDto,
166+
) {
167+
try {
168+
await this.marketplaceService.acceptBid(listingId, bidId, body.sellerPublicKey);
169+
return { ok: true };
170+
} catch (err) {
171+
if (err instanceof MarketplaceError) {
172+
this.throwHttp(err);
173+
}
174+
throw err;
175+
}
176+
}
177+
178+
private throwHttp(err: MarketplaceError): never {
179+
switch (err.code) {
180+
case MarketplaceErrorCode.LISTING_NOT_FOUND:
181+
case MarketplaceErrorCode.BID_NOT_FOUND:
182+
throw new NotFoundException({ code: err.code, message: err.message });
183+
case MarketplaceErrorCode.UNAUTHORIZED:
184+
case MarketplaceErrorCode.USERNAME_NOT_OWNED:
185+
throw new ForbiddenException({ code: err.code, message: err.message });
186+
case MarketplaceErrorCode.ALREADY_LISTED:
187+
throw new ConflictException({ code: err.code, message: err.message });
188+
default:
189+
throw new BadRequestException({ code: err.code, message: err.message });
190+
}
191+
}
192+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Module } from '@nestjs/common';
2+
3+
import { SupabaseModule } from '../supabase/supabase.module';
4+
import { UsernamesModule } from '../usernames/usernames.module';
5+
import { MarketplaceController } from './marketplace.controller';
6+
import { MarketplaceService } from './marketplace.service';
7+
8+
@Module({
9+
imports: [SupabaseModule, UsernamesModule],
10+
controllers: [MarketplaceController],
11+
providers: [MarketplaceService],
12+
})
13+
export class MarketplaceModule {}

0 commit comments

Comments
 (0)