From acc8f35682c84c424793fd6fe7a4b72c3458d2ac Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Mon, 10 Aug 2026 01:02:47 +0330 Subject: [PATCH] Improve cross-site customer signup and login membership. Same-password register joins silently; mismatched password explains the existing Meshkee account; login/OTP with domain attaches business membership. Co-authored-by: Cursor --- src/auth/auth.controller.ts | 2 +- src/auth/auth.service.ts | 98 +++++++++++++++++++++++++++++------ src/auth/dto/login-otp.dto.ts | 7 ++- src/auth/dto/login.dto.ts | 7 ++- 4 files changed, 96 insertions(+), 18 deletions(-) diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 2971014..a3b2b95 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -43,7 +43,7 @@ export class AuthController { @Post('login-otp') loginWithOtp(@Body() dto: LoginOtpDto) { - return this.authService.loginWithOtp(dto.cellNumber, dto.code); + return this.authService.loginWithOtp(dto.cellNumber, dto.code, dto.domain); } @Post('reset-password') diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 5102bc9..726efb5 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -57,16 +57,41 @@ export class AuthService { const existingUser = await this.prisma.user.findUnique({ where: { cellNumber: dto.cellNumber }, include: { - businessCustomers: { where: { businessId: business.id } }, + businessCustomers: { + include: { business: { select: { id: true, name: true, nameFa: true } } }, + }, }, }); - if (existingUser?.businessCustomers.length) { + const alreadyOnThisSite = existingUser?.businessCustomers.some( + (item) => item.businessId === business.id, + ); + + if (alreadyOnThisSite) { throw new ConflictException( 'This cell number is already registered on this website', ); } + if (existingUser) { + const passwordValid = await bcrypt.compare( + dto.password, + existingUser.passwordHash, + ); + + if (!passwordValid) { + const otherNames = existingUser.businessCustomers + .map((item) => item.business.nameFa?.trim() || item.business.name) + .filter(Boolean); + const siteList = + otherNames.length > 0 ? otherNames.join(', ') : 'another Meshkee website'; + + throw new ConflictException( + `CELL_EXISTS_OTHER_SITE:${siteList}`, + ); + } + } + const user = await this.prisma.$transaction(async (tx) => { const account = existingUser ?? @@ -81,18 +106,6 @@ export class AuthService { }, })); - if (existingUser) { - const passwordValid = await bcrypt.compare( - dto.password, - existingUser.passwordHash, - ); - if (!passwordValid) { - throw new ConflictException( - 'Cell number exists on another account. Use login or reset password.', - ); - } - } - await tx.businessCustomer.create({ data: { businessId: business.id, @@ -159,6 +172,10 @@ export class AuthService { ); } + if (dto.domain?.trim()) { + await this.ensureCustomerMembershipForDomain(user.id, dto.domain.trim()); + } + await this.prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() }, @@ -340,9 +357,13 @@ export class AuthService { }; } - async loginWithOtp(cellNumber: string, code: string) { + async loginWithOtp(cellNumber: string, code: string, domain?: string) { const user = await this.consumeOtp(cellNumber, code); + if (domain?.trim()) { + await this.ensureCustomerMembershipForDomain(user.id, domain.trim()); + } + await this.prisma.user.update({ where: { id: user.id }, data: { @@ -401,6 +422,53 @@ export class AuthService { return user; } + private async ensureCustomerMembershipForDomain( + userId: bigint, + domain: string, + ) { + const business = await this.tenant.resolveBusinessByDomain(domain); + + const existing = await this.prisma.businessCustomer.findUnique({ + where: { + businessId_userId: { businessId: business.id, userId }, + }, + }); + + if (!existing) { + await this.prisma.businessCustomer.create({ + data: { + businessId: business.id, + userId, + }, + }); + } + + const customerRole = await this.prisma.role.findUnique({ + where: { slug: 'customer' }, + }); + if (!customerRole) { + return; + } + + const hasCustomerRole = await this.prisma.userRole.findUnique({ + where: { + userId_roleId: { + userId, + roleId: customerRole.id, + }, + }, + }); + + if (!hasCustomerRole) { + await this.prisma.userRole.create({ + data: { + userId, + roleId: customerRole.id, + }, + }); + } + } + private async getAuthUser(userId: bigint): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId }, diff --git a/src/auth/dto/login-otp.dto.ts b/src/auth/dto/login-otp.dto.ts index f188de9..539a499 100644 --- a/src/auth/dto/login-otp.dto.ts +++ b/src/auth/dto/login-otp.dto.ts @@ -1,4 +1,4 @@ -import { IsString, Length, Matches } from 'class-validator'; +import { IsOptional, IsString, Length, Matches } from 'class-validator'; export class LoginOtpDto { @IsString() @@ -11,4 +11,9 @@ export class LoginOtpDto { @Length(6, 6) @Matches(/^\d{6}$/, { message: 'code must be a 6-digit number' }) code!: string; + + /** Website apex — when set, attaches customer membership for that tenant on successful OTP login. */ + @IsOptional() + @IsString() + domain?: string; } diff --git a/src/auth/dto/login.dto.ts b/src/auth/dto/login.dto.ts index 1ae66ea..9630cfa 100644 --- a/src/auth/dto/login.dto.ts +++ b/src/auth/dto/login.dto.ts @@ -1,4 +1,4 @@ -import { IsString, Matches, MinLength } from 'class-validator'; +import { IsOptional, IsString, Matches, MinLength } from 'class-validator'; export class LoginDto { @IsString() @@ -10,4 +10,9 @@ export class LoginDto { @IsString() @MinLength(8) password!: string; + + /** Website apex — when set, attaches customer membership for that tenant on successful login. */ + @IsOptional() + @IsString() + domain?: string; }