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
67 changes: 67 additions & 0 deletions apps/backend/src/user/tests/user.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,29 @@ const allUsers = [
{ id: 3, createdAt: mockDate, email: '[email protected]', 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),
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
33 changes: 19 additions & 14 deletions apps/backend/src/user/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
}
Expand Down
7 changes: 7 additions & 0 deletions apps/backend/src/user/user.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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) {}
82 changes: 70 additions & 12 deletions apps/backend/src/user/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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 },
};
}
}
4 changes: 2 additions & 2 deletions apps/frontend/src/features/chat/ChatBoxHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export const ChatBoxHeader = ({ conversation }: { conversation: ResConversationS
</div>
<div className="py-2 px-2.5 flex">
{isDirect && isValidOtherUser ? (
<Link to={`/users/${otherUser.id}`}>{avatarEl}</Link>
<Link to={`/users/${otherUser.name}`}>{avatarEl}</Link>
) : hasEvent ? (
<Link to={`/events/${eventSlug}`}>{avatarEl}</Link>
) : (
Expand All @@ -76,7 +76,7 @@ export const ChatBoxHeader = ({ conversation }: { conversation: ResConversationS
<div className="text-accent-foreground">
<div className="text-xs mt-0.5 mb-0.5">{conversation.type}</div>
{isDirect && isValidOtherUser ? (
<Link to={`/users/${otherUser.id}`} className="text-lg font-bold hover:underline">
<Link to={`/users/${otherUser.name}`} className="text-lg font-bold hover:underline">
{title}
</Link>
) : hasEvent ? (
Expand Down
4 changes: 2 additions & 2 deletions apps/frontend/src/features/chat/ChatBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const ChatBubble = ({ message }: { message: ResChatMessage }) => {
{isDeletedUser ? (
<UserAvatar user={author} size="xs" />
) : (
<Link to={`/users/${author.id}`}>
<Link to={`/users/${author.name}`}>
<UserAvatar user={author} size="xs" />
</Link>
)}
Expand All @@ -60,7 +60,7 @@ export const ChatBubble = ({ message }: { message: ResChatMessage }) => {
{isDeletedUser ? (
<span>{author.displayName ?? author.name}</span>
) : (
<Link to={`/users/${author.id}`} className="hover:underline">
<Link to={`/users/${author.name}`} className="hover:underline">
{author.displayName ?? author.name}
</Link>
)}
Expand Down
4 changes: 2 additions & 2 deletions apps/frontend/src/features/chat/ConversationCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export const ConversationCard = ({ conversation, isActive }: ConversationCardPro
)}
</div>
{isDirect && isValidOtherUser ? (
<Link to={`/users/${otherUser.id}`}>{avatarEl}</Link>
<Link to={`/users/${otherUser.name}`}>{avatarEl}</Link>
) : hasEvent ? (
<Link to={`/events/${eventSlug}`}>{avatarEl}</Link>
) : (
Expand All @@ -90,7 +90,7 @@ export const ConversationCard = ({ conversation, isActive }: ConversationCardPro
<div className="w-full">
{isDirect && isValidOtherUser ? (
<Link
to={`/users/${otherUser.id}`}
to={`/users/${otherUser.name}`}
className="font-medium flex items-center leading-tight hover:underline"
>
{title}
Expand Down
6 changes: 3 additions & 3 deletions apps/frontend/src/features/search/GlobalSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
});
};

Expand Down Expand Up @@ -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);
}}
>
<UserAvatar user={user} size="xs" className="shrink-0" />
Expand Down
2 changes: 1 addition & 1 deletion apps/frontend/src/pages/events/EventPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ export const EventPage = () => {

{event.author ? (
<Link
to={`/users/${event.author.id}`}
to={`/users/${event.author.name}`}
className="group min-w-0 max-w-full flex items-center gap-1.5 text-left cursor-pointer"
>
<Text className="text-lg truncate md:underline decoration-dashed underline-offset-4 group-hover:decoration-solid transition-all">
Expand Down
8 changes: 4 additions & 4 deletions apps/frontend/src/pages/my-friends/Page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ function FriendSearch({
actions={
<>
<Button variant="outline" size="sm" asChild>
<Link to={`/users/${user.id}`}>
<Link to={`/users/${user.name}`}>
<Eye className="h-4 w-4" />
</Link>
</Button>
Expand Down Expand Up @@ -328,7 +328,7 @@ function PendingSection({ requests, onAccept, onDecline }: PendingSectionProps)
actions={
<>
<Button variant="outline" size="sm" asChild>
<Link to={`/users/${req.requester.id}`}>
<Link to={`/users/${req.requester.name}`}>
<Eye className="h-4 w-4" />
</Link>
</Button>
Expand Down Expand Up @@ -365,7 +365,7 @@ function OutgoingSection({ requests, onCancel }: OutgoingSectionProps) {
actions={
<>
<Button variant="outline" size="sm" asChild>
<Link to={`/users/${req.receiver.id}`}>
<Link to={`/users/${req.receiver.name}`}>
<Eye className="h-4 w-4" />
</Link>
</Button>
Expand Down Expand Up @@ -469,7 +469,7 @@ function FriendsSection({
actions={
<>
<Button variant="outline" size="sm" asChild>
<Link to={`/users/${friend.friend.id}`}>
<Link to={`/users/${friend.friend.name}`}>
<Eye className="h-4 w-4" />
</Link>
</Button>
Expand Down
Loading
Loading