diff --git a/apps/backend/src/user/tests/user.controller.spec.ts b/apps/backend/src/user/tests/user.controller.spec.ts
index 9ffdc139..76b2e11a 100644
--- a/apps/backend/src/user/tests/user.controller.spec.ts
+++ b/apps/backend/src/user/tests/user.controller.spec.ts
@@ -20,6 +20,29 @@ const allUsers = [
{ id: 3, createdAt: mockDate, email: 'cindy@example.com', name: 'Cindy' },
];
+const mockPublicUser = {
+ id: 1,
+ name: 'alice',
+ displayName: 'Alice',
+ avatarKey: null,
+ createdAt: mockDate.toISOString(),
+ bio: null,
+ city: null,
+ country: null,
+ isProfilePublic: true,
+};
+
+const mockPublicEvents = [
+ {
+ id: 1,
+ title: 'Test Event',
+ slug: 'test-event',
+ startAt: mockDate.toISOString(),
+ imageKey: null,
+ location: null,
+ },
+];
+
// Create a mock user service
const mockUserService = {
userGet: jest.fn().mockResolvedValue(allUsers),
@@ -28,6 +51,8 @@ const mockUserService = {
createdAt: mockDate,
...data,
})),
+ userGetPublicByName: jest.fn().mockResolvedValue(mockPublicUser),
+ userGetPublicEventsByName: jest.fn().mockResolvedValue(mockPublicEvents),
};
// Create the Nest testing module and replace the real UserService with a mock.
@@ -65,6 +90,48 @@ describe('UserController', () => {
});
});
+ // Tests for getUserByUsername controller path.
+ describe('Get public user by username', () => {
+ it('should call userService.userGetPublicByName() with the username param', async () => {
+ const spy = jest.spyOn(userService, 'userGetPublicByName');
+ await userController.getUserByUsername('alice');
+ expect(spy).toHaveBeenCalledWith('alice', undefined);
+ });
+
+ it('should return the user from service', async () => {
+ const result = await userController.getUserByUsername('alice');
+ expect(result).toEqual(mockPublicUser);
+ });
+
+ it('should throw NotFoundException when user does not exist', async () => {
+ jest.spyOn(userService, 'userGetPublicByName').mockResolvedValueOnce(null);
+ await expect(userController.getUserByUsername('nobody')).rejects.toThrow('User not found');
+ });
+ });
+
+ // Tests for getUserEventsByUsername controller path.
+ describe('Get public user events by username', () => {
+ const defaultQuery = { limit: 12, cursor: undefined };
+
+ it('should call userService.userGetPublicEventsByName() with the username and query params', async () => {
+ const spy = jest.spyOn(userService, 'userGetPublicEventsByName');
+ await userController.getUserEventsByUsername('alice', defaultQuery);
+ expect(spy).toHaveBeenCalledWith('alice', undefined, 12, undefined);
+ });
+
+ it('should return events from service', async () => {
+ const result = await userController.getUserEventsByUsername('alice', defaultQuery);
+ expect(result).toEqual(mockPublicEvents);
+ });
+
+ it('should throw NotFoundException when user does not exist', async () => {
+ jest.spyOn(userService, 'userGetPublicEventsByName').mockResolvedValueOnce(null);
+ await expect(userController.getUserEventsByUsername('nobody', defaultQuery)).rejects.toThrow(
+ 'User not found'
+ );
+ });
+ });
+
// Tests for the userPost controller path.
// Test 1: Verifies that the controller delegates the call to userService.userPost. Also that it is called with correct argument.
// Test 2: Verifies that the controller returns the value received from userService.userPost without altering it.
diff --git a/apps/backend/src/user/user.controller.ts b/apps/backend/src/user/user.controller.ts
index d39beb09..4b193faf 100644
--- a/apps/backend/src/user/user.controller.ts
+++ b/apps/backend/src/user/user.controller.ts
@@ -33,6 +33,7 @@ import {
ResMyInvitedEventsDto,
ResUserDeleteSchema,
ResUserPatchSchema,
+ ReqUserPublicEventsDto,
} from '@/user/user.schema';
import { UserService } from '@/user/user.service';
import { ResUserGetAllSchema, ResUserAdminGetAllSchema } from '@grit/schema';
@@ -199,28 +200,32 @@ export class UserController {
return this.userService.userDelete(param.id, user);
}
- @Get(':id')
+ @Get(':username')
@UseGuards(JwtAuthOptionalGuard)
- async getUserById(@Param('id') id: string, @GetUser('id') requestingUserId?: number) {
- const userId = parseInt(id, 10);
- if (isNaN(userId)) {
- throw new NotFoundException('User not found');
- }
- const user = await this.userService.userGetPublic(userId, requestingUserId);
+ async getUserByUsername(
+ @Param('username') username: string,
+ @GetUser('id') requestingUserId?: number
+ ) {
+ const user = await this.userService.userGetPublicByName(username, requestingUserId);
if (!user) {
throw new NotFoundException('User not found');
}
return user;
}
- @Get(':id/events')
+ @Get(':username/events')
@UseGuards(JwtAuthOptionalGuard)
- async getUserEvents(@Param('id') id: string, @GetUser('id') requestingUserId?: number) {
- const userId = parseInt(id, 10);
- if (isNaN(userId)) {
- throw new NotFoundException('User not found');
- }
- const events = await this.userService.userGetPublicEvents(userId, requestingUserId);
+ async getUserEventsByUsername(
+ @Param('username') username: string,
+ @Query() query: ReqUserPublicEventsDto,
+ @GetUser('id') requestingUserId?: number
+ ) {
+ const events = await this.userService.userGetPublicEventsByName(
+ username,
+ requestingUserId,
+ query.limit,
+ query.cursor
+ );
if (events === null) {
throw new NotFoundException('User not found');
}
diff --git a/apps/backend/src/user/user.schema.ts b/apps/backend/src/user/user.schema.ts
index e881cf08..f7761cf3 100644
--- a/apps/backend/src/user/user.schema.ts
+++ b/apps/backend/src/user/user.schema.ts
@@ -27,6 +27,12 @@ export const ReqUserGetAllSchema = z.strictObject({
search: z.string().min(1).optional(),
});
+// Query params for paginated public user events
+export const ReqUserPublicEventsSchema = z.strictObject({
+ limit: z.coerce.number().int().positive().max(100).default(12),
+ cursor: z.string().optional(),
+});
+
// Post a new user draft
export const ReqUserPostSchema = z.object({
name: z.string(),
@@ -106,3 +112,4 @@ export class ReqUserDeleteAvatarDto extends createZodDto(ReqUserDeleteAvatarSche
export class ReqUserDeleteByIdDto extends createZodDto(ReqUserDeleteByIdSchema) {}
export class ReqUserPatchDto extends createZodDto(ReqUserPatchSchema) {}
export class ReqUserPatchByIdDto extends createZodDto(ReqUserPatchByIdSchema) {}
+export class ReqUserPublicEventsDto extends createZodDto(ReqUserPublicEventsSchema) {}
diff --git a/apps/backend/src/user/user.service.ts b/apps/backend/src/user/user.service.ts
index 3419927d..71b46676 100644
--- a/apps/backend/src/user/user.service.ts
+++ b/apps/backend/src/user/user.service.ts
@@ -747,6 +747,31 @@ export class UserService {
};
}
+ async userGetPublicByName(name: string, requestingUserId?: number) {
+ const normalized = name.toLowerCase();
+ const user = await this.prisma.user.findUnique({
+ where: { name: normalized },
+ select: { id: true },
+ });
+ if (!user) return null;
+ return this.userGetPublic(user.id, requestingUserId);
+ }
+
+ async userGetPublicEventsByName(
+ name: string,
+ requestingUserId?: number,
+ limit = 12,
+ cursor?: string
+ ) {
+ const normalized = name.toLowerCase();
+ const user = await this.prisma.user.findUnique({
+ where: { name: normalized },
+ select: { id: true },
+ });
+ if (!user) return null;
+ return this.userGetPublicEvents(user.id, requestingUserId, limit, cursor);
+ }
+
async userGetPublic(id: number, requestingUserId?: number) {
const user = await this.prisma.user.findUnique({
where: { id },
@@ -816,7 +841,12 @@ export class UserService {
};
}
- async userGetPublicEvents(userId: number, requestingUserId?: number) {
+ async userGetPublicEvents(
+ userId: number,
+ requestingUserId?: number,
+ limit = 12,
+ cursor?: string
+ ) {
// First check if the user's profile is accessible
const user = await this.prisma.user.findUnique({
where: { id: userId },
@@ -848,27 +878,55 @@ export class UserService {
}
}
+ // Decode cursor: encodes startAt (ISO) + id, e.g. "2026-01-01T00:00:00.000Z|42"
+ let cursorFilter: Prisma.EventWhereInput = {};
+ if (cursor) {
+ try {
+ const decoded = Buffer.from(cursor, 'base64').toString('utf-8');
+ const [startAtStr, idStr] = decoded.split('|');
+ const startAt = new Date(startAtStr);
+ const id = parseInt(idStr, 10);
+ if (isNaN(startAt.getTime()) || isNaN(id)) throw new Error('bad cursor');
+ // ordered by startAt desc, id desc — so "after cursor" means earlier startAt, or same startAt with smaller id
+ cursorFilter = {
+ OR: [{ startAt: { lt: startAt } }, { startAt, id: { lt: id } }],
+ };
+ } catch {
+ throw new BadRequestException('Invalid cursor provided');
+ }
+ }
+
const events = await this.prisma.event.findMany({
where: {
authorId: userId,
isPublished: true,
isPublic: true,
+ ...cursorFilter,
},
include: {
location: true,
},
- orderBy: {
- startAt: 'desc',
- },
+ orderBy: [{ startAt: 'desc' }, { id: 'desc' }],
+ take: limit + 1,
});
- return events.map((event) => ({
- id: event.id,
- title: event.title,
- slug: event.slug,
- startAt: event.startAt.toISOString(),
- imageKey: event.imageKey,
- location: event.location,
- }));
+ const hasMore = events.length > limit;
+ const sliced = hasMore ? events.slice(0, limit) : events;
+ const last = sliced[sliced.length - 1];
+ const nextCursor = hasMore
+ ? Buffer.from(`${last.startAt.toISOString()}|${String(last.id)}`).toString('base64')
+ : null;
+
+ return {
+ data: sliced.map((event) => ({
+ id: event.id,
+ title: event.title,
+ slug: event.slug,
+ startAt: event.startAt.toISOString(),
+ imageKey: event.imageKey,
+ location: event.location,
+ })),
+ pagination: { nextCursor, hasMore },
+ };
}
}
diff --git a/apps/frontend/src/features/chat/ChatBoxHeader.tsx b/apps/frontend/src/features/chat/ChatBoxHeader.tsx
index e9c71526..3d084737 100644
--- a/apps/frontend/src/features/chat/ChatBoxHeader.tsx
+++ b/apps/frontend/src/features/chat/ChatBoxHeader.tsx
@@ -67,7 +67,7 @@ export const ChatBoxHeader = ({ conversation }: { conversation: ResConversationS
{isDirect && isValidOtherUser ? (
-
{avatarEl}
+
{avatarEl}
) : hasEvent ? (
{avatarEl}
) : (
@@ -76,7 +76,7 @@ export const ChatBoxHeader = ({ conversation }: { conversation: ResConversationS
{conversation.type}
{isDirect && isValidOtherUser ? (
-
+
{title}
) : hasEvent ? (
diff --git a/apps/frontend/src/features/chat/ChatBubble.tsx b/apps/frontend/src/features/chat/ChatBubble.tsx
index 9e2478dc..922053e7 100644
--- a/apps/frontend/src/features/chat/ChatBubble.tsx
+++ b/apps/frontend/src/features/chat/ChatBubble.tsx
@@ -48,7 +48,7 @@ export const ChatBubble = ({ message }: { message: ResChatMessage }) => {
{isDeletedUser ? (
) : (
-
+
)}
@@ -60,7 +60,7 @@ export const ChatBubble = ({ message }: { message: ResChatMessage }) => {
{isDeletedUser ? (
{author.displayName ?? author.name}
) : (
-
+
{author.displayName ?? author.name}
)}
diff --git a/apps/frontend/src/features/chat/ConversationCard.tsx b/apps/frontend/src/features/chat/ConversationCard.tsx
index 889ec7b9..c828ad22 100644
--- a/apps/frontend/src/features/chat/ConversationCard.tsx
+++ b/apps/frontend/src/features/chat/ConversationCard.tsx
@@ -80,7 +80,7 @@ export const ConversationCard = ({ conversation, isActive }: ConversationCardPro
)}
{isDirect && isValidOtherUser ? (
-
{avatarEl}
+
{avatarEl}
) : hasEvent ? (
{avatarEl}
) : (
@@ -90,7 +90,7 @@ export const ConversationCard = ({ conversation, isActive }: ConversationCardPro
{isDirect && isValidOtherUser ? (
{title}
diff --git a/apps/frontend/src/features/search/GlobalSearch.tsx b/apps/frontend/src/features/search/GlobalSearch.tsx
index 2960ef0e..568cebfb 100644
--- a/apps/frontend/src/features/search/GlobalSearch.tsx
+++ b/apps/frontend/src/features/search/GlobalSearch.tsx
@@ -126,10 +126,10 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
});
};
- const handleSelectUser = (id: number) => {
+ const handleSelectUser = (name: string) => {
onOpenChange(false);
requestAnimationFrame(() => {
- void navigate(`/users/${String(id)}`);
+ void navigate(`/users/${name}`);
});
};
@@ -240,7 +240,7 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
key={user.id}
value={`user-${String(user.id)}-${user.name}`}
onSelect={() => {
- handleSelectUser(user.id);
+ handleSelectUser(user.name);
}}
>
diff --git a/apps/frontend/src/pages/events/EventPage.tsx b/apps/frontend/src/pages/events/EventPage.tsx
index 62cc60c2..024d8aa1 100644
--- a/apps/frontend/src/pages/events/EventPage.tsx
+++ b/apps/frontend/src/pages/events/EventPage.tsx
@@ -203,7 +203,7 @@ export const EventPage = () => {
{event.author ? (
diff --git a/apps/frontend/src/pages/my-friends/Page.tsx b/apps/frontend/src/pages/my-friends/Page.tsx
index aa7022bc..c46ac321 100644
--- a/apps/frontend/src/pages/my-friends/Page.tsx
+++ b/apps/frontend/src/pages/my-friends/Page.tsx
@@ -269,7 +269,7 @@ function FriendSearch({
actions={
<>
@@ -328,7 +328,7 @@ function PendingSection({ requests, onAccept, onDecline }: PendingSectionProps)
actions={
<>
@@ -365,7 +365,7 @@ function OutgoingSection({ requests, onCancel }: OutgoingSectionProps) {
actions={
<>
@@ -469,7 +469,7 @@ function FriendsSection({
actions={
<>
diff --git a/apps/frontend/src/pages/profile/components/ProfileSidebar.tsx b/apps/frontend/src/pages/profile/components/ProfileSidebar.tsx
index b59f2eda..4aa99fa9 100644
--- a/apps/frontend/src/pages/profile/components/ProfileSidebar.tsx
+++ b/apps/frontend/src/pages/profile/components/ProfileSidebar.tsx
@@ -135,7 +135,7 @@ export function ProfileSidebar({ user, avatarUrl, onAvatarUpdate }: ProfileSideb
{
label: 'Public Profile',
icon: Eye,
- href: `/users/${user.id}`,
+ href: `/users/${user.name}`,
},
];
diff --git a/apps/frontend/src/pages/public-profile/Page.tsx b/apps/frontend/src/pages/public-profile/Page.tsx
index 1a883f40..10ef533c 100644
--- a/apps/frontend/src/pages/public-profile/Page.tsx
+++ b/apps/frontend/src/pages/public-profile/Page.tsx
@@ -4,7 +4,7 @@ import { userService } from '@/services/userService';
import { useAuthStore } from '@/store/authStore';
import { useCurrentUserStore } from '@/store/currentUserStore';
import type { FriendshipStatus } from '@/types/friends';
-import type { ResUserPublicEvents } from '@grit/schema';
+import type { ResUserPublicEventsPaginated } from '@grit/schema';
import { useState } from 'react';
import { LoaderFunctionArgs, useLoaderData } from 'react-router-dom';
import { toast } from 'sonner';
@@ -35,17 +35,20 @@ const fetchAllRequests = async (
};
export const publicProfileLoader = async ({ params }: LoaderFunctionArgs) => {
- const id = parseInt(params.id ?? '', 10);
- if (isNaN(id)) {
+ const username = params.username ?? '';
+ if (!username) {
throw new Response('User not found', { status: 404 });
}
- const user = await userService.getUserById(id);
+ const user = await userService.getUserByName(username);
// Only fetch events if profile is public (or the field is not set, for backwards compatibility)
- let events: ResUserPublicEvents = [];
+ let eventPage: ResUserPublicEventsPaginated = {
+ data: [],
+ pagination: { hasMore: false, nextCursor: null },
+ };
if (user.isProfilePublic !== false) {
- events = await userService.getUserEvents(id);
+ eventPage = await userService.getUserEventsByName({ username });
}
// Only fetch friendship status if user is logged in
@@ -54,13 +57,13 @@ export const publicProfileLoader = async ({ params }: LoaderFunctionArgs) => {
const token = useAuthStore.getState().token;
if (token) {
try {
- const status = await userService.getFriendshipStatus(id);
+ const status = await userService.getFriendshipStatus(user.id);
friendshipStatus = status;
// If there's a pending received request, fetch the request details to get the ID
if (status === 'pending_received') {
const incomingRequests = await fetchAllRequests(friendService.listIncomingRequests);
- const request = incomingRequests.find((req) => req.requesterId === id);
+ const request = incomingRequests.find((req) => req.requesterId === user.id);
if (request) {
friendRequestId = request.id;
}
@@ -69,7 +72,7 @@ export const publicProfileLoader = async ({ params }: LoaderFunctionArgs) => {
// If there's a pending sent request, fetch the request details to get the ID
if (status === 'pending_sent') {
const outgoingRequests = await fetchAllRequests(friendService.listOutgoingRequests);
- const request = outgoingRequests.find((req) => req.receiverId === id);
+ const request = outgoingRequests.find((req) => req.receiverId === user.id);
if (request) {
friendRequestId = request.id;
}
@@ -80,7 +83,7 @@ export const publicProfileLoader = async ({ params }: LoaderFunctionArgs) => {
}
}
- return { user, events, friendshipStatus, friendRequestId };
+ return { user, eventPage, friendshipStatus, friendRequestId };
};
export default function PublicProfilePage() {
@@ -205,7 +208,12 @@ export default function PublicProfilePage() {
void handleCancelRequest();
}}
/>
-
+
);
}
diff --git a/apps/frontend/src/pages/public-profile/components/ProfileTabs.tsx b/apps/frontend/src/pages/public-profile/components/ProfileTabs.tsx
index 1c5ce656..87815ebc 100644
--- a/apps/frontend/src/pages/public-profile/components/ProfileTabs.tsx
+++ b/apps/frontend/src/pages/public-profile/components/ProfileTabs.tsx
@@ -1,22 +1,52 @@
+import { useState } from 'react';
+import { useInfiniteScroll, type Pagination } from '@/hooks/useInfiniteScroll';
+import { userService } from '@/services/userService';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Heading, Text } from '@/components/ui/typography';
-import type { ResUserPublic, ResUserPublicEvents } from '@grit/schema';
+import type { ResUserPublic, ResUserPublicEvent } from '@grit/schema';
import { PublicEventCard } from './PublicEventCard';
+import { toast } from 'sonner';
interface ProfileTabsProps {
user: ResUserPublic;
- events: ResUserPublicEvents;
+ username: string;
+ initialEvents: ResUserPublicEvent[];
+ initialPagination: Pagination;
}
-export function ProfileTabs({ user, events }: ProfileTabsProps) {
+export function ProfileTabs({
+ user,
+ username,
+ initialEvents,
+ initialPagination,
+}: ProfileTabsProps) {
+ const [activeTab, setActiveTab] = useState('info');
+
+ const {
+ items: events,
+ sentinelRef,
+ isLoading,
+ } = useInfiniteScroll
(
+ initialEvents,
+ initialPagination,
+ async (cursor) => {
+ const result = await userService.getUserEventsByName({ username, cursor });
+ return result;
+ },
+ [activeTab], // recreate observer when tab becomes visible
+ () => {
+ toast.error('Failed to load more events');
+ }
+ );
+
return (
-
+
Info
- Events ({events.length})
+ Events {initialPagination.hasMore ? '' : `(${events.length})`}
@@ -38,7 +68,7 @@ export function ProfileTabs({ user, events }: ProfileTabsProps) {
- {events.length === 0 ? (
+ {events.length === 0 && !isLoading ? (
No public events hosted yet.
@@ -49,6 +79,10 @@ export function ProfileTabs({ user, events }: ProfileTabsProps) {
))}
)}
+
+ {isLoading && (
+ Loading more...
+ )}
);
diff --git a/apps/frontend/src/router.tsx b/apps/frontend/src/router.tsx
index 6a430b67..093217c3 100644
--- a/apps/frontend/src/router.tsx
+++ b/apps/frontend/src/router.tsx
@@ -67,7 +67,7 @@ export const router = createBrowserRouter([
path: 'users',
children: [
{
- path: ':id',
+ path: ':username',
Component: PublicProfilePage,
loader: publicProfileLoader,
handle: { title: 'Profile' },
diff --git a/apps/frontend/src/services/userService.ts b/apps/frontend/src/services/userService.ts
index 2dc66db4..48d44491 100644
--- a/apps/frontend/src/services/userService.ts
+++ b/apps/frontend/src/services/userService.ts
@@ -4,7 +4,7 @@ import {
ResMyEvents,
ResMyInvitedEvents,
ResUserPublicSchema,
- ResUserPublicEventsSchema,
+ ResUserPublicEventsPaginatedSchema,
ResFriendshipStatusSchema,
} from '@grit/schema';
import { useCurrentUserStore } from '@/store/currentUserStore';
@@ -113,14 +113,17 @@ export const userService = {
await api.delete('users/me');
},
- getUserById: async (id: number) => {
- const response = await api.get(`/users/${id}`);
+ getUserByName: async (username: string) => {
+ const response = await api.get(`/users/${username}`);
return ResUserPublicSchema.parse(response.data);
},
- getUserEvents: async (id: number) => {
- const response = await api.get(`/users/${id}/events`);
- return ResUserPublicEventsSchema.parse(response.data);
+ getUserEventsByName: async (params: { username: string; limit?: number; cursor?: string }) => {
+ const { username, limit = 12, cursor } = params;
+ const query = new URLSearchParams({ limit: String(limit) });
+ if (cursor) query.set('cursor', cursor);
+ const response = await api.get(`/users/${username}/events?${query.toString()}`);
+ return ResUserPublicEventsPaginatedSchema.parse(response.data);
},
getFriendshipStatus: async (id: number) => {
diff --git a/apps/frontend/tests/e2e/global-search.spec.ts b/apps/frontend/tests/e2e/global-search.spec.ts
index 06c7be06..aad821ec 100644
--- a/apps/frontend/tests/e2e/global-search.spec.ts
+++ b/apps/frontend/tests/e2e/global-search.spec.ts
@@ -45,7 +45,8 @@ const mockEvents = [
const mockUsers = [
{
id: 10,
- name: 'Alice Müller',
+ name: 'alice-abc',
+ displayName: 'Alice Müller',
avatarKey: null,
bio: null,
city: 'Berlin',
@@ -55,7 +56,8 @@ const mockUsers = [
},
{
id: 11,
- name: 'Bob Smith',
+ name: 'bob-xyz',
+ displayName: 'Bob Smith',
avatarKey: null,
bio: null,
city: null,
@@ -249,11 +251,13 @@ test.describe('Global Search', () => {
await page.route('**/api/events/berlin-techno-night-abc123', async (route) => {
await route.fulfill({ json: mockEvents[0] });
});
- await page.route('**/api/users/10', async (route) => {
+ await page.route('**/api/users/alice-abc', async (route) => {
await route.fulfill({ json: mockUsers[0] });
});
- await page.route('**/api/users/10/events', async (route) => {
- await route.fulfill({ json: [] });
+ await page.route('**/api/users/alice-abc/events', async (route) => {
+ await route.fulfill({
+ json: { data: [], pagination: { nextCursor: null, hasMore: false } },
+ });
});
await page.route('**/api/users/me/friends/status/10', async (route) => {
await route.fulfill({ json: { status: 'none' } });
@@ -296,7 +300,7 @@ test.describe('Global Search', () => {
await searchInput(page).fill('alice');
await expect(page.getByText('Alice Müller')).toBeVisible({ timeout: 2000 });
await page.getByText('Alice Müller').click();
- await expect(page).toHaveURL('/users/10');
+ await expect(page).toHaveURL('/users/alice-abc');
});
});
diff --git a/apps/frontend/tests/e2e/public-profile.spec.ts b/apps/frontend/tests/e2e/public-profile.spec.ts
index feb967a0..4af0cc52 100644
--- a/apps/frontend/tests/e2e/public-profile.spec.ts
+++ b/apps/frontend/tests/e2e/public-profile.spec.ts
@@ -7,7 +7,7 @@ test.describe('Public Profile', () => {
await route.fulfill({
json: {
id: 1,
- name: 'Test User',
+ name: 'test-user',
email: 'test@example.com',
avatarKey: null,
},
@@ -26,12 +26,12 @@ test.describe('Public Profile', () => {
});
test('should display public profile with user info', async ({ page }) => {
- // Mock user profile with bio and location
- await page.route(/\/api\/users\/2$/, async (route) => {
+ await page.route(/\/api\/users\/alice-johnson$/, async (route) => {
await route.fulfill({
json: {
id: 2,
- name: 'Alice Johnson',
+ name: 'alice-johnson',
+ displayName: 'Alice Johnson',
avatarKey: null,
bio: 'Event organizer',
city: 'New York',
@@ -41,15 +41,17 @@ test.describe('Public Profile', () => {
});
});
- await page.route(/\/api\/users\/2\/events/, async (route) => {
- await route.fulfill({ json: [] });
+ await page.route(/\/api\/users\/alice-johnson\/events/, async (route) => {
+ await route.fulfill({
+ json: { data: [], pagination: { nextCursor: null, hasMore: false } },
+ });
});
await page.route(/\/api\/users\/me\/friends\/status\/2/, async (route) => {
await route.fulfill({ json: { status: 'none' } });
});
- await page.goto('/users/2');
+ await page.goto('/users/alice-johnson');
await page.waitForLoadState('networkidle');
// Verify user info is displayed
@@ -63,11 +65,12 @@ test.describe('Public Profile', () => {
});
test('should show empty bio message when no bio', async ({ page }) => {
- await page.route(/\/api\/users\/3$/, async (route) => {
+ await page.route(/\/api\/users\/bob-smith$/, async (route) => {
await route.fulfill({
json: {
id: 3,
- name: 'Bob Smith',
+ name: 'bob-smith',
+ displayName: 'Bob Smith',
avatarKey: null,
bio: null,
city: null,
@@ -77,15 +80,17 @@ test.describe('Public Profile', () => {
});
});
- await page.route(/\/api\/users\/3\/events/, async (route) => {
- await route.fulfill({ json: [] });
+ await page.route(/\/api\/users\/bob-smith\/events/, async (route) => {
+ await route.fulfill({
+ json: { data: [], pagination: { nextCursor: null, hasMore: false } },
+ });
});
await page.route(/\/api\/users\/me\/friends\/status\/3/, async (route) => {
await route.fulfill({ json: { status: 'none' } });
});
- await page.goto('/users/3');
+ await page.goto('/users/bob-smith');
await page.waitForLoadState('networkidle');
await expect(page.locator('h2:has-text("Bob Smith")')).toBeVisible();
@@ -96,11 +101,12 @@ test.describe('Public Profile', () => {
});
test('should show hosted events', async ({ page }) => {
- await page.route(/\/api\/users\/4$/, async (route) => {
+ await page.route(/\/api\/users\/charlie-day$/, async (route) => {
await route.fulfill({
json: {
id: 4,
- name: 'Charlie Day',
+ name: 'charlie-day',
+ displayName: 'Charlie Day',
avatarKey: null,
bio: 'Party host',
city: 'Chicago',
@@ -110,29 +116,32 @@ test.describe('Public Profile', () => {
});
});
- await page.route(/\/api\/users\/4\/events/, async (route) => {
+ await page.route(/\/api\/users\/charlie-day\/events/, async (route) => {
await route.fulfill({
- json: [
- {
- id: 10,
- title: 'New Year Party',
- slug: 'new-year-party',
- startAt: '2026-12-31T21:00:00Z',
- imageKey: null,
- location: {
+ json: {
+ data: [
+ {
id: 10,
- authorId: 4,
- name: 'My House',
- address: '123 Main St',
- city: 'Chicago',
- country: 'USA',
- postalCode: '60601',
- isPublic: true,
- longitude: -87.6298,
- latitude: 41.8781,
+ title: 'New Year Party',
+ slug: 'new-year-party',
+ startAt: '2026-12-31T21:00:00Z',
+ imageKey: null,
+ location: {
+ id: 10,
+ authorId: 4,
+ name: 'My House',
+ address: '123 Main St',
+ city: 'Chicago',
+ country: 'USA',
+ postalCode: '60601',
+ isPublic: true,
+ longitude: -87.6298,
+ latitude: 41.8781,
+ },
},
- },
- ],
+ ],
+ pagination: { nextCursor: null, hasMore: false },
+ },
});
});
@@ -140,7 +149,7 @@ test.describe('Public Profile', () => {
await route.fulfill({ json: { status: 'none' } });
});
- await page.goto('/users/4');
+ await page.goto('/users/charlie-day');
await page.waitForLoadState('networkidle');
// Click on Events tab
diff --git a/apps/frontend/tests/features/GlobalSearch.test.tsx b/apps/frontend/tests/features/GlobalSearch.test.tsx
index 0fedbe08..996fba50 100644
--- a/apps/frontend/tests/features/GlobalSearch.test.tsx
+++ b/apps/frontend/tests/features/GlobalSearch.test.tsx
@@ -70,7 +70,8 @@ const mockEvents = [
const mockUsers = [
{
id: 10,
- name: 'Alice Müller',
+ name: 'alice-abc',
+ displayName: 'Alice Müller',
avatarKey: null,
bio: null,
city: 'Berlin',
@@ -80,7 +81,8 @@ const mockUsers = [
},
{
id: 11,
- name: 'Bob Smith',
+ name: 'bob-xyz',
+ displayName: 'Bob Smith',
avatarKey: null,
bio: null,
city: null,
@@ -115,7 +117,7 @@ function renderGlobalSearch() {
[
{ path: '/', Component: Wrapper },
{ path: '/events/:id', Component: () => Event Page
},
- { path: '/users/:id', Component: () => User Page
},
+ { path: '/users/:username', Component: () => User Page
},
],
{ initialEntries: ['/'] }
);
@@ -342,7 +344,7 @@ describe('GlobalSearch', () => {
});
await user.click(screen.getByText('Alice Müller'));
await waitFor(() => {
- expect(router.state.location.pathname).toBe('/users/10');
+ expect(router.state.location.pathname).toBe('/users/alice-abc');
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
});
});
diff --git a/packages/schema/src/user.ts b/packages/schema/src/user.ts
index b341323f..34369d1b 100644
--- a/packages/schema/src/user.ts
+++ b/packages/schema/src/user.ts
@@ -96,9 +96,16 @@ export const ResUserPublicEventSchema = z.object({
imageKey: z.string().nullable().optional(),
location: ResEventLocationSchema.nullable().optional(),
});
+export type ResUserPublicEvent = z.infer;
export const ResUserPublicEventsSchema = z.array(ResUserPublicEventSchema);
export type ResUserPublicEvents = z.infer;
+export const ResUserPublicEventsPaginatedSchema = z.object({
+ data: ResUserPublicEventsSchema,
+ pagination: z.object({ nextCursor: z.string().nullable(), hasMore: z.boolean() }),
+});
+export type ResUserPublicEventsPaginated = z.infer;
+
export const FriendshipStatusSchema = z.enum([
'none',
'pending_sent',