Skip to content
Closed
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
17 changes: 17 additions & 0 deletions app/backend/src/dto/username/public-profile.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,21 @@ export class PublicProfileDto {
example: '2025-02-19T08:00:00Z',
})
createdAt: string;

@ApiProperty({
description: 'Whether profile visibility is public',
example: true,
required: false,
})
isPublic?: boolean;

@ApiProperty({
description: 'Public payment settings for frontend payment link generation',
example: { acceptedAssets: ['USDC', 'XLM', 'AQUA', 'yXLM'], defaultAsset: 'USDC' },
required: false,
})
paymentSettings?: {
acceptedAssets: string[];
defaultAsset: string;
};
}
5 changes: 2 additions & 3 deletions app/backend/src/supabase/supabase.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -609,7 +609,7 @@ export class SupabaseService {
}

/**
* Fetch a single public profile by username
* Fetch a profile by username (including is_public flag)
*/
async getPublicProfile(
username: string,
Expand All @@ -620,8 +620,7 @@ export class SupabaseService {
"id, username, public_key, created_at, last_active_at, is_public",
)
.eq("username", username)
.eq("is_public", true)
.single();
.maybeSingle();

if (error) {
if (error.code === "PGRST116") return null; // not found
Expand Down
2 changes: 2 additions & 0 deletions app/backend/src/usernames/errors/username-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export enum UsernameErrorCode {
INVALID_FORMAT = 'USERNAME_INVALID_FORMAT',
/** Username not found. */
NOT_FOUND = 'USERNAME_NOT_FOUND',
/** Public profile visibility is disabled. */
PRIVACY_DISABLED = 'USERNAME_PRIVACY_DISABLED',
}

export class UsernameConflictError extends Error {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
import { UsernamesController } from './usernames.controller';
import { UsernamesService } from './usernames.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
Expand All @@ -13,6 +13,7 @@ describe('UsernamesController - Public Profile Discovery (Integration)', () => {
togglePublicProfile: jest.Mock;
listByPublicKey: jest.Mock;
create: jest.Mock;
getPublicProfile: jest.Mock;
getProfileByUsername: jest.Mock;
};
let eventEmitterMock: Partial<EventEmitter2>;
Expand All @@ -24,6 +25,7 @@ describe('UsernamesController - Public Profile Discovery (Integration)', () => {
togglePublicProfile: jest.fn(),
listByPublicKey: jest.fn(),
create: jest.fn(),
getPublicProfile: jest.fn(),
getProfileByUsername: jest.fn(),
};

Expand Down Expand Up @@ -66,7 +68,11 @@ describe('UsernamesController - Public Profile Discovery (Integration)', () => {
},
];

serviceMock.searchPublicUsernames.mockResolvedValue(mockResults);
serviceMock.searchPublicUsernames.mockResolvedValue({
data: mockResults,
next_cursor: null,
has_more: false,
});

const result = await controller.searchUsernames({
query: 'alice',
Expand All @@ -92,7 +98,11 @@ describe('UsernamesController - Public Profile Discovery (Integration)', () => {
},
];

serviceMock.searchPublicUsernames.mockResolvedValue(mockResults);
serviceMock.searchPublicUsernames.mockResolvedValue({
data: mockResults,
next_cursor: null,
has_more: false,
});

const result = await controller.searchUsernames({
query: 'bob',
Expand Down Expand Up @@ -135,7 +145,11 @@ describe('UsernamesController - Public Profile Discovery (Integration)', () => {
},
];

serviceMock.getTrendingCreators.mockResolvedValue(mockCreators);
serviceMock.getTrendingCreators.mockResolvedValue({
data: mockCreators,
next_cursor: null,
has_more: false,
});

const result = await controller.getTrendingCreators({
timeWindowHours: 24,
Expand Down Expand Up @@ -164,7 +178,11 @@ describe('UsernamesController - Public Profile Discovery (Integration)', () => {
},
];

serviceMock.getTrendingCreators.mockResolvedValue(mockCreators);
serviceMock.getTrendingCreators.mockResolvedValue({
data: mockCreators,
next_cursor: null,
has_more: false,
});

const result = await controller.getTrendingCreators({
timeWindowHours: 48,
Expand Down Expand Up @@ -232,6 +250,69 @@ describe('UsernamesController - Public Profile Discovery (Integration)', () => {
});
});

describe('GET /username/:username/public', () => {
it('should return public profile for active public username', async () => {
const mockServiceResult = {
id: '123e4567-e89b-12d3-a456-426614174000',
username: 'alice',
public_key: 'GBXGQ55JMQ4L2B6E7S8Y9Z0A1B2C3D4E5F6G7H8I7YWR',
created_at: '2025-02-19T08:00:00Z',
last_active_at: '2025-03-27T10:00:00Z',
is_public: true,
paymentSettings: {
acceptedAssets: ['USDC', 'XLM', 'AQUA', 'yXLM'],
defaultAsset: 'USDC',
},
};

serviceMock.getPublicProfile.mockResolvedValue(mockServiceResult);

const result = await controller.getPublicProfile('alice');

expect(result).toEqual({
id: '123e4567-e89b-12d3-a456-426614174000',
username: 'alice',
publicKey: 'GBXGQ55JMQ4L2B6E7S8Y9Z0A1B2C3D4E5F6G7H8I7YWR',
isPublic: true,
lastActiveAt: '2025-03-27T10:00:00Z',
createdAt: '2025-02-19T08:00:00Z',
paymentSettings: {
acceptedAssets: ['USDC', 'XLM', 'AQUA', 'yXLM'],
defaultAsset: 'USDC',
},
});
expect(serviceMock.getPublicProfile).toHaveBeenCalledWith('alice');
});

it('should throw 403 Forbidden for privacy-disabled profile', async () => {
const error = new UsernameValidationError(
UsernameErrorCode.PRIVACY_DISABLED,
'Public profile is disabled for this user',
'username',
);

serviceMock.getPublicProfile.mockRejectedValue(error);

await expect(controller.getPublicProfile('bob')).rejects.toThrow(
ForbiddenException,
);
});

it('should throw 404 Not Found for missing username', async () => {
const error = new UsernameValidationError(
UsernameErrorCode.NOT_FOUND,
'Username not found',
'username',
);

serviceMock.getPublicProfile.mockRejectedValue(error);

await expect(controller.getPublicProfile('nonexistent')).rejects.toThrow(
NotFoundException,
);
});
});

describe('GET /username/:username', () => {
it('should return public profile successfully', async () => {
serviceMock.getProfileByUsername.mockResolvedValue({
Expand Down
71 changes: 70 additions & 1 deletion app/backend/src/usernames/usernames.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
Body,
Controller,
Get,
Param,
Post,
Query,
Param,
Expand All @@ -14,6 +15,7 @@ import { Throttle } from "@nestjs/throttler";
import {
ApiBody,
ApiOperation,
ApiParam,
ApiQuery,
ApiResponse,
ApiTags,
Expand Down Expand Up @@ -433,6 +435,11 @@ export class UsernamesController {
description: "Returns profile details for a given username. " +
"If the profile is private, returns a privacy-aware response.",
})
@ApiParam({
name: "username",
description: "Username to retrieve",
example: "alice",
})
@ApiResponse({
status: 200,
description: "Profile details",
Expand Down Expand Up @@ -476,4 +483,66 @@ export class UsernamesController {
throw err;
}
}
}

@Get(":username/public")
@ApiOperation({
summary: "Get public profile by username",
description:
"Returns profile metadata, Stellar public key, and public payment settings for a username. " +
"Returns 404 if username is not found, and 403 if public profile visibility is disabled.",
})
@ApiParam({
name: "username",
description: "Username or slug to retrieve",
example: "alice",
})
@ApiResponse({
status: 200,
description: "Public profile retrieved successfully",
type: PublicProfileDto,
})
@ApiResponse({
status: 404,
description: "Username not found",
})
@ApiResponse({
status: 403,
description: "Public profile is disabled for this user",
})
async getPublicProfile(
@Param("username") username: string,
): Promise<PublicProfileDto> {
try {
const profile = await this.usernamesService.getPublicProfile(username);
return {
id: profile.id,
username: profile.username,
publicKey: profile.public_key,
isPublic: profile.is_public,
lastActiveAt: profile.last_active_at || profile.created_at,
createdAt: profile.created_at,
paymentSettings: profile.paymentSettings,
};
} catch (err) {
if (err instanceof UsernameValidationError) {
if (err.code === UsernameErrorCode.NOT_FOUND) {
throw new NotFoundException({
code: UsernameErrorCode.NOT_FOUND,
message: err.message,
});
}
if (err.code === UsernameErrorCode.PRIVACY_DISABLED) {
throw new ForbiddenException({
code: UsernameErrorCode.PRIVACY_DISABLED,
message: err.message,
});
}
throw new BadRequestException({
code: err.code,
message: err.message,
});
}
throw err;
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,13 @@ describe('UsernamesService - Cache Invalidation', () => {

const result = await service.getPublicProfile('alice');

expect(result).toEqual(profile);
expect(result).toEqual({
...profile,
paymentSettings: {
acceptedAssets: ['USDC', 'XLM', 'AQUA', 'yXLM'],
defaultAsset: 'USDC',
},
});
expect(supabaseMock.getPublicProfile).not.toHaveBeenCalled();
expect(cacheMock.setProfile).not.toHaveBeenCalled();
});
Expand All @@ -128,17 +134,23 @@ describe('UsernamesService - Cache Invalidation', () => {

const result = await service.getPublicProfile('alice');

expect(result).toEqual(profile);
expect(result).toEqual({
...profile,
paymentSettings: {
acceptedAssets: ['USDC', 'XLM', 'AQUA', 'yXLM'],
defaultAsset: 'USDC',
},
});
expect(supabaseMock.getPublicProfile).toHaveBeenCalledWith('alice');
expect(cacheMock.setProfile).toHaveBeenCalledWith('alice', profile);
});

it('does not cache null profiles', async () => {
supabaseMock.getPublicProfile!.mockResolvedValue(null);

const result = await service.getPublicProfile('ghost');

expect(result).toBeNull();
await expect(service.getPublicProfile('ghost')).rejects.toThrow(
UsernameValidationError,
);
expect(cacheMock.setProfile).not.toHaveBeenCalled();
});

Expand Down
Loading
Loading