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
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ export class NotificationService {
return {
title: "New follower",
body: `${username} started following you.`,
url: null,
url: username === "Someone" ? null : `/u/${username}`,
};
case NotificationType.TAGGED_PRODUCT_SOLD_VIA_POST:
return {
Expand Down
17 changes: 15 additions & 2 deletions apps/backend/src/domains/users/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,8 +268,21 @@ export class AuthController {

@Post("email/verify")
@HttpCode(HttpStatus.OK)
async verifyEmail(@Body() dto: VerifyEmailDto) {
return this.authService.verifyEmail(dto);
async verifyEmail(
@Body() dto: VerifyEmailDto,
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
) {
const result = await this.authService.verifyEmail(
dto,
this.getSessionMetadata(request),
);
this.setAuthCookies(response, result);

return this.successEnvelope({
verified: result.verified,
redirectTo: result.redirectTo,
});
}

@Throttle({ default: { limit: 3, ttl: 3_600_000 } })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,44 @@ jest.mock("bcrypt", () => ({

describe("AuthService email registration", () => {
const prisma = {
$transaction: jest.fn(),
user: {
findFirst: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
create: jest.fn(),
},
session: {
create: jest.fn(),
},
storeProfile: {
findUnique: jest.fn(),
},
oTPCode: {
updateMany: jest.fn(),
update: jest.fn(),
findFirst: jest.fn(),
create: jest.fn(),
},
};
const resendClient = {
sendEmail: jest.fn(),
};
const jwtService = {
signAsync: jest.fn(),
};
const verificationService = {
tryUpgradeTierForUser: jest.fn(),
};

type AuthServiceTestHarness = {
prisma: typeof prisma;
resendClient: typeof resendClient;
jwtService: typeof jwtService;
verificationService: typeof verificationService;
otpSecret: string;
accessTokenSecret: string;
refreshTokenSecret: string;
};

const baseDto = {
Expand All @@ -50,7 +67,11 @@ describe("AuthService email registration", () => {
Object.assign(service as unknown as AuthServiceTestHarness, {
prisma,
resendClient,
jwtService,
verificationService,
otpSecret: "otp-secret",
accessTokenSecret: "access-secret",
refreshTokenSecret: "refresh-secret",
});

(bcrypt.hash as jest.Mock).mockResolvedValue("hashed-password");
Expand All @@ -68,7 +89,16 @@ describe("AuthService email registration", () => {
);
prisma.storeProfile.findUnique.mockResolvedValue(null);
prisma.oTPCode.updateMany.mockResolvedValue({ count: 0 });
prisma.oTPCode.update.mockResolvedValue({});
prisma.oTPCode.findFirst.mockResolvedValue(null);
prisma.oTPCode.create.mockResolvedValue({});
prisma.user.update.mockResolvedValue({});
prisma.session.create.mockResolvedValue({});
prisma.$transaction.mockResolvedValue([]);
jwtService.signAsync
.mockResolvedValueOnce("access-token")
.mockResolvedValueOnce("refresh-token");
verificationService.tryUpgradeTierForUser.mockResolvedValue(undefined);
resendClient.sendEmail.mockResolvedValue({});
});

Expand Down Expand Up @@ -209,4 +239,69 @@ describe("AuthService email registration", () => {
}),
});
});

it("issues auth tokens after successful email verification", async () => {
const code = "123456";
const user = {
id: "user-1",
email: baseDto.email,
phone: baseDto.phone,
username: "user123456",
displayName: "Ada",
dateOfBirth: baseDto.dateOfBirth,
firstName: "Ada",
lastName: "Lovelace",
passwordHash: "hashed-password",
role: UserRole.USER,
emailVerified: false,
phoneVerified: false,
isActive: true,
deletedAt: null,
storeProfile: null,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
};
const otpSecret = (service as unknown as AuthServiceTestHarness).otpSecret;
const codeHash = await (
service as unknown as {
hashOtp: (
code: string,
purpose: OTPPurpose,
identifier: string,
) => string;
}
).hashOtp(code, OTPPurpose.EMAIL_VERIFY, baseDto.email);

prisma.user.findUnique.mockResolvedValueOnce(user);
prisma.oTPCode.findFirst.mockResolvedValueOnce({
id: "otp-1",
codeHash,
attempts: 0,
});

const result = await service.verifyEmail(
{ email: baseDto.email, code },
{ userAgent: "jest", ipAddress: "127.0.0.1" },
);

expect(otpSecret).toBe("otp-secret");
expect(result).toEqual({
accessToken: "access-token",
refreshToken: "refresh-token",
verified: true,
redirectTo: "/explore",
});
expect(jwtService.signAsync).toHaveBeenCalledTimes(2);
expect(prisma.session.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: user.id,
refreshTokenHash: expect.any(String),
userAgent: "jest",
ipAddress: "127.0.0.1",
expiresAt: expect.any(Date),
}),
});
expect(verificationService.tryUpgradeTierForUser).toHaveBeenCalledWith(
user.id,
);
});
});
28 changes: 26 additions & 2 deletions apps/backend/src/domains/users/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,9 +467,17 @@ export class AuthService {
};
}

async verifyEmail(dto: VerifyEmailDto): Promise<{ verified: true }> {
async verifyEmail(
dto: VerifyEmailDto,
metadata: SessionMetadata,
): Promise<AuthCookieTokens & { verified: true; redirectTo: string }> {
const user = await this.prisma.user.findUnique({
where: { email: dto.email },
include: {
storeProfile: {
select: { id: true },
},
},
});

if (!user) {
Expand Down Expand Up @@ -525,7 +533,23 @@ export class AuthService {
);
});

return { verified: true };
const tokens = await this.createSessionTokens({
user: {
id: user.id,
email: user.email,
role: user.role,
storeId: user.storeProfile?.id,
},
metadata,
});

return {
...tokens,
verified: true,
redirectTo: user.storeProfile
? REDIRECT_TARGET.STORE_DASHBOARD
: REDIRECT_TARGET.EXPLORE,
};
}

async resendEmailVerification(
Expand Down
24 changes: 24 additions & 0 deletions apps/backend/src/domains/users/user/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,30 @@ export class UserController {
return this.userService.unfollowUser(user.id, userId);
}

@UseGuards(JwtAuthGuard)
@Get(":userId/follow-status")
getFollowStatus(
@CurrentUser() user: AuthenticatedRequestUser,
@Param("userId") userId: string,
) {
return this.userService.getFollowStatus(user.id, userId);
}

@Get(":username/followers")
listFollowers(@Param("username") username: string) {
return this.userService.listFollowers(username);
}

@Get(":username/following")
listFollowing(@Param("username") username: string) {
return this.userService.listFollowing(username);
}

@Get(":username/verified-followers")
listVerifiedFollowers(@Param("username") username: string) {
return this.userService.listVerifiedFollowers(username);
}

@Get(":username")
getPublicProfile(@Param("username") username: string) {
return this.userService.getPublicProfile(username);
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/domains/users/user/user.module.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Module } from "@nestjs/common";

import { NotificationModule } from "../../social/notification/notification.module";
import { UserController } from "./user.controller";
import { UserService } from "./user.service";

@Module({
imports: [NotificationModule],
controllers: [UserController],
providers: [UserService],
exports: [UserService],
Expand Down
Loading
Loading