mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
Initial commit: Meshkee CMS API
NestJS backend with Prisma, Docker Compose for Postgres/Redis, and deploy docs for the production VM.
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { RedisModule } from './redis/redis.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { BusinessTeamModule } from './business-team/business-team.module';
|
||||
import { BusinessAdminModule } from './business-admin/business-admin.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { RolesModule } from './roles/roles.module';
|
||||
import { TenantModule } from './tenant/tenant.module';
|
||||
import { StorageModule } from './storage/storage.module';
|
||||
import { MediaModule } from './media/media.module';
|
||||
import { DomainAdminModule } from './domain-admin/domain-admin.module';
|
||||
import { CategoriesModule } from './categories/categories.module';
|
||||
import { ProductsModule } from './products/products.module';
|
||||
import { BlogsModule } from './blogs/blogs.module';
|
||||
import { PortfoliosModule } from './portfolios/portfolios.module';
|
||||
import { CommentsModule } from './comments/comments.module';
|
||||
import { CitiesModule } from './cities/cities.module';
|
||||
import { ExpertReviewsModule } from './expert-reviews/expert-reviews.module';
|
||||
import { BusinessSettingsModule } from './business-settings/business-settings.module';
|
||||
import { BusinessProfileModule } from './business-profile/business-profile.module';
|
||||
import { StoreModule } from './store/store.module';
|
||||
import { CartModule } from './cart/cart.module';
|
||||
import { OrdersModule } from './orders/orders.module';
|
||||
import { CustomersModule } from './customers/customers.module';
|
||||
import { ShoppingCardsModule } from './shopping-cards/shopping-cards.module';
|
||||
import { ContactSubmissionsModule } from './contact-submissions/contact-submissions.module';
|
||||
import { FavoritesModule } from './favorites/favorites.module';
|
||||
import { BrandsModule } from './brands/brands.module';
|
||||
import { WebsiteModule } from './website/website.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
PrismaModule,
|
||||
RedisModule,
|
||||
AuthModule,
|
||||
BusinessTeamModule,
|
||||
BusinessAdminModule,
|
||||
UsersModule,
|
||||
RolesModule,
|
||||
TenantModule,
|
||||
StorageModule,
|
||||
MediaModule,
|
||||
DomainAdminModule,
|
||||
CategoriesModule,
|
||||
ProductsModule,
|
||||
BlogsModule,
|
||||
PortfoliosModule,
|
||||
CommentsModule,
|
||||
ExpertReviewsModule,
|
||||
BusinessSettingsModule,
|
||||
BusinessProfileModule,
|
||||
CitiesModule,
|
||||
StoreModule,
|
||||
CartModule,
|
||||
OrdersModule,
|
||||
CustomersModule,
|
||||
ShoppingCardsModule,
|
||||
ContactSubmissionsModule,
|
||||
FavoritesModule,
|
||||
BrandsModule,
|
||||
WebsiteModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { CurrentUser } from './decorators/current-user.decorator';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { SendOtpDto } from './dto/send-otp.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { UpsertUserAddressDto } from './dto/upsert-user-address.dto';
|
||||
import { VerifyOtpDto } from './dto/verify-otp.dto';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { AuthUser } from './auth.types';
|
||||
import { UserAddressesService } from './user-addresses.service';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly userAddresses: UserAddressesService,
|
||||
) {}
|
||||
|
||||
@Post('register')
|
||||
register(@Body() dto: RegisterDto) {
|
||||
return this.authService.register(dto);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.authService.login(dto);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
refresh(@Body() dto: RefreshTokenDto) {
|
||||
return this.authService.refresh(dto.refreshToken);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
me(@CurrentUser() user: AuthUser) {
|
||||
return this.authService.me(user);
|
||||
}
|
||||
|
||||
@Patch('profile')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
updateProfile(@CurrentUser() user: AuthUser, @Body() dto: UpdateProfileDto) {
|
||||
return this.authService.updateProfile(user, dto);
|
||||
}
|
||||
|
||||
@Post('change-password')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
changePassword(@CurrentUser() user: AuthUser, @Body() dto: ChangePasswordDto) {
|
||||
return this.authService.changePassword(user, dto);
|
||||
}
|
||||
|
||||
@Post('send-otp')
|
||||
sendOtp(@Body() dto: SendOtpDto) {
|
||||
return this.authService.sendOtp(dto.cellNumber);
|
||||
}
|
||||
|
||||
@Post('verify-otp')
|
||||
verifyOtp(@Body() dto: VerifyOtpDto) {
|
||||
return this.authService.verifyOtp(dto.cellNumber, dto.code);
|
||||
}
|
||||
|
||||
@Get('addresses')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
listAddresses(@CurrentUser() user: AuthUser) {
|
||||
return this.userAddresses.list(user);
|
||||
}
|
||||
|
||||
@Post('addresses')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
createAddress(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() dto: UpsertUserAddressDto,
|
||||
) {
|
||||
return this.userAddresses.create(user, dto);
|
||||
}
|
||||
|
||||
@Patch('addresses/:addressId')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
updateAddress(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('addressId') addressId: string,
|
||||
@Body() dto: UpsertUserAddressDto,
|
||||
) {
|
||||
return this.userAddresses.update(user, addressId, dto);
|
||||
}
|
||||
|
||||
@Delete('addresses/:addressId')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
removeAddress(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('addressId') addressId: string,
|
||||
) {
|
||||
return this.userAddresses.remove(user, addressId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { TenantModule } from '../tenant/tenant.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { BusinessPermissionGuard } from './guards/business-permission.guard';
|
||||
import { PermissionsService } from './permissions.service';
|
||||
import { SmsService } from './sms.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { UserAddressesService } from './user-addresses.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PrismaModule,
|
||||
TenantModule,
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: config.get<string>('JWT_ACCESS_EXPIRES_IN', '15m') as `${number}${'s' | 'm' | 'h' | 'd'}`,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
UserAddressesService,
|
||||
SmsService,
|
||||
PermissionsService,
|
||||
BusinessPermissionGuard,
|
||||
JwtStrategy,
|
||||
],
|
||||
exports: [AuthService, PermissionsService, BusinessPermissionGuard, SmsService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,467 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
ServiceUnavailableException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
import {
|
||||
AuthJwtPayload,
|
||||
AuthUser,
|
||||
DashboardType,
|
||||
UserProfile,
|
||||
resolvePrimaryRole,
|
||||
} from './auth.types';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { PermissionsService } from './permissions.service';
|
||||
import { parseUserProfile } from './profile.util';
|
||||
import { SmsService } from './sms.service';
|
||||
|
||||
const OTP_TTL_SECONDS = 300;
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly redis: RedisService,
|
||||
private readonly sms: SmsService,
|
||||
private readonly tenant: TenantService,
|
||||
private readonly permissions: PermissionsService,
|
||||
) {}
|
||||
|
||||
async register(dto: RegisterDto) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(dto.domain);
|
||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
const smsEnabled = this.sms.isEnabled();
|
||||
|
||||
const customerRole = await this.prisma.role.findUnique({
|
||||
where: { slug: 'customer' },
|
||||
});
|
||||
|
||||
if (!customerRole) {
|
||||
throw new Error('Customer role is missing. Run database migrations first.');
|
||||
}
|
||||
|
||||
const existingUser = await this.prisma.user.findUnique({
|
||||
where: { cellNumber: dto.cellNumber },
|
||||
include: {
|
||||
businessCustomers: { where: { businessId: business.id } },
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser?.businessCustomers.length) {
|
||||
throw new ConflictException(
|
||||
'This cell number is already registered on this website',
|
||||
);
|
||||
}
|
||||
|
||||
const user = await this.prisma.$transaction(async (tx) => {
|
||||
const account =
|
||||
existingUser ??
|
||||
(await tx.user.create({
|
||||
data: {
|
||||
cellNumber: dto.cellNumber,
|
||||
passwordHash,
|
||||
email: dto.email,
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
cellVerifiedAt: smsEnabled ? null : new Date(),
|
||||
},
|
||||
}));
|
||||
|
||||
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,
|
||||
userId: account.id,
|
||||
},
|
||||
});
|
||||
|
||||
const hasCustomerRole = await tx.userRole.findUnique({
|
||||
where: {
|
||||
userId_roleId: {
|
||||
userId: account.id,
|
||||
roleId: customerRole.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasCustomerRole) {
|
||||
await tx.userRole.create({
|
||||
data: {
|
||||
userId: account.id,
|
||||
roleId: customerRole.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return account;
|
||||
});
|
||||
|
||||
const authUser = await this.getAuthUser(user.id);
|
||||
const tokens = await this.issueTokens(authUser);
|
||||
|
||||
return {
|
||||
message: smsEnabled
|
||||
? 'Registration successful. Please verify your cell number with OTP.'
|
||||
: 'Registration successful. SMS verification is disabled — account auto-verified.',
|
||||
smsEnabled,
|
||||
user: this.serializeUser(authUser),
|
||||
registeredBusiness: {
|
||||
id: business.id,
|
||||
name: business.name,
|
||||
slug: business.slug,
|
||||
},
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
async login(dto: LoginDto) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { cellNumber: dto.cellNumber },
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException('Invalid cell number or password');
|
||||
}
|
||||
|
||||
const passwordValid = await bcrypt.compare(dto.password, user.passwordHash);
|
||||
if (!passwordValid) {
|
||||
throw new UnauthorizedException('Invalid cell number or password');
|
||||
}
|
||||
|
||||
if (this.sms.isEnabled() && !user.cellVerifiedAt) {
|
||||
throw new UnauthorizedException(
|
||||
'Cell number is not verified. Please complete OTP verification.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
|
||||
const authUser = await this.getAuthUser(user.id);
|
||||
const tokens = await this.issueTokens(authUser);
|
||||
|
||||
return {
|
||||
message: 'Login successful',
|
||||
user: this.serializeUser(authUser),
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string) {
|
||||
let payload: AuthJwtPayload;
|
||||
|
||||
try {
|
||||
payload = await this.jwt.verifyAsync<AuthJwtPayload>(refreshToken, {
|
||||
secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'),
|
||||
});
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid or expired refresh token');
|
||||
}
|
||||
|
||||
if (payload.type !== 'refresh') {
|
||||
throw new UnauthorizedException('Invalid token type');
|
||||
}
|
||||
|
||||
const authUser = await this.getAuthUser(BigInt(payload.sub));
|
||||
const tokens = await this.issueTokens(authUser);
|
||||
|
||||
return {
|
||||
message: 'Token refreshed',
|
||||
user: this.serializeUser(authUser),
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
async me(user: AuthUser) {
|
||||
return { user: this.serializeUser(user) };
|
||||
}
|
||||
|
||||
async updateProfile(user: AuthUser, dto: UpdateProfileDto) {
|
||||
const currentProfile = user.profile;
|
||||
const nextProfile: UserProfile = {
|
||||
...currentProfile,
|
||||
about: dto.about ?? currentProfile.about,
|
||||
city: dto.city ?? currentProfile.city,
|
||||
address: dto.address ?? currentProfile.address,
|
||||
landline: dto.landline ?? currentProfile.landline,
|
||||
backupPhone: dto.backupPhone ?? currentProfile.backupPhone,
|
||||
postalCode: dto.postalCode ?? currentProfile.postalCode,
|
||||
instagram: dto.instagram ?? currentProfile.instagram,
|
||||
telegramId: dto.telegramId ?? currentProfile.telegramId,
|
||||
linkedin: dto.linkedin ?? currentProfile.linkedin,
|
||||
};
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
firstName: dto.firstName ?? user.firstName,
|
||||
lastName: dto.lastName ?? user.lastName,
|
||||
email: dto.email ?? user.email,
|
||||
profile: nextProfile as object,
|
||||
},
|
||||
});
|
||||
|
||||
const authUser = await this.getAuthUser(user.id);
|
||||
|
||||
return {
|
||||
message: 'Profile updated successfully',
|
||||
user: this.serializeUser(authUser),
|
||||
};
|
||||
}
|
||||
|
||||
async changePassword(user: AuthUser, dto: ChangePasswordDto) {
|
||||
const account = await this.prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
const passwordValid = await bcrypt.compare(
|
||||
dto.currentPassword,
|
||||
account.passwordHash,
|
||||
);
|
||||
|
||||
if (!passwordValid) {
|
||||
throw new BadRequestException('Current password is incorrect');
|
||||
}
|
||||
|
||||
if (dto.currentPassword === dto.newPassword) {
|
||||
throw new BadRequestException(
|
||||
'New password must be different from the current password',
|
||||
);
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.newPassword, 10);
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { passwordHash },
|
||||
});
|
||||
|
||||
return { message: 'Password changed successfully' };
|
||||
}
|
||||
|
||||
async sendOtp(cellNumber: string) {
|
||||
if (!this.sms.isEnabled()) {
|
||||
return {
|
||||
enabled: false,
|
||||
message:
|
||||
'SMS verification is currently disabled. Register and login work without OTP.',
|
||||
};
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { cellNumber },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Cell number is not registered');
|
||||
}
|
||||
|
||||
const code = this.generateOtpCode();
|
||||
await this.redis.setOtp(cellNumber, code, OTP_TTL_SECONDS);
|
||||
|
||||
try {
|
||||
await this.sms.sendVerificationCode(cellNumber, code);
|
||||
} catch {
|
||||
throw new ServiceUnavailableException(
|
||||
'SMS provider is not configured yet',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
message: 'Verification code sent',
|
||||
expiresInSeconds: OTP_TTL_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyOtp(cellNumber: string, code: string) {
|
||||
if (!this.sms.isEnabled()) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { cellNumber },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Cell number is not registered');
|
||||
}
|
||||
|
||||
if (!user.cellVerifiedAt) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { cellVerifiedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: false,
|
||||
verified: true,
|
||||
message: 'SMS verification is disabled — cell number marked as verified.',
|
||||
};
|
||||
}
|
||||
|
||||
const storedCode = await this.redis.getOtp(cellNumber);
|
||||
if (!storedCode || storedCode !== code) {
|
||||
throw new UnauthorizedException('Invalid or expired verification code');
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { cellNumber },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Cell number is not registered');
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { cellVerifiedAt: new Date() },
|
||||
});
|
||||
await this.redis.deleteOtp(cellNumber);
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
verified: true,
|
||||
message: 'Cell number verified successfully',
|
||||
};
|
||||
}
|
||||
|
||||
private async getAuthUser(userId: bigint): Promise<AuthUser> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
userRoles: { include: { role: true } },
|
||||
businessUsers: { include: { business: true } },
|
||||
businessCustomers: { include: { business: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
const roles = user.userRoles.map((ur) => ur.role.slug);
|
||||
const businesses = await this.permissions.getBusinessMemberships(user.id);
|
||||
const customerBusinesses = user.businessCustomers.map((bc) => ({
|
||||
id: bc.business.id,
|
||||
name: bc.business.name,
|
||||
slug: bc.business.slug,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
cellNumber: user.cellNumber,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
cellVerifiedAt: user.cellVerifiedAt,
|
||||
roles,
|
||||
dashboard: this.resolveDashboard(roles, businesses.length),
|
||||
profile: parseUserProfile(user.profile),
|
||||
businesses,
|
||||
customerBusinesses,
|
||||
};
|
||||
}
|
||||
|
||||
private resolveDashboard(roles: string[], businessCount: number): DashboardType {
|
||||
if (roles.includes('super_admin')) {
|
||||
return 'super_admin';
|
||||
}
|
||||
if (
|
||||
roles.includes('business_owner') ||
|
||||
roles.includes('business_staff') ||
|
||||
roles.includes('owner') ||
|
||||
businessCount > 0
|
||||
) {
|
||||
return 'business';
|
||||
}
|
||||
return 'customer';
|
||||
}
|
||||
|
||||
private async issueTokens(user: AuthUser) {
|
||||
const accessPayload: AuthJwtPayload = {
|
||||
sub: user.id.toString(),
|
||||
cellNumber: user.cellNumber,
|
||||
roles: user.roles,
|
||||
dashboard: user.dashboard,
|
||||
type: 'access',
|
||||
};
|
||||
|
||||
const refreshPayload: AuthJwtPayload = {
|
||||
sub: user.id.toString(),
|
||||
cellNumber: user.cellNumber,
|
||||
roles: user.roles,
|
||||
dashboard: user.dashboard,
|
||||
type: 'refresh',
|
||||
};
|
||||
|
||||
const accessExpiresIn = this.config.get<string>('JWT_ACCESS_EXPIRES_IN', '15m');
|
||||
const refreshExpiresIn = this.config.get<string>('JWT_REFRESH_EXPIRES_IN', '7d');
|
||||
|
||||
const [accessToken, refreshToken] = await Promise.all([
|
||||
this.jwt.signAsync(accessPayload, {
|
||||
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||
expiresIn: accessExpiresIn as `${number}${'s' | 'm' | 'h' | 'd'}`,
|
||||
}),
|
||||
this.jwt.signAsync(refreshPayload, {
|
||||
secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'),
|
||||
expiresIn: refreshExpiresIn as `${number}${'s' | 'm' | 'h' | 'd'}`,
|
||||
}),
|
||||
]);
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
private serializeUser(user: AuthUser) {
|
||||
const primaryRole = resolvePrimaryRole(user.roles);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
cellNumber: user.cellNumber,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
cellVerifiedAt: user.cellVerifiedAt,
|
||||
roles: user.roles,
|
||||
dashboard: user.dashboard,
|
||||
primaryRole: primaryRole.slug,
|
||||
roleLabel: primaryRole.label,
|
||||
isSuperAdmin: primaryRole.isSuperAdmin,
|
||||
profile: user.profile,
|
||||
businesses: user.businesses,
|
||||
customerBusinesses: user.customerBusinesses,
|
||||
};
|
||||
}
|
||||
|
||||
private generateOtpCode(): string {
|
||||
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
export type DashboardType = 'super_admin' | 'business' | 'customer';
|
||||
|
||||
export interface AuthJwtPayload {
|
||||
sub: string;
|
||||
cellNumber: string;
|
||||
roles: string[];
|
||||
dashboard: DashboardType;
|
||||
type: 'access' | 'refresh';
|
||||
}
|
||||
|
||||
export interface BusinessMembership {
|
||||
id: bigint;
|
||||
name: string;
|
||||
slug: string;
|
||||
isOwner: boolean;
|
||||
teamRole: string | null;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
about: string;
|
||||
city: string;
|
||||
address: string;
|
||||
landline: string;
|
||||
backupPhone: string;
|
||||
postalCode: string;
|
||||
instagram: string;
|
||||
telegramId: string;
|
||||
linkedin: string;
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: bigint;
|
||||
cellNumber: string;
|
||||
email: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
cellVerifiedAt: Date | null;
|
||||
roles: string[];
|
||||
dashboard: DashboardType;
|
||||
profile: UserProfile;
|
||||
businesses: BusinessMembership[];
|
||||
customerBusinesses: { id: bigint; name: string; slug: string }[];
|
||||
}
|
||||
|
||||
/** Roles a business owner can assign to team members */
|
||||
export const ASSIGNABLE_TEAM_ROLES = ['admin', 'editor', 'viewer'] as const;
|
||||
export type AssignableTeamRole = (typeof ASSIGNABLE_TEAM_ROLES)[number];
|
||||
|
||||
/** Global roles a super admin can assign to users */
|
||||
export const ASSIGNABLE_GLOBAL_ROLES = [
|
||||
'super_admin',
|
||||
'business_owner',
|
||||
'customer',
|
||||
] as const;
|
||||
export type AssignableGlobalRole = (typeof ASSIGNABLE_GLOBAL_ROLES)[number];
|
||||
|
||||
export const ROLE_LABELS: Record<string, string> = {
|
||||
super_admin: 'Super Admin',
|
||||
business_owner: 'Business Owner',
|
||||
business_staff: 'Business Staff',
|
||||
customer: 'Customer',
|
||||
owner: 'Business Owner',
|
||||
admin: 'Admin',
|
||||
editor: 'Editor',
|
||||
viewer: 'Viewer',
|
||||
};
|
||||
|
||||
/** Highest global role for UI display (badge in header). */
|
||||
export function resolvePrimaryRole(roles: string[]): {
|
||||
slug: string;
|
||||
label: string;
|
||||
isSuperAdmin: boolean;
|
||||
} {
|
||||
if (roles.includes('super_admin')) {
|
||||
return { slug: 'super_admin', label: ROLE_LABELS.super_admin, isSuperAdmin: true };
|
||||
}
|
||||
if (roles.includes('business_owner') || roles.includes('owner')) {
|
||||
return {
|
||||
slug: 'business_owner',
|
||||
label: ROLE_LABELS.business_owner,
|
||||
isSuperAdmin: false,
|
||||
};
|
||||
}
|
||||
if (roles.includes('business_staff')) {
|
||||
return {
|
||||
slug: 'business_staff',
|
||||
label: ROLE_LABELS.business_staff,
|
||||
isSuperAdmin: false,
|
||||
};
|
||||
}
|
||||
if (roles.includes('customer')) {
|
||||
return { slug: 'customer', label: ROLE_LABELS.customer, isSuperAdmin: false };
|
||||
}
|
||||
|
||||
const slug = roles[0] ?? 'customer';
|
||||
return { slug, label: ROLE_LABELS[slug] ?? 'User', isSuperAdmin: false };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { AuthUser } from '../auth.types';
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): AuthUser => {
|
||||
const request = ctx.switchToHttp().getRequest<{ user: AuthUser }>();
|
||||
return request.user;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const BUSINESS_PERMISSION_KEY = 'business_permission';
|
||||
|
||||
export const RequireBusinessPermission = (permission: string) =>
|
||||
SetMetadata(BUSINESS_PERMISSION_KEY, permission);
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@IsString()
|
||||
currentPassword!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8, { message: 'newPassword must be at least 8 characters' })
|
||||
newPassword!: string;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsString, Matches, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
@Matches(/^\+[1-9]\d{6,14}$/, {
|
||||
message: 'cellNumber must be in E.164 format (e.g. +989121234567)',
|
||||
})
|
||||
cellNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@IsString()
|
||||
refreshToken!: string;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { IsEmail, IsOptional, IsString, Matches, MinLength } from 'class-validator';
|
||||
|
||||
export class RegisterDto {
|
||||
@IsString()
|
||||
@Matches(/^\+[1-9]\d{6,14}$/, {
|
||||
message: 'cellNumber must be in E.164 format (e.g. +989121234567)',
|
||||
})
|
||||
cellNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8, { message: 'password must be at least 8 characters' })
|
||||
password!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
firstName!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
lastName!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
/** Domain of the business website (e.g. shop-a.local). Resolves tenant for customer registration. */
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
domain!: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsString, Matches } from 'class-validator';
|
||||
|
||||
export class SendOtpDto {
|
||||
@IsString()
|
||||
@Matches(/^\+[1-9]\d{6,14}$/, {
|
||||
message: 'cellNumber must be in E.164 format (e.g. +989121234567)',
|
||||
})
|
||||
cellNumber!: string;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { IsEmail, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class UpdateProfileDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
firstName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
lastName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
@MaxLength(255)
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
about?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
city?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
address?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(30)
|
||||
landline?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(30)
|
||||
backupPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
postalCode?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
instagram?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
telegramId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
linkedin?: string;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class UpsertUserAddressDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
label?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
province!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
city!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(500)
|
||||
address!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
postalCode?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(30)
|
||||
landline?: string;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
export class VerifyOtpDto {
|
||||
@IsString()
|
||||
@Matches(/^\+[1-9]\d{6,14}$/, {
|
||||
message: 'cellNumber must be in E.164 format (e.g. +989121234567)',
|
||||
})
|
||||
cellNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@Length(6, 6)
|
||||
@Matches(/^\d{6}$/, { message: 'code must be a 6-digit number' })
|
||||
code!: string;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthUser } from '../auth.types';
|
||||
import { BUSINESS_PERMISSION_KEY } from '../decorators/require-business-permission.decorator';
|
||||
import { PermissionsService } from '../permissions.service';
|
||||
|
||||
@Injectable()
|
||||
export class BusinessPermissionGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly permissions: PermissionsService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const permission = this.reflector.get<string>(
|
||||
BUSINESS_PERMISSION_KEY,
|
||||
context.getHandler(),
|
||||
);
|
||||
|
||||
if (!permission) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<{
|
||||
user: AuthUser;
|
||||
params: { businessId?: string };
|
||||
}>();
|
||||
|
||||
const businessIdRaw = request.params.businessId;
|
||||
if (!businessIdRaw) {
|
||||
throw new ForbiddenException('Business context is required');
|
||||
}
|
||||
|
||||
const allowed = await this.permissions.hasBusinessPermission(
|
||||
request.user.id,
|
||||
BigInt(businessIdRaw),
|
||||
permission,
|
||||
);
|
||||
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(
|
||||
`Missing permission: ${permission} for this business`,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class PermissionsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async isSuperAdmin(userId: bigint): Promise<boolean> {
|
||||
const count = await this.prisma.userRole.count({
|
||||
where: {
|
||||
userId,
|
||||
role: { slug: 'super_admin' },
|
||||
},
|
||||
});
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
async getBusinessMemberships(userId: bigint) {
|
||||
const memberships = await this.prisma.businessUser.findMany({
|
||||
where: { userId },
|
||||
include: { business: true, role: true },
|
||||
});
|
||||
|
||||
return Promise.all(
|
||||
memberships.map(async (m) => ({
|
||||
id: m.business.id,
|
||||
name: m.business.name,
|
||||
slug: m.business.slug,
|
||||
isOwner: m.isOwner,
|
||||
teamRole: m.isOwner ? 'business_owner' : m.role?.slug ?? null,
|
||||
permissions: await this.getPermissionsForBusiness(userId, m.businessId),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async getPermissionsForBusiness(
|
||||
userId: bigint,
|
||||
businessId: bigint,
|
||||
): Promise<string[]> {
|
||||
if (await this.isSuperAdmin(userId)) {
|
||||
return this.getAllPermissionSlugs();
|
||||
}
|
||||
|
||||
const membership = await this.prisma.businessUser.findUnique({
|
||||
where: {
|
||||
businessId_userId: { businessId, userId },
|
||||
},
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
rolePermissions: { include: { permission: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (membership.isOwner) {
|
||||
return this.getRolePermissionSlugs('business_owner');
|
||||
}
|
||||
|
||||
if (!membership.role) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return membership.role.rolePermissions.map((rp) => rp.permission.slug);
|
||||
}
|
||||
|
||||
async hasBusinessPermission(
|
||||
userId: bigint,
|
||||
businessId: bigint,
|
||||
permission: string,
|
||||
): Promise<boolean> {
|
||||
const permissions = await this.getPermissionsForBusiness(userId, businessId);
|
||||
return permissions.includes(permission);
|
||||
}
|
||||
|
||||
private async getRolePermissionSlugs(roleSlug: string): Promise<string[]> {
|
||||
const role = await this.prisma.role.findUnique({
|
||||
where: { slug: roleSlug },
|
||||
include: {
|
||||
rolePermissions: { include: { permission: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return role?.rolePermissions.map((rp) => rp.permission.slug) ?? [];
|
||||
}
|
||||
|
||||
private async getAllPermissionSlugs(): Promise<string[]> {
|
||||
const permissions = await this.prisma.permission.findMany({
|
||||
select: { slug: true },
|
||||
});
|
||||
return permissions.map((p) => p.slug);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { UserProfile } from './auth.types';
|
||||
|
||||
export function parseUserProfile(value: unknown): UserProfile {
|
||||
const source =
|
||||
value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
|
||||
|
||||
return {
|
||||
about: typeof source.about === 'string' ? source.about : '',
|
||||
city: typeof source.city === 'string' ? source.city : '',
|
||||
address: typeof source.address === 'string' ? source.address : '',
|
||||
landline: typeof source.landline === 'string' ? source.landline : '',
|
||||
backupPhone: typeof source.backupPhone === 'string' ? source.backupPhone : '',
|
||||
postalCode: typeof source.postalCode === 'string' ? source.postalCode : '',
|
||||
instagram: typeof source.instagram === 'string' ? source.instagram : '',
|
||||
telegramId: typeof source.telegramId === 'string' ? source.telegramId : '',
|
||||
linkedin: typeof source.linkedin === 'string' ? source.linkedin : '',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class SmsService {
|
||||
private readonly logger = new Logger(SmsService.name);
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.config.get<string>('SMS_ENABLED', 'false') === 'true';
|
||||
}
|
||||
|
||||
async sendVerificationCode(cellNumber: string, code: string): Promise<void> {
|
||||
if (!this.isEnabled()) {
|
||||
this.logger.warn(
|
||||
`SMS disabled — verification code for ${cellNumber} not sent (code: ${code})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: integrate real SMS provider when API credentials are available
|
||||
this.logger.log(`Sending SMS verification code to ${cellNumber}`);
|
||||
throw new Error('SMS provider is not configured yet');
|
||||
}
|
||||
|
||||
async sendMessage(cellNumber: string, message: string): Promise<void> {
|
||||
if (!this.isEnabled()) {
|
||||
this.logger.warn(`SMS disabled — message for ${cellNumber} not sent: ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: integrate real SMS provider when API credentials are available
|
||||
this.logger.log(`Sending SMS message to ${cellNumber}: ${message}`);
|
||||
throw new Error('SMS provider is not configured yet');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import {
|
||||
AuthJwtPayload,
|
||||
AuthUser,
|
||||
DashboardType,
|
||||
} from '../auth.types';
|
||||
import { PermissionsService } from '../permissions.service';
|
||||
import { parseUserProfile } from '../profile.util';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: AuthJwtPayload): Promise<AuthUser> {
|
||||
if (payload.type !== 'access') {
|
||||
throw new UnauthorizedException('Invalid token type');
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: BigInt(payload.sub) },
|
||||
include: {
|
||||
userRoles: { include: { role: true } },
|
||||
businessCustomers: { include: { business: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException('User not found or inactive');
|
||||
}
|
||||
|
||||
const roles = user.userRoles.map((ur) => ur.role.slug);
|
||||
const businesses = await this.permissions.getBusinessMemberships(user.id);
|
||||
const customerBusinesses = user.businessCustomers.map((bc) => ({
|
||||
id: bc.business.id,
|
||||
name: bc.business.name,
|
||||
slug: bc.business.slug,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
cellNumber: user.cellNumber,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
cellVerifiedAt: user.cellVerifiedAt,
|
||||
roles,
|
||||
dashboard: this.resolveDashboard(roles, businesses.length),
|
||||
profile: parseUserProfile(user.profile),
|
||||
businesses,
|
||||
customerBusinesses,
|
||||
};
|
||||
}
|
||||
|
||||
private resolveDashboard(roles: string[], businessCount: number): DashboardType {
|
||||
if (roles.includes('super_admin')) {
|
||||
return 'super_admin';
|
||||
}
|
||||
if (
|
||||
roles.includes('business_owner') ||
|
||||
roles.includes('business_staff') ||
|
||||
roles.includes('owner') ||
|
||||
businessCount > 0
|
||||
) {
|
||||
return 'business';
|
||||
}
|
||||
return 'customer';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuthUser } from './auth.types';
|
||||
import { UpsertUserAddressDto } from './dto/upsert-user-address.dto';
|
||||
|
||||
@Injectable()
|
||||
export class UserAddressesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(actor: AuthUser) {
|
||||
const items = await this.prisma.address.findMany({
|
||||
where: { userId: actor.id, businessId: null },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
return { items: items.map((item) => this.serialize(item)) };
|
||||
}
|
||||
|
||||
async create(actor: AuthUser, dto: UpsertUserAddressDto) {
|
||||
const created = await this.prisma.address.create({
|
||||
data: {
|
||||
userId: actor.id,
|
||||
label: dto.label?.trim() || null,
|
||||
province: dto.province.trim(),
|
||||
city: dto.city.trim(),
|
||||
address: dto.address.trim(),
|
||||
postalCode: dto.postalCode?.trim() || null,
|
||||
landline: dto.landline?.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
return { address: this.serialize(created) };
|
||||
}
|
||||
|
||||
async update(
|
||||
actor: AuthUser,
|
||||
addressIdRaw: string,
|
||||
dto: UpsertUserAddressDto,
|
||||
) {
|
||||
const address = await this.findOwnedAddress(actor, addressIdRaw);
|
||||
|
||||
const updated = await this.prisma.address.update({
|
||||
where: { id: address.id },
|
||||
data: {
|
||||
label: dto.label?.trim() || null,
|
||||
province: dto.province.trim(),
|
||||
city: dto.city.trim(),
|
||||
address: dto.address.trim(),
|
||||
postalCode: dto.postalCode?.trim() || null,
|
||||
landline: dto.landline?.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
return { address: this.serialize(updated) };
|
||||
}
|
||||
|
||||
async remove(actor: AuthUser, addressIdRaw: string) {
|
||||
const address = await this.findOwnedAddress(actor, addressIdRaw);
|
||||
|
||||
await this.prisma.address.delete({
|
||||
where: { id: address.id },
|
||||
});
|
||||
|
||||
return { message: 'Address removed.' };
|
||||
}
|
||||
|
||||
private async findOwnedAddress(actor: AuthUser, addressIdRaw: string) {
|
||||
const address = await this.prisma.address.findFirst({
|
||||
where: {
|
||||
id: BigInt(addressIdRaw),
|
||||
userId: actor.id,
|
||||
businessId: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!address) {
|
||||
throw new NotFoundException('Address not found');
|
||||
}
|
||||
|
||||
return address;
|
||||
}
|
||||
|
||||
private serialize(address: {
|
||||
id: bigint;
|
||||
label: string | null;
|
||||
province: string;
|
||||
city: string;
|
||||
address: string;
|
||||
postalCode: string | null;
|
||||
landline: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: address.id.toString(),
|
||||
label: address.label,
|
||||
province: address.province,
|
||||
city: address.city,
|
||||
address: address.address,
|
||||
postalCode: address.postalCode,
|
||||
landline: address.landline,
|
||||
createdAt: address.createdAt,
|
||||
updatedAt: address.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { BlogsService } from './blogs.service';
|
||||
import {
|
||||
CreateBlogCommentDto,
|
||||
CreateBlogDto,
|
||||
ListBlogsDto,
|
||||
ListPublicBlogsDto,
|
||||
UpdateBlogDto,
|
||||
} from './dto/blog.dto';
|
||||
|
||||
@Controller('businesses/:businessId/blogs')
|
||||
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||
export class BlogsController {
|
||||
constructor(private readonly service: BlogsService) {}
|
||||
|
||||
@Get()
|
||||
@RequireBusinessPermission('blogs.read')
|
||||
list(
|
||||
@Param('businessId') businessId: string,
|
||||
@Query() query: ListBlogsDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.list(businessId, query, user);
|
||||
}
|
||||
|
||||
@Get(':blogId')
|
||||
@RequireBusinessPermission('blogs.read')
|
||||
getOne(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('blogId') blogId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.getOne(businessId, blogId, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequireBusinessPermission('blogs.create')
|
||||
create(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: CreateBlogDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.create(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Patch(':blogId')
|
||||
@RequireBusinessPermission('blogs.update')
|
||||
update(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('blogId') blogId: string,
|
||||
@Body() dto: UpdateBlogDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.update(businessId, blogId, dto, user);
|
||||
}
|
||||
|
||||
@Delete(':blogId')
|
||||
@RequireBusinessPermission('blogs.delete')
|
||||
remove(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('blogId') blogId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.remove(businessId, blogId, user);
|
||||
}
|
||||
|
||||
@Get(':blogId/comments')
|
||||
@RequireBusinessPermission('comments.read')
|
||||
listComments(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('blogId') blogId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.listCommentsAdmin(businessId, blogId, user);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('tenants/:host/blogs')
|
||||
export class PublicBlogsController {
|
||||
constructor(private readonly service: BlogsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Param('host') host: string, @Query() query: ListPublicBlogsDto) {
|
||||
return this.service.listPublic(host, query);
|
||||
}
|
||||
|
||||
@Get(':blogId/comments')
|
||||
listComments(@Param('host') host: string, @Param('blogId') blogId: string) {
|
||||
return this.service.listCommentsPublic(host, blogId);
|
||||
}
|
||||
|
||||
@Post(':blogId/comments')
|
||||
createComment(
|
||||
@Param('host') host: string,
|
||||
@Param('blogId') blogId: string,
|
||||
@Body() dto: CreateBlogCommentDto,
|
||||
) {
|
||||
return this.service.createCommentPublic(host, blogId, dto);
|
||||
}
|
||||
|
||||
@Get(':slug')
|
||||
getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
|
||||
return this.service.getPublicBySlug(host, slug);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
|
||||
import { TenantModule } from '../tenant/tenant.module';
|
||||
import { BlogsController, PublicBlogsController } from './blogs.controller';
|
||||
import { BlogsService } from './blogs.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, BusinessSettingsModule, TenantModule],
|
||||
controllers: [BlogsController, PublicBlogsController],
|
||||
providers: [BlogsService],
|
||||
})
|
||||
export class BlogsModule {}
|
||||
@@ -0,0 +1,733 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ContentStatus,
|
||||
MediaEntityType,
|
||||
Prisma,
|
||||
} from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { BusinessSettingsService } from '../business-settings/business-settings.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
import {
|
||||
CreateBlogCommentDto,
|
||||
CreateBlogDto,
|
||||
ListBlogsDto,
|
||||
ListPublicBlogsDto,
|
||||
UpdateBlogDto,
|
||||
} from './dto/blog.dto';
|
||||
|
||||
function slugify(value: string): string {
|
||||
return (
|
||||
value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'blog'
|
||||
);
|
||||
}
|
||||
|
||||
type BlogWithRelations = Prisma.blogsGetPayload<{
|
||||
include: {
|
||||
media: true;
|
||||
users: { select: { id: true; firstName: true; lastName: true; email: true } };
|
||||
};
|
||||
}>;
|
||||
|
||||
const blogInclude = {
|
||||
media: true,
|
||||
users: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.blogsInclude;
|
||||
|
||||
@Injectable()
|
||||
export class BlogsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly tenant: TenantService,
|
||||
private readonly businessSettings: BusinessSettingsService,
|
||||
) {}
|
||||
|
||||
async list(businessIdRaw: string, query: ListBlogsDto, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'blogs.read');
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 12;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where = await this.buildWhere(businessId, query);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.blogs.findMany({
|
||||
where,
|
||||
orderBy: [{ published_at: 'desc' }, { created_at: 'desc' }],
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: blogInclude,
|
||||
}),
|
||||
this.prisma.blogs.count({ where }),
|
||||
]);
|
||||
|
||||
const serialized = await Promise.all(
|
||||
items.map((item) => this.serializeBlog(item, { includeComments: true })),
|
||||
);
|
||||
|
||||
return { items: serialized, total, page, pageSize };
|
||||
}
|
||||
|
||||
async getOne(businessIdRaw: string, blogIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const blogId = BigInt(blogIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'blogs.read');
|
||||
|
||||
const blog = await this.findBlogOrThrow(businessId, blogId);
|
||||
|
||||
return { blog: await this.serializeBlog(blog, { includeComments: true }) };
|
||||
}
|
||||
|
||||
async create(businessIdRaw: string, dto: CreateBlogDto, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'blogs.create');
|
||||
|
||||
const slug = await this.ensureUniqueSlug(
|
||||
businessId,
|
||||
dto.slug ?? slugify(dto.title),
|
||||
);
|
||||
|
||||
const status = dto.status ?? ContentStatus.draft;
|
||||
const featuredMediaId = dto.featuredMediaId
|
||||
? BigInt(dto.featuredMediaId)
|
||||
: null;
|
||||
|
||||
if (featuredMediaId) {
|
||||
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
|
||||
}
|
||||
|
||||
const authorId = dto.authorId ? BigInt(dto.authorId) : actor.id;
|
||||
await this.assertAuthorBelongsToBusiness(businessId, authorId);
|
||||
|
||||
if (dto.categoryId) {
|
||||
await this.assertCategoryBelongsToBusiness(businessId, BigInt(dto.categoryId));
|
||||
}
|
||||
|
||||
const created = await this.prisma.$transaction(async (tx) => {
|
||||
const blog = await tx.blogs.create({
|
||||
data: {
|
||||
business_id: businessId,
|
||||
author_id: authorId,
|
||||
title: dto.title.trim(),
|
||||
slug,
|
||||
excerpt: dto.abstract?.trim() || null,
|
||||
content: this.buildContent(dto.mainTextHtml) as Prisma.InputJsonValue,
|
||||
post_type: dto.type,
|
||||
status,
|
||||
featured_media_id: featuredMediaId,
|
||||
published_at: status === ContentStatus.published ? new Date() : null,
|
||||
metadata: this.buildMetadata(dto.tags) as Prisma.InputJsonValue,
|
||||
},
|
||||
include: blogInclude,
|
||||
});
|
||||
|
||||
if (dto.categoryId) {
|
||||
await tx.categoryAssignment.create({
|
||||
data: {
|
||||
businessId,
|
||||
categoryId: BigInt(dto.categoryId),
|
||||
entityType: MediaEntityType.blog,
|
||||
entityId: blog.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return blog;
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Blog post created successfully',
|
||||
blog: await this.serializeBlog(created),
|
||||
};
|
||||
}
|
||||
|
||||
async update(
|
||||
businessIdRaw: string,
|
||||
blogIdRaw: string,
|
||||
dto: UpdateBlogDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const blogId = BigInt(blogIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'blogs.update');
|
||||
|
||||
const existing = await this.prisma.blogs.findFirst({
|
||||
where: { id: blogId, business_id: businessId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Blog post not found');
|
||||
}
|
||||
|
||||
let slug = existing.slug;
|
||||
if (dto.slug) {
|
||||
slug = await this.ensureUniqueSlug(businessId, dto.slug, blogId);
|
||||
} else if (dto.title && dto.title !== existing.title) {
|
||||
slug = await this.ensureUniqueSlug(businessId, slugify(dto.title), blogId);
|
||||
}
|
||||
|
||||
let featuredMediaId: bigint | null | undefined = undefined;
|
||||
if (dto.featuredMediaId !== undefined) {
|
||||
if (dto.featuredMediaId === null || dto.featuredMediaId === '') {
|
||||
featuredMediaId = null;
|
||||
} else {
|
||||
featuredMediaId = BigInt(dto.featuredMediaId);
|
||||
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
|
||||
}
|
||||
}
|
||||
|
||||
let authorId: bigint | null | undefined = undefined;
|
||||
if (dto.authorId !== undefined) {
|
||||
if (dto.authorId === null || dto.authorId === '') {
|
||||
authorId = null;
|
||||
} else {
|
||||
authorId = BigInt(dto.authorId);
|
||||
await this.assertAuthorBelongsToBusiness(businessId, authorId);
|
||||
}
|
||||
}
|
||||
|
||||
const existingContent = this.asRecord(existing.content);
|
||||
const existingMetadata = this.asRecord(existing.metadata);
|
||||
|
||||
const nextContent = { ...existingContent };
|
||||
if (dto.mainTextHtml !== undefined) {
|
||||
nextContent.html = dto.mainTextHtml ?? '';
|
||||
}
|
||||
|
||||
const nextMetadata = { ...existingMetadata };
|
||||
if (dto.tags !== undefined) {
|
||||
nextMetadata.tags = dto.tags;
|
||||
}
|
||||
|
||||
let publishedAt: Date | null | undefined = undefined;
|
||||
if (dto.status !== undefined) {
|
||||
if (dto.status === ContentStatus.published && existing.status !== ContentStatus.published) {
|
||||
publishedAt = new Date();
|
||||
}
|
||||
if (dto.status !== ContentStatus.published) {
|
||||
publishedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const blog = await tx.blogs.update({
|
||||
where: { id: blogId },
|
||||
data: {
|
||||
...(dto.title !== undefined ? { title: dto.title.trim() } : {}),
|
||||
...(dto.abstract !== undefined
|
||||
? { excerpt: dto.abstract?.trim() || null }
|
||||
: {}),
|
||||
...(dto.type !== undefined ? { post_type: dto.type } : {}),
|
||||
...(dto.status !== undefined ? { status: dto.status } : {}),
|
||||
...(featuredMediaId !== undefined ? { featured_media_id: featuredMediaId } : {}),
|
||||
...(authorId !== undefined ? { author_id: authorId } : {}),
|
||||
...(publishedAt !== undefined ? { published_at: publishedAt } : {}),
|
||||
slug,
|
||||
content: nextContent as Prisma.InputJsonValue,
|
||||
metadata: nextMetadata as Prisma.InputJsonValue,
|
||||
},
|
||||
include: blogInclude,
|
||||
});
|
||||
|
||||
if (dto.categoryId !== undefined) {
|
||||
await tx.categoryAssignment.deleteMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.blog,
|
||||
entityId: blogId,
|
||||
},
|
||||
});
|
||||
|
||||
if (dto.categoryId) {
|
||||
const categoryId = BigInt(dto.categoryId);
|
||||
await this.assertCategoryBelongsToBusiness(businessId, categoryId);
|
||||
await tx.categoryAssignment.create({
|
||||
data: {
|
||||
businessId,
|
||||
categoryId,
|
||||
entityType: MediaEntityType.blog,
|
||||
entityId: blogId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return blog;
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Blog post updated successfully',
|
||||
blog: await this.serializeBlog(updated),
|
||||
};
|
||||
}
|
||||
|
||||
async remove(businessIdRaw: string, blogIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const blogId = BigInt(blogIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'blogs.delete');
|
||||
|
||||
const existing = await this.prisma.blogs.findFirst({
|
||||
where: { id: blogId, business_id: businessId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Blog post not found');
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.comment.deleteMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.blog,
|
||||
entityId: blogId,
|
||||
},
|
||||
}),
|
||||
this.prisma.categoryAssignment.deleteMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.blog,
|
||||
entityId: blogId,
|
||||
},
|
||||
}),
|
||||
this.prisma.blogs.delete({ where: { id: blogId } }),
|
||||
]);
|
||||
|
||||
return { message: 'Blog post deleted successfully' };
|
||||
}
|
||||
|
||||
async listPublic(host: string, query: ListPublicBlogsDto) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const businessId = business.id;
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 12;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where = await this.buildWhere(businessId, {
|
||||
...query,
|
||||
status: ContentStatus.published,
|
||||
});
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.blogs.findMany({
|
||||
where,
|
||||
orderBy: [{ published_at: 'desc' }, { created_at: 'desc' }],
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: blogInclude,
|
||||
}),
|
||||
this.prisma.blogs.count({ where }),
|
||||
]);
|
||||
|
||||
const serialized = await Promise.all(
|
||||
items.map((item) =>
|
||||
this.serializeBlog(item, { includeComments: true, approvedCommentsOnly: true }),
|
||||
),
|
||||
);
|
||||
|
||||
return { items: serialized, total, page, pageSize };
|
||||
}
|
||||
|
||||
async getPublicBySlug(host: string, slug: string) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const businessId = business.id;
|
||||
|
||||
const blog = await this.prisma.blogs.findFirst({
|
||||
where: {
|
||||
business_id: businessId,
|
||||
slug,
|
||||
status: ContentStatus.published,
|
||||
},
|
||||
include: blogInclude,
|
||||
});
|
||||
|
||||
if (!blog) {
|
||||
throw new NotFoundException('Blog post not found');
|
||||
}
|
||||
|
||||
return {
|
||||
blog: await this.serializeBlog(blog, {
|
||||
includeComments: true,
|
||||
approvedCommentsOnly: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async listCommentsPublic(host: string, blogIdRaw: string) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const businessId = business.id;
|
||||
const blogId = BigInt(blogIdRaw);
|
||||
|
||||
await this.assertPublishedBlogExists(businessId, blogId);
|
||||
|
||||
const items = await this.prisma.comment.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.blog,
|
||||
entityId: blogId,
|
||||
isApproved: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { approver: true },
|
||||
});
|
||||
|
||||
return { items: items.map((item) => this.serializeComment(item)) };
|
||||
}
|
||||
|
||||
async createCommentPublic(
|
||||
host: string,
|
||||
blogIdRaw: string,
|
||||
dto: CreateBlogCommentDto,
|
||||
) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const businessId = business.id;
|
||||
const blogId = BigInt(blogIdRaw);
|
||||
|
||||
await this.assertPublishedBlogExists(businessId, blogId);
|
||||
|
||||
const autoApprove = await this.businessSettings.isCommentsAutoApprove(businessId);
|
||||
const approvedAt = autoApprove ? new Date() : null;
|
||||
|
||||
const created = await this.prisma.comment.create({
|
||||
data: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.blog,
|
||||
entityId: blogId,
|
||||
authorName: dto.authorName.trim(),
|
||||
authorEmail: dto.authorEmail?.trim() || null,
|
||||
text: dto.text.trim(),
|
||||
isApproved: autoApprove,
|
||||
approvedAt,
|
||||
},
|
||||
include: { approver: true },
|
||||
});
|
||||
|
||||
return {
|
||||
comment: this.serializeComment(created),
|
||||
message: autoApprove
|
||||
? 'Comment submitted and is approved'
|
||||
: 'Comment submitted and is pending approval',
|
||||
};
|
||||
}
|
||||
|
||||
async listCommentsAdmin(
|
||||
businessIdRaw: string,
|
||||
blogIdRaw: string,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const blogId = BigInt(blogIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'comments.read');
|
||||
|
||||
const blog = await this.prisma.blogs.findFirst({
|
||||
where: { id: blogId, business_id: businessId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!blog) {
|
||||
throw new NotFoundException('Blog post not found');
|
||||
}
|
||||
|
||||
const items = await this.prisma.comment.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.blog,
|
||||
entityId: blogId,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { approver: true },
|
||||
});
|
||||
|
||||
return { items: items.map((item) => this.serializeComment(item)) };
|
||||
}
|
||||
|
||||
private async buildWhere(
|
||||
businessId: bigint,
|
||||
query: (ListBlogsDto | ListPublicBlogsDto) & { status?: ContentStatus },
|
||||
): Promise<Prisma.blogsWhereInput> {
|
||||
let entityIds: bigint[] | undefined;
|
||||
|
||||
if (query.categoryId) {
|
||||
const assignments = await this.prisma.categoryAssignment.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
categoryId: BigInt(query.categoryId),
|
||||
entityType: MediaEntityType.blog,
|
||||
},
|
||||
select: { entityId: true },
|
||||
});
|
||||
|
||||
entityIds = assignments.map((item) => item.entityId);
|
||||
|
||||
if (entityIds.length === 0) {
|
||||
return { id: { in: [] } };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
business_id: businessId,
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(query.type ? { post_type: query.type } : {}),
|
||||
...(entityIds ? { id: { in: entityIds } } : {}),
|
||||
...(query.title?.trim()
|
||||
? {
|
||||
title: { contains: query.title.trim(), mode: 'insensitive' },
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
private async findBlogOrThrow(businessId: bigint, blogId: bigint) {
|
||||
const blog = await this.prisma.blogs.findFirst({
|
||||
where: { id: blogId, business_id: businessId },
|
||||
include: blogInclude,
|
||||
});
|
||||
|
||||
if (!blog) {
|
||||
throw new NotFoundException('Blog post not found');
|
||||
}
|
||||
|
||||
return blog;
|
||||
}
|
||||
|
||||
private async assertPublishedBlogExists(businessId: bigint, blogId: bigint) {
|
||||
const blog = await this.prisma.blogs.findFirst({
|
||||
where: {
|
||||
id: blogId,
|
||||
business_id: businessId,
|
||||
status: ContentStatus.published,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!blog) {
|
||||
throw new NotFoundException('Blog post not found');
|
||||
}
|
||||
}
|
||||
|
||||
private async serializeBlog(
|
||||
blog: BlogWithRelations,
|
||||
options: {
|
||||
includeComments?: boolean;
|
||||
approvedCommentsOnly?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const content = this.asRecord(blog.content);
|
||||
const metadata = this.asRecord(blog.metadata);
|
||||
|
||||
const [categoryAssignment, commentData] = await Promise.all([
|
||||
this.prisma.categoryAssignment.findFirst({
|
||||
where: {
|
||||
businessId: blog.business_id,
|
||||
entityType: MediaEntityType.blog,
|
||||
entityId: blog.id,
|
||||
},
|
||||
include: { category: true },
|
||||
}),
|
||||
options.includeComments
|
||||
? this.loadComments(blog.business_id, blog.id, options.approvedCommentsOnly)
|
||||
: Promise.resolve({ commentCount: 0, comments: [] }),
|
||||
]);
|
||||
|
||||
return {
|
||||
id: blog.id.toString(),
|
||||
businessId: blog.business_id.toString(),
|
||||
title: blog.title,
|
||||
slug: blog.slug,
|
||||
type: blog.post_type,
|
||||
abstract: blog.excerpt ?? '',
|
||||
mainTextHtml: (content.html as string | undefined) ?? '',
|
||||
status: blog.status,
|
||||
categoryId: categoryAssignment?.categoryId.toString() ?? null,
|
||||
categoryName: categoryAssignment?.category.name ?? '',
|
||||
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
|
||||
authorId: blog.author_id?.toString() ?? null,
|
||||
author: blog.users
|
||||
? {
|
||||
id: blog.users.id.toString(),
|
||||
firstName: blog.users.firstName,
|
||||
lastName: blog.users.lastName,
|
||||
email: blog.users.email,
|
||||
}
|
||||
: null,
|
||||
titleImageUrl: blog.media?.publicUrl ?? null,
|
||||
featuredMediaId: blog.featured_media_id?.toString() ?? null,
|
||||
commentCount: commentData.commentCount,
|
||||
comments: commentData.comments,
|
||||
publishedAt: blog.published_at,
|
||||
createdAt: blog.created_at,
|
||||
updatedAt: blog.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadComments(
|
||||
businessId: bigint,
|
||||
blogId: bigint,
|
||||
approvedOnly?: boolean,
|
||||
) {
|
||||
const where: Prisma.CommentWhereInput = {
|
||||
businessId,
|
||||
entityType: MediaEntityType.blog,
|
||||
entityId: blogId,
|
||||
...(approvedOnly ? { isApproved: true } : {}),
|
||||
};
|
||||
|
||||
const [commentCount, comments] = await Promise.all([
|
||||
this.prisma.comment.count({ where }),
|
||||
this.prisma.comment.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: approvedOnly ? 50 : undefined,
|
||||
include: { approver: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
commentCount,
|
||||
comments: comments.map((item) => this.serializeComment(item)),
|
||||
};
|
||||
}
|
||||
|
||||
private serializeComment(
|
||||
comment: Prisma.CommentGetPayload<{ include: { approver: true } }>,
|
||||
) {
|
||||
return {
|
||||
id: comment.id.toString(),
|
||||
businessId: comment.businessId.toString(),
|
||||
entityType: comment.entityType,
|
||||
entityId: comment.entityId.toString(),
|
||||
authorName: comment.authorName,
|
||||
authorEmail: comment.authorEmail,
|
||||
text: comment.text,
|
||||
isApproved: comment.isApproved,
|
||||
approvedAt: comment.approvedAt,
|
||||
approvedBy: comment.approvedBy?.toString() ?? null,
|
||||
approver: comment.approver
|
||||
? {
|
||||
id: comment.approver.id.toString(),
|
||||
firstName: comment.approver.firstName,
|
||||
lastName: comment.approver.lastName,
|
||||
}
|
||||
: null,
|
||||
createdAt: comment.createdAt,
|
||||
updatedAt: comment.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private buildContent(mainTextHtml?: string) {
|
||||
return {
|
||||
html: mainTextHtml ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
private buildMetadata(tags?: string[]) {
|
||||
return {
|
||||
tags: tags?.map((tag) => tag.trim()).filter(Boolean) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
private asRecord(value: Prisma.JsonValue): Record<string, unknown> {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
private async assertMediaBelongsToBusiness(businessId: bigint, mediaId: bigint) {
|
||||
const media = await this.prisma.media.findFirst({
|
||||
where: { id: mediaId, businessId },
|
||||
});
|
||||
if (!media) {
|
||||
throw new BadRequestException('Media not found for this business');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertCategoryBelongsToBusiness(
|
||||
businessId: bigint,
|
||||
categoryId: bigint,
|
||||
) {
|
||||
const category = await this.prisma.category.findFirst({
|
||||
where: {
|
||||
id: categoryId,
|
||||
businessId,
|
||||
entityType: MediaEntityType.blog,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
if (!category) {
|
||||
throw new BadRequestException('Blog category not found for this business');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertAuthorBelongsToBusiness(businessId: bigint, authorId: bigint) {
|
||||
const member = await this.prisma.businessUser.findFirst({
|
||||
where: { businessId, userId: authorId },
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
throw new BadRequestException('Author must be a team member of this business');
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureUniqueSlug(
|
||||
businessId: bigint,
|
||||
baseSlug: string,
|
||||
excludeId?: bigint,
|
||||
) {
|
||||
let slug = baseSlug;
|
||||
let suffix = 1;
|
||||
|
||||
while (true) {
|
||||
const existing = await this.prisma.blogs.findFirst({
|
||||
where: {
|
||||
business_id: businessId,
|
||||
slug,
|
||||
...(excludeId ? { NOT: { id: excludeId } } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return slug;
|
||||
}
|
||||
|
||||
suffix += 1;
|
||||
slug = `${baseSlug}-${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
private async assertPermission(
|
||||
businessId: bigint,
|
||||
userId: bigint,
|
||||
permission: string,
|
||||
) {
|
||||
const allowed = await this.permissions.hasBusinessPermission(
|
||||
userId,
|
||||
businessId,
|
||||
permission,
|
||||
);
|
||||
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(`Missing permission: ${permission} for this business`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { BlogPostType, ContentStatus } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ListBlogsDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ContentStatus)
|
||||
status?: ContentStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(BlogPostType)
|
||||
type?: BlogPostType;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export class ListPublicBlogsDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(BlogPostType)
|
||||
type?: BlogPostType;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export class CreateBlogDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
title!: string;
|
||||
|
||||
@IsEnum(BlogPostType)
|
||||
type!: BlogPostType;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
abstract?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mainTextHtml?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ContentStatus)
|
||||
status?: ContentStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
featuredMediaId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
authorId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
export class UpdateBlogDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(BlogPostType)
|
||||
type?: BlogPostType;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
abstract?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mainTextHtml?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ContentStatus)
|
||||
status?: ContentStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
featuredMediaId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
authorId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
export class CreateBlogCommentDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
authorName!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
authorEmail?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
text!: string;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { BrandsService } from './brands.service';
|
||||
import { CreateBrandDto, ListBrandsDto, UpdateBrandDto } from './dto/brand.dto';
|
||||
|
||||
@Controller('businesses/:businessId/brands')
|
||||
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||
export class BrandsController {
|
||||
constructor(private readonly service: BrandsService) {}
|
||||
|
||||
@Get()
|
||||
@RequireBusinessPermission('brands.read')
|
||||
list(
|
||||
@Param('businessId') businessId: string,
|
||||
@Query() query: ListBrandsDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.list(businessId, query, user);
|
||||
}
|
||||
|
||||
@Get(':brandId')
|
||||
@RequireBusinessPermission('brands.read')
|
||||
getOne(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('brandId') brandId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.getOne(businessId, brandId, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequireBusinessPermission('brands.create')
|
||||
create(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: CreateBrandDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.create(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Patch(':brandId')
|
||||
@RequireBusinessPermission('brands.update')
|
||||
update(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('brandId') brandId: string,
|
||||
@Body() dto: UpdateBrandDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.update(businessId, brandId, dto, user);
|
||||
}
|
||||
|
||||
@Delete(':brandId')
|
||||
@RequireBusinessPermission('brands.delete')
|
||||
remove(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('brandId') brandId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.remove(businessId, brandId, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BrandsController } from './brands.controller';
|
||||
import { BrandsService } from './brands.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [BrandsController],
|
||||
providers: [BrandsService],
|
||||
exports: [BrandsService],
|
||||
})
|
||||
export class BrandsModule {}
|
||||
@@ -0,0 +1,284 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateBrandDto, ListBrandsDto, UpdateBrandDto } from './dto/brand.dto';
|
||||
|
||||
function slugify(value: string): string {
|
||||
return (
|
||||
value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'brand'
|
||||
);
|
||||
}
|
||||
|
||||
type BrandWithImage = Prisma.BrandGetPayload<{
|
||||
include: { imageMedia: true };
|
||||
}>;
|
||||
|
||||
@Injectable()
|
||||
export class BrandsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
) {}
|
||||
|
||||
async list(businessIdRaw: string, query: ListBrandsDto, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'brands.read');
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.BrandWhereInput = {
|
||||
businessId,
|
||||
...(query.name?.trim()
|
||||
? {
|
||||
OR: [
|
||||
{ nameEn: { contains: query.name.trim(), mode: 'insensitive' } },
|
||||
{ nameFa: { contains: query.name.trim(), mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.brand.findMany({
|
||||
where,
|
||||
orderBy: [{ sort_order: 'asc' }, { nameEn: 'asc' }, { createdAt: 'desc' }],
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: { imageMedia: true },
|
||||
}),
|
||||
this.prisma.brand.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((item) => this.serialize(item)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async getOne(businessIdRaw: string, brandIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const brandId = BigInt(brandIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'brands.read');
|
||||
|
||||
const brand = await this.findBrandOrThrow(businessId, brandId);
|
||||
return { brand: this.serialize(brand) };
|
||||
}
|
||||
|
||||
async create(businessIdRaw: string, dto: CreateBrandDto, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'brands.create');
|
||||
|
||||
const slug = await this.ensureUniqueSlug(
|
||||
businessId,
|
||||
dto.slug ?? slugify(dto.nameEn),
|
||||
);
|
||||
|
||||
let imageMediaId: bigint | null = null;
|
||||
if (dto.imageMediaId) {
|
||||
imageMediaId = BigInt(dto.imageMediaId);
|
||||
await this.assertBrandImageMedia(businessId, imageMediaId);
|
||||
}
|
||||
|
||||
const created = await this.prisma.brand.create({
|
||||
data: {
|
||||
businessId,
|
||||
nameEn: dto.nameEn.trim(),
|
||||
nameFa: dto.nameFa?.trim() || null,
|
||||
imageMediaId,
|
||||
about: dto.about?.trim() || null,
|
||||
slug,
|
||||
sort_order: dto.sortOrder ?? 0,
|
||||
},
|
||||
include: { imageMedia: true },
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Brand created successfully',
|
||||
brand: this.serialize(created),
|
||||
};
|
||||
}
|
||||
|
||||
async update(
|
||||
businessIdRaw: string,
|
||||
brandIdRaw: string,
|
||||
dto: UpdateBrandDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const brandId = BigInt(brandIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'brands.update');
|
||||
|
||||
const existing = await this.findBrandOrThrow(businessId, brandId);
|
||||
|
||||
let slug = existing.slug;
|
||||
if (dto.slug) {
|
||||
slug = await this.ensureUniqueSlug(businessId, dto.slug, brandId);
|
||||
} else if (dto.nameEn && dto.nameEn !== existing.nameEn) {
|
||||
slug = await this.ensureUniqueSlug(
|
||||
businessId,
|
||||
slugify(dto.nameEn),
|
||||
brandId,
|
||||
);
|
||||
}
|
||||
|
||||
let imageMediaId: bigint | null | undefined = undefined;
|
||||
if (dto.imageMediaId !== undefined) {
|
||||
if (dto.imageMediaId === null || dto.imageMediaId === '') {
|
||||
imageMediaId = null;
|
||||
} else {
|
||||
imageMediaId = BigInt(dto.imageMediaId);
|
||||
await this.assertBrandImageMedia(businessId, imageMediaId);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await this.prisma.brand.update({
|
||||
where: { id: brandId },
|
||||
data: {
|
||||
...(dto.nameEn !== undefined ? { nameEn: dto.nameEn.trim() } : {}),
|
||||
...(dto.nameFa !== undefined ? { nameFa: dto.nameFa?.trim() || null } : {}),
|
||||
...(dto.about !== undefined ? { about: dto.about?.trim() || null } : {}),
|
||||
...(imageMediaId !== undefined ? { imageMediaId } : {}),
|
||||
...(dto.sortOrder !== undefined ? { sort_order: dto.sortOrder } : {}),
|
||||
slug,
|
||||
},
|
||||
include: { imageMedia: true },
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Brand updated successfully',
|
||||
brand: this.serialize(updated),
|
||||
};
|
||||
}
|
||||
|
||||
async remove(businessIdRaw: string, brandIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const brandId = BigInt(brandIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'brands.delete');
|
||||
|
||||
await this.findBrandOrThrow(businessId, brandId);
|
||||
await this.prisma.brand.delete({ where: { id: brandId } });
|
||||
|
||||
return { message: 'Brand deleted successfully' };
|
||||
}
|
||||
|
||||
async assertBrandBelongsToBusiness(businessId: bigint, brandId: bigint) {
|
||||
const brand = await this.prisma.brand.findFirst({
|
||||
where: { id: brandId, businessId },
|
||||
});
|
||||
|
||||
if (!brand) {
|
||||
throw new BadRequestException('Brand not found for this business');
|
||||
}
|
||||
}
|
||||
|
||||
serializeBrandSummary(brand: BrandWithImage | null) {
|
||||
if (!brand) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.serialize(brand);
|
||||
}
|
||||
|
||||
private async findBrandOrThrow(businessId: bigint, brandId: bigint) {
|
||||
const brand = await this.prisma.brand.findFirst({
|
||||
where: { id: brandId, businessId },
|
||||
include: { imageMedia: true },
|
||||
});
|
||||
|
||||
if (!brand) {
|
||||
throw new NotFoundException('Brand not found');
|
||||
}
|
||||
|
||||
return brand;
|
||||
}
|
||||
|
||||
private serialize(brand: BrandWithImage) {
|
||||
return {
|
||||
id: brand.id.toString(),
|
||||
businessId: brand.businessId.toString(),
|
||||
nameEn: brand.nameEn,
|
||||
nameFa: brand.nameFa,
|
||||
about: brand.about,
|
||||
slug: brand.slug,
|
||||
imageMediaId: brand.imageMediaId?.toString() ?? null,
|
||||
imageUrl: brand.imageMedia?.publicUrl ?? null,
|
||||
sortOrder: brand.sort_order,
|
||||
createdAt: brand.createdAt,
|
||||
updatedAt: brand.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private async assertBrandImageMedia(businessId: bigint, mediaId: bigint) {
|
||||
const media = await this.prisma.media.findFirst({
|
||||
where: { id: mediaId, businessId },
|
||||
select: { id: true, mimeType: true },
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
throw new BadRequestException('Brand image media not found for this business');
|
||||
}
|
||||
|
||||
if (media.mimeType !== 'image/png') {
|
||||
throw new BadRequestException('Brand image must be a PNG file');
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureUniqueSlug(
|
||||
businessId: bigint,
|
||||
baseSlug: string,
|
||||
excludeId?: bigint,
|
||||
) {
|
||||
let slug = baseSlug;
|
||||
let suffix = 1;
|
||||
|
||||
while (true) {
|
||||
const existing = await this.prisma.brand.findFirst({
|
||||
where: {
|
||||
businessId,
|
||||
slug,
|
||||
...(excludeId ? { NOT: { id: excludeId } } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return slug;
|
||||
}
|
||||
|
||||
suffix += 1;
|
||||
slug = `${baseSlug}-${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
private async assertPermission(
|
||||
businessId: bigint,
|
||||
userId: bigint,
|
||||
permission: string,
|
||||
) {
|
||||
const allowed = await this.permissions.hasBusinessPermission(
|
||||
userId,
|
||||
businessId,
|
||||
permission,
|
||||
);
|
||||
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(
|
||||
`Missing permission: ${permission} for this business`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ListBrandsDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export class CreateBrandDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
nameEn!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nameFa?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageMediaId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
about?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
||||
slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export class UpdateBrandDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
nameEn?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nameFa?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageMediaId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
about?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
||||
slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { ListBusinessesDto } from './dto/list-businesses.dto';
|
||||
import { SearchBusinessesDto } from './dto/search-businesses.dto';
|
||||
import { CreateBusinessDto } from './dto/create-business.dto';
|
||||
import { AddDomainDto } from './dto/add-domain.dto';
|
||||
import { UpdateDomainDto } from './dto/update-domain.dto';
|
||||
import { DisableBusinessDto } from './dto/disable-business.dto';
|
||||
import { UpdateBusinessDto } from './dto/update-business.dto';
|
||||
import { BusinessAdminService } from './business-admin.service';
|
||||
|
||||
@Controller('businesses')
|
||||
export class BusinessAdminController {
|
||||
constructor(private readonly service: BusinessAdminService) {}
|
||||
|
||||
@Get('search')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
search(@Query() query: SearchBusinessesDto, @CurrentUser() user: AuthUser) {
|
||||
return this.service.search(query, user);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
list(@Query() query: ListBusinessesDto, @CurrentUser() user: AuthUser) {
|
||||
return this.service.list(query, user);
|
||||
}
|
||||
|
||||
@Get(':businessId/staff')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
listStaff(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) {
|
||||
return this.service.listStaff(businessId, user);
|
||||
}
|
||||
|
||||
@Get(':businessId')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
getOne(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) {
|
||||
return this.service.getOne(businessId, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
create(@Body() dto: CreateBusinessDto, @CurrentUser() user: AuthUser) {
|
||||
return this.service.create(dto, user);
|
||||
}
|
||||
|
||||
@Patch(':businessId')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
update(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: UpdateBusinessDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.update(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Post(':businessId/domains')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
addDomain(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: AddDomainDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.addDomain(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Patch(':businessId/domains/:domainId')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
updateDomain(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('domainId') domainId: string,
|
||||
@Body() dto: UpdateDomainDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.updateDomain(businessId, domainId, dto, user);
|
||||
}
|
||||
|
||||
@Patch(':businessId/disable')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
disable(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: DisableBusinessDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.disable(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Delete(':businessId')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
remove(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) {
|
||||
return this.service.remove(businessId, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BusinessCategoriesController } from './business-categories.controller';
|
||||
import { BusinessCategoriesService } from './business-categories.service';
|
||||
import { BusinessAdminController } from './business-admin.controller';
|
||||
import { BusinessAdminService } from './business-admin.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [BusinessAdminController, BusinessCategoriesController],
|
||||
providers: [BusinessAdminService, BusinessCategoriesService],
|
||||
})
|
||||
export class BusinessAdminModule {}
|
||||
|
||||
@@ -0,0 +1,646 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { AddDomainDto } from './dto/add-domain.dto';
|
||||
import { UpdateDomainDto } from './dto/update-domain.dto';
|
||||
import { CreateBusinessDto } from './dto/create-business.dto';
|
||||
import { DisableBusinessDto } from './dto/disable-business.dto';
|
||||
import { UpdateBusinessDto } from './dto/update-business.dto';
|
||||
import { ListBusinessesDto } from './dto/list-businesses.dto';
|
||||
import { SearchBusinessesDto } from './dto/search-businesses.dto';
|
||||
import { normalizeBusinessPrimaryColorId } from '../business-settings/business-primary-colors';
|
||||
import type { BusinessPrimaryColorId } from '../business-settings/business-primary-colors';
|
||||
|
||||
type BusinessRow = {
|
||||
id: bigint;
|
||||
name: string;
|
||||
nameFa: string | null;
|
||||
about: string | null;
|
||||
slug: string;
|
||||
createdAt: Date;
|
||||
isActive: boolean;
|
||||
domainId: bigint | null;
|
||||
domain: string | null;
|
||||
sslEnabled: boolean | null;
|
||||
ownerUserId: bigint | null;
|
||||
ownerName: string | null;
|
||||
ownerCellNumber: string | null;
|
||||
primaryColor: string | null;
|
||||
};
|
||||
|
||||
function slugify(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BusinessAdminService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
) {}
|
||||
|
||||
private async assertSuperAdmin(actor: AuthUser) {
|
||||
if (!(await this.permissions.isSuperAdmin(actor.id))) {
|
||||
throw new ForbiddenException('Super admin access required');
|
||||
}
|
||||
}
|
||||
|
||||
async list(query: ListBusinessesDto, actor: AuthUser) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 10;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const nameLike = query.name ? `%${query.name.trim()}%` : null;
|
||||
const domainLike = query.domain ? `%${query.domain.trim()}%` : null;
|
||||
const categoryLike = query.category ? `%${query.category.trim()}%` : null;
|
||||
|
||||
const where = Prisma.sql`
|
||||
WHERE 1=1
|
||||
${nameLike ? Prisma.sql`AND (b.name ILIKE ${nameLike} OR b.name_fa ILIKE ${nameLike})` : Prisma.empty}
|
||||
${domainLike ? Prisma.sql`
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM domains d
|
||||
WHERE d.business_id = b.id AND d.host ILIKE ${domainLike}
|
||||
)
|
||||
` : Prisma.empty}
|
||||
${categoryLike ? Prisma.sql`
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM business_category_assignments bca
|
||||
JOIN business_categories bc ON bc.id = bca.category_id
|
||||
WHERE bca.business_id = b.id
|
||||
AND (bc.slug ILIKE ${categoryLike} OR bc.name ILIKE ${categoryLike})
|
||||
)
|
||||
` : Prisma.empty}
|
||||
`;
|
||||
|
||||
const [items, totalRow] = await Promise.all([
|
||||
this.prisma.$queryRaw<BusinessRow[]>(Prisma.sql`
|
||||
SELECT
|
||||
b.id AS "id",
|
||||
b.name AS "name",
|
||||
b.name_fa AS "nameFa",
|
||||
b.about AS "about",
|
||||
b.slug AS "slug",
|
||||
b.created_at AS "createdAt",
|
||||
b.is_active AS "isActive",
|
||||
dom.id AS "domainId",
|
||||
dom.host AS "domain",
|
||||
dom.ssl_enabled AS "sslEnabled",
|
||||
own."ownerUserId" AS "ownerUserId",
|
||||
own."ownerName" AS "ownerName",
|
||||
own."ownerCellNumber" AS "ownerCellNumber",
|
||||
b.settings->'branding'->>'primaryColor' AS "primaryColor"
|
||||
FROM businesses b
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT d.id, d.host, d.ssl_enabled
|
||||
FROM domains d
|
||||
WHERE d.business_id = b.id
|
||||
ORDER BY d.is_primary DESC, d.created_at DESC
|
||||
LIMIT 1
|
||||
) dom ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
u.id AS "ownerUserId",
|
||||
(u.first_name || ' ' || u.last_name) AS "ownerName",
|
||||
u.cell_number AS "ownerCellNumber"
|
||||
FROM business_users bu
|
||||
JOIN users u ON u.id = bu.user_id
|
||||
WHERE bu.business_id = b.id AND bu.is_owner = TRUE
|
||||
LIMIT 1
|
||||
) own ON TRUE
|
||||
${where}
|
||||
ORDER BY b.created_at DESC
|
||||
LIMIT ${pageSize} OFFSET ${skip}
|
||||
`),
|
||||
this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql`
|
||||
SELECT COUNT(*)::int AS "total"
|
||||
FROM businesses b
|
||||
${where}
|
||||
`),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
...item,
|
||||
primaryColor: normalizeBusinessPrimaryColorId(
|
||||
item.primaryColor,
|
||||
) as BusinessPrimaryColorId,
|
||||
})),
|
||||
total: totalRow[0]?.total ?? 0,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async search(query: SearchBusinessesDto, actor: AuthUser) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
const q = query.q.trim();
|
||||
const limit = Math.min(Math.max(query.limit ?? 20, 1), 50);
|
||||
const like = `%${q}%`;
|
||||
|
||||
const items = await this.prisma.$queryRaw<
|
||||
{
|
||||
id: bigint;
|
||||
name: string;
|
||||
nameFa: string | null;
|
||||
slug: string;
|
||||
}[]
|
||||
>(Prisma.sql`
|
||||
SELECT DISTINCT
|
||||
b.id AS "id",
|
||||
b.name AS "name",
|
||||
b.name_fa AS "nameFa",
|
||||
b.slug AS "slug"
|
||||
FROM businesses b
|
||||
LEFT JOIN domains d ON d.business_id = b.id
|
||||
WHERE b.is_active = TRUE
|
||||
AND (
|
||||
b.name ILIKE ${like}
|
||||
OR b.name_fa ILIKE ${like}
|
||||
OR b.slug ILIKE ${like}
|
||||
OR d.host ILIKE ${like}
|
||||
)
|
||||
ORDER BY b.name ASC
|
||||
LIMIT ${limit}
|
||||
`);
|
||||
|
||||
return {
|
||||
items: items.map((business) => ({
|
||||
id: business.id,
|
||||
name: business.name,
|
||||
nameFa: business.nameFa,
|
||||
slug: business.slug,
|
||||
label: this.formatBusinessLabel(business),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async listStaff(businessIdRaw: string, actor: AuthUser) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const business = await this.prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
});
|
||||
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
|
||||
const members = await this.prisma.businessUser.findMany({
|
||||
where: { businessId },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
cellNumber: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
cellVerifiedAt: true,
|
||||
isActive: true,
|
||||
},
|
||||
},
|
||||
role: true,
|
||||
inviter: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
|
||||
return {
|
||||
items: members.map((member) => ({
|
||||
id: member.id,
|
||||
userId: member.user.id,
|
||||
cellNumber: member.user.cellNumber,
|
||||
firstName: member.user.firstName,
|
||||
lastName: member.user.lastName,
|
||||
email: member.user.email,
|
||||
isActive: member.user.isActive,
|
||||
isVerified: member.user.cellVerifiedAt !== null,
|
||||
isOwner: member.isOwner,
|
||||
teamRole: member.isOwner ? 'business_owner' : member.role?.slug ?? null,
|
||||
invitedBy: member.inviter,
|
||||
createdAt: member.createdAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async getOne(businessIdRaw: string, actor: AuthUser) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
|
||||
const business = await this.prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
include: {
|
||||
categoryAssignments: {
|
||||
include: { category: true },
|
||||
},
|
||||
businessUsers: {
|
||||
where: { isOwner: true },
|
||||
include: { user: true },
|
||||
take: 1,
|
||||
},
|
||||
domains: { orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }] },
|
||||
},
|
||||
});
|
||||
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
|
||||
return this.serializeBusiness(business);
|
||||
}
|
||||
|
||||
async create(dto: CreateBusinessDto, actor: AuthUser) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
const slug = dto.slug?.trim() || slugify(dto.name);
|
||||
if (!slug) {
|
||||
throw new BadRequestException('Could not generate slug from name');
|
||||
}
|
||||
|
||||
await this.assertSlugAvailable(slug);
|
||||
await this.validateCategoryIds(dto.categoryIds);
|
||||
|
||||
const business = await this.prisma.$transaction(async (tx) => {
|
||||
const owner = await this.createOwnerUser(tx, dto);
|
||||
|
||||
const created = await tx.business.create({
|
||||
data: {
|
||||
name: dto.name.trim(),
|
||||
nameFa: dto.nameFa.trim(),
|
||||
about: dto.about?.trim() ?? null,
|
||||
slug,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.businessCategoryAssignment.createMany({
|
||||
data: dto.categoryIds.map((id) => ({
|
||||
businessId: created.id,
|
||||
categoryId: BigInt(id),
|
||||
})),
|
||||
});
|
||||
|
||||
await this.assignOwner(tx, created.id, owner.id, actor.id);
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
return this.getOne(business.id.toString(), actor);
|
||||
}
|
||||
|
||||
async update(businessIdRaw: string, dto: UpdateBusinessDto, actor: AuthUser) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const business = await this.prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
});
|
||||
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
|
||||
const nextName = dto.name?.trim() ?? business.name;
|
||||
const nextNameFa = dto.nameFa?.trim() ?? business.nameFa ?? business.name;
|
||||
const nextSlug =
|
||||
dto.slug?.trim() ?? (dto.name ? slugify(dto.name) : business.slug);
|
||||
|
||||
if (nextSlug !== business.slug) {
|
||||
await this.assertSlugAvailable(nextSlug, businessId);
|
||||
}
|
||||
|
||||
if (dto.categoryIds) {
|
||||
await this.validateCategoryIds(dto.categoryIds);
|
||||
}
|
||||
|
||||
if (dto.ownerUserId !== undefined && dto.ownerUserId !== null) {
|
||||
await this.findOwnerUser(dto.ownerUserId);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.business.update({
|
||||
where: { id: businessId },
|
||||
data: {
|
||||
name: nextName,
|
||||
nameFa: nextNameFa,
|
||||
about: dto.about !== undefined ? dto.about.trim() || null : undefined,
|
||||
slug: nextSlug,
|
||||
},
|
||||
});
|
||||
|
||||
if (dto.categoryIds) {
|
||||
await tx.businessCategoryAssignment.deleteMany({ where: { businessId } });
|
||||
await tx.businessCategoryAssignment.createMany({
|
||||
data: dto.categoryIds.map((id) => ({
|
||||
businessId,
|
||||
categoryId: BigInt(id),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (dto.ownerUserId !== undefined && dto.ownerUserId !== null) {
|
||||
await tx.businessUser.deleteMany({
|
||||
where: { businessId, isOwner: true },
|
||||
});
|
||||
await this.assignOwner(tx, businessId, BigInt(dto.ownerUserId), actor.id);
|
||||
}
|
||||
});
|
||||
|
||||
return this.getOne(businessIdRaw, actor);
|
||||
}
|
||||
|
||||
async addDomain(businessIdRaw: string, dto: AddDomainDto, actor: AuthUser) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const host = dto.host.trim();
|
||||
|
||||
if (!host) {
|
||||
throw new BadRequestException('host is required');
|
||||
}
|
||||
|
||||
const business = await this.prisma.business.findUnique({ where: { id: businessId } });
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
|
||||
const hasPrimary = await this.prisma.domain.findFirst({
|
||||
where: { businessId, isPrimary: true },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const isPrimary = dto.isPrimary ?? !hasPrimary;
|
||||
|
||||
return this.prisma.domain.create({
|
||||
data: {
|
||||
businessId,
|
||||
host,
|
||||
isPrimary,
|
||||
isVerified: false,
|
||||
sslEnabled: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateDomain(
|
||||
businessIdRaw: string,
|
||||
domainIdRaw: string,
|
||||
dto: UpdateDomainDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const domainId = BigInt(domainIdRaw);
|
||||
const host = dto.host.trim();
|
||||
|
||||
if (!host) {
|
||||
throw new BadRequestException('host is required');
|
||||
}
|
||||
|
||||
const domain = await this.prisma.domain.findFirst({
|
||||
where: { id: domainId, businessId },
|
||||
});
|
||||
|
||||
if (!domain) {
|
||||
throw new NotFoundException('Domain not found');
|
||||
}
|
||||
|
||||
const existing = await this.prisma.domain.findUnique({ where: { host } });
|
||||
if (existing && existing.id !== domainId) {
|
||||
throw new ConflictException('Domain host is already taken');
|
||||
}
|
||||
|
||||
return this.prisma.domain.update({
|
||||
where: { id: domainId },
|
||||
data: { host },
|
||||
});
|
||||
}
|
||||
|
||||
async disable(businessIdRaw: string, dto: DisableBusinessDto, actor: AuthUser) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const business = await this.prisma.business.findUnique({ where: { id: businessId } });
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
|
||||
return this.prisma.business.update({
|
||||
where: { id: businessId },
|
||||
data: { isActive: dto.isActive },
|
||||
});
|
||||
}
|
||||
|
||||
async remove(businessIdRaw: string, actor: AuthUser) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const business = await this.prisma.business.findUnique({ where: { id: businessId } });
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
|
||||
await this.prisma.business.delete({ where: { id: businessId } });
|
||||
|
||||
return { message: 'Business removed' };
|
||||
}
|
||||
|
||||
private async assertSlugAvailable(slug: string, excludeId?: bigint) {
|
||||
const existing = await this.prisma.business.findUnique({ where: { slug } });
|
||||
if (existing && existing.id !== excludeId) {
|
||||
throw new ConflictException('Business slug is already taken');
|
||||
}
|
||||
}
|
||||
|
||||
private async validateCategoryIds(categoryIds: number[]) {
|
||||
const ids = [...new Set(categoryIds)].map((id) => BigInt(id));
|
||||
const count = await this.prisma.businessCategory.count({
|
||||
where: { id: { in: ids }, isActive: true },
|
||||
});
|
||||
if (count !== ids.length) {
|
||||
throw new BadRequestException('One or more categoryIds are invalid');
|
||||
}
|
||||
}
|
||||
|
||||
private async createOwnerUser(
|
||||
tx: Prisma.TransactionClient,
|
||||
dto: Pick<
|
||||
CreateBusinessDto,
|
||||
'ownerFirstName' | 'ownerLastName' | 'ownerCellNumber' | 'ownerPassword'
|
||||
>,
|
||||
) {
|
||||
const existing = await tx.user.findUnique({
|
||||
where: { cellNumber: dto.ownerCellNumber },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('A user with this cell number already exists');
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.ownerPassword, 10);
|
||||
|
||||
return tx.user.create({
|
||||
data: {
|
||||
cellNumber: dto.ownerCellNumber,
|
||||
passwordHash,
|
||||
firstName: dto.ownerFirstName.trim(),
|
||||
lastName: dto.ownerLastName.trim(),
|
||||
cellVerifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async findOwnerUser(ownerUserId: number) {
|
||||
const owner = await this.prisma.user.findUnique({
|
||||
where: { id: BigInt(ownerUserId) },
|
||||
});
|
||||
if (!owner || !owner.isActive) {
|
||||
throw new NotFoundException('Owner user not found');
|
||||
}
|
||||
return owner;
|
||||
}
|
||||
|
||||
private async assignOwner(
|
||||
tx: Prisma.TransactionClient,
|
||||
businessId: bigint,
|
||||
ownerUserId: bigint,
|
||||
invitedBy: bigint,
|
||||
) {
|
||||
const businessOwnerRole = await tx.role.findUnique({
|
||||
where: { slug: 'business_owner' },
|
||||
});
|
||||
if (!businessOwnerRole) {
|
||||
throw new Error('business_owner role is missing');
|
||||
}
|
||||
|
||||
await tx.businessUser.upsert({
|
||||
where: {
|
||||
businessId_userId: { businessId, userId: ownerUserId },
|
||||
},
|
||||
create: {
|
||||
businessId,
|
||||
userId: ownerUserId,
|
||||
isOwner: true,
|
||||
invitedBy,
|
||||
},
|
||||
update: {
|
||||
isOwner: true,
|
||||
roleId: null,
|
||||
invitedBy,
|
||||
},
|
||||
});
|
||||
|
||||
const hasRole = await tx.userRole.findUnique({
|
||||
where: {
|
||||
userId_roleId: {
|
||||
userId: ownerUserId,
|
||||
roleId: businessOwnerRole.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasRole) {
|
||||
await tx.userRole.create({
|
||||
data: { userId: ownerUserId, roleId: businessOwnerRole.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private serializeBusiness(
|
||||
business: {
|
||||
id: bigint;
|
||||
name: string;
|
||||
nameFa: string | null;
|
||||
about: string | null;
|
||||
slug: string;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
categoryAssignments: {
|
||||
category: {
|
||||
id: bigint;
|
||||
name: string;
|
||||
slug: string;
|
||||
parentId: bigint | null;
|
||||
};
|
||||
}[];
|
||||
businessUsers: {
|
||||
user: {
|
||||
id: bigint;
|
||||
cellNumber: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
email: string | null;
|
||||
};
|
||||
}[];
|
||||
domains: {
|
||||
id: bigint;
|
||||
host: string;
|
||||
isPrimary: boolean;
|
||||
isVerified: boolean;
|
||||
sslEnabled: boolean;
|
||||
}[];
|
||||
},
|
||||
) {
|
||||
const owner = business.businessUsers[0]?.user ?? null;
|
||||
|
||||
return {
|
||||
id: business.id,
|
||||
name: business.name,
|
||||
nameFa: business.nameFa,
|
||||
about: business.about,
|
||||
slug: business.slug,
|
||||
isActive: business.isActive,
|
||||
createdAt: business.createdAt,
|
||||
updatedAt: business.updatedAt,
|
||||
categories: business.categoryAssignments.map((a) => ({
|
||||
id: a.category.id,
|
||||
name: a.category.name,
|
||||
slug: a.category.slug,
|
||||
parentId: a.category.parentId,
|
||||
})),
|
||||
categoryIds: business.categoryAssignments.map((a) => a.category.id),
|
||||
owner: owner
|
||||
? {
|
||||
id: owner.id,
|
||||
cellNumber: owner.cellNumber,
|
||||
firstName: owner.firstName,
|
||||
lastName: owner.lastName,
|
||||
email: owner.email,
|
||||
}
|
||||
: null,
|
||||
ownerUserId: owner?.id ?? null,
|
||||
domains: business.domains,
|
||||
};
|
||||
}
|
||||
|
||||
private formatBusinessLabel(business: {
|
||||
name: string;
|
||||
nameFa: string | null;
|
||||
slug: string;
|
||||
}): string {
|
||||
if (business.nameFa && business.nameFa !== business.name) {
|
||||
return `${business.name} / ${business.nameFa}`;
|
||||
}
|
||||
return business.name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { BusinessCategoriesService } from './business-categories.service';
|
||||
|
||||
@Controller('business-categories')
|
||||
export class BusinessCategoriesController {
|
||||
constructor(private readonly service: BusinessCategoriesService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
list(@CurrentUser() user: AuthUser) {
|
||||
return this.service.list(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Injectable, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
|
||||
@Injectable()
|
||||
export class BusinessCategoriesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
) {}
|
||||
|
||||
async list(actor: AuthUser) {
|
||||
const isSuperAdmin = await this.permissions.isSuperAdmin(actor.id);
|
||||
const canRead =
|
||||
isSuperAdmin ||
|
||||
actor.roles.includes('business_owner') ||
|
||||
actor.roles.includes('business_staff');
|
||||
|
||||
if (!canRead) {
|
||||
throw new ForbiddenException('Access denied');
|
||||
}
|
||||
|
||||
const categories = await this.prisma.businessCategory.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
parentId: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
icon: true,
|
||||
sortOrder: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { items: categories };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IsBoolean, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class AddDomainDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
host!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPrimary?: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class CreateBusinessDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
nameFa!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
about?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
||||
message: 'slug must be lowercase letters, numbers, and hyphens',
|
||||
})
|
||||
slug?: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@Type(() => Number)
|
||||
@IsInt({ each: true })
|
||||
categoryIds!: number[];
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
ownerFirstName!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
ownerLastName!: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^\+[1-9]\d{6,14}$/, {
|
||||
message: 'ownerCellNumber must be in E.164 format (e.g. +989121234567)',
|
||||
})
|
||||
ownerCellNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
ownerPassword!: string;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsBoolean } from 'class-validator';
|
||||
|
||||
export class DisableBusinessDto {
|
||||
@IsBoolean()
|
||||
isActive!: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export class ListBusinessesDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(5)
|
||||
@Max(50)
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
domain?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsOptional, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class SearchBusinessesDto {
|
||||
@IsString()
|
||||
@MinLength(2, { message: 'q must be at least 2 characters' })
|
||||
q!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
limit?: number = 20;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class UpdateBusinessDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
nameFa?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
about?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
||||
message: 'slug must be lowercase letters, numbers, and hyphens',
|
||||
})
|
||||
slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@Type(() => Number)
|
||||
@IsInt({ each: true })
|
||||
categoryIds?: number[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, value) => value !== null)
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
ownerUserId?: number | null;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class UpdateDomainDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
host!: string;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Body, Controller, Get, Param, Patch, UseGuards } from '@nestjs/common';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { BusinessProfileService } from './business-profile.service';
|
||||
import { UpdateBusinessProfileDto } from './dto/update-business-profile.dto';
|
||||
|
||||
@Controller('businesses/:businessId/profile')
|
||||
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||
export class BusinessProfileController {
|
||||
constructor(private readonly service: BusinessProfileService) {}
|
||||
|
||||
@Get()
|
||||
@RequireBusinessPermission('business.read')
|
||||
get(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) {
|
||||
return this.service.get(businessId, user);
|
||||
}
|
||||
|
||||
@Patch()
|
||||
@RequireBusinessPermission('business.update')
|
||||
update(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: UpdateBusinessProfileDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.update(businessId, dto, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BusinessProfileController } from './business-profile.controller';
|
||||
import { BusinessProfileService } from './business-profile.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [BusinessProfileController],
|
||||
providers: [BusinessProfileService],
|
||||
})
|
||||
export class BusinessProfileModule {}
|
||||
@@ -0,0 +1,510 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { MediaType, Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import sharp from 'sharp';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { UpdateBusinessProfileDto } from './dto/update-business-profile.dto';
|
||||
import {
|
||||
BusinessAddress,
|
||||
BusinessProfile,
|
||||
DEFAULT_BUSINESS_SOCIAL_MEDIA,
|
||||
} from './business-profile.types';
|
||||
import {
|
||||
normalizeEmails,
|
||||
normalizePhoneNumbers,
|
||||
normalizeSocialMedia,
|
||||
toPrismaJsonEmails,
|
||||
toPrismaJsonPhoneNumbers,
|
||||
toPrismaJsonSocialMedia,
|
||||
} from './business-profile.util';
|
||||
|
||||
const FAVICON_SIZE = 64;
|
||||
const FAVICON_RADIUS = 14;
|
||||
const FAVICON_PADDING = 8;
|
||||
|
||||
function roundedRectSvg(size: number, radius: number) {
|
||||
return Buffer.from(
|
||||
`<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="${size}" height="${size}" rx="${radius}" ry="${radius}" fill="#fff"/>
|
||||
</svg>`,
|
||||
);
|
||||
}
|
||||
|
||||
function roundedMaskSvg(size: number, radius: number) {
|
||||
return Buffer.from(
|
||||
`<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="${size}" height="${size}" rx="${radius}" ry="${radius}" fill="#fff"/>
|
||||
</svg>`,
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BusinessProfileService {
|
||||
private readonly logger = new Logger(BusinessProfileService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly storage: StorageService,
|
||||
) {}
|
||||
|
||||
async get(businessIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'business.read');
|
||||
|
||||
let business = await this.prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
include: {
|
||||
categoryAssignments: true,
|
||||
addresses: { orderBy: { createdAt: 'asc' } },
|
||||
logoMedia: true,
|
||||
faviconMedia: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
|
||||
// Backfill or upgrade favicon when logo exists but favicon is missing / outdated.
|
||||
if (business.logoMediaId) {
|
||||
const faviconMeta = business.faviconMedia?.metadata as
|
||||
| { faviconStyle?: string }
|
||||
| null
|
||||
| undefined;
|
||||
const needsFavicon =
|
||||
!business.faviconMediaId || faviconMeta?.faviconStyle !== 'rounded-v1';
|
||||
|
||||
if (needsFavicon) {
|
||||
try {
|
||||
await this.syncFaviconFromLogo(
|
||||
businessId,
|
||||
business.logoMediaId,
|
||||
actor.id,
|
||||
business.faviconMediaId,
|
||||
);
|
||||
business = await this.prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
include: {
|
||||
categoryAssignments: true,
|
||||
addresses: { orderBy: { createdAt: 'asc' } },
|
||||
logoMedia: true,
|
||||
faviconMedia: true,
|
||||
},
|
||||
});
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Favicon sync failed for business ${businessIdRaw}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
|
||||
return {
|
||||
businessId: business.id.toString(),
|
||||
profile: this.serializeProfile(business),
|
||||
addresses: business.addresses.map((item) => this.serializeAddress(item)),
|
||||
};
|
||||
}
|
||||
|
||||
async update(
|
||||
businessIdRaw: string,
|
||||
dto: UpdateBusinessProfileDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'business.update');
|
||||
|
||||
const business = await this.prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
include: {
|
||||
categoryAssignments: true,
|
||||
addresses: true,
|
||||
logoMedia: true,
|
||||
faviconMedia: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
|
||||
if (dto.categoryIds) {
|
||||
await this.validateCategoryIds(dto.categoryIds);
|
||||
}
|
||||
|
||||
if (dto.logoMediaId !== undefined && dto.logoMediaId !== null) {
|
||||
await this.assertLogoMedia(businessId, BigInt(dto.logoMediaId));
|
||||
}
|
||||
|
||||
const previousFaviconMediaId = business.faviconMediaId;
|
||||
const logoChanged =
|
||||
dto.logoMediaId !== undefined &&
|
||||
(dto.logoMediaId === null
|
||||
? business.logoMediaId !== null
|
||||
: business.logoMediaId?.toString() !== String(dto.logoMediaId));
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const data: Prisma.BusinessUpdateInput = {};
|
||||
|
||||
if (dto.nameEn !== undefined) data.name = dto.nameEn.trim();
|
||||
if (dto.nameFa !== undefined) data.nameFa = dto.nameFa.trim();
|
||||
if (dto.about !== undefined) data.about = dto.about.trim() || null;
|
||||
if (dto.vision !== undefined) data.vision = dto.vision.trim() || null;
|
||||
if (dto.emails !== undefined) {
|
||||
data.emails = toPrismaJsonEmails(
|
||||
dto.emails.map((item) => item.trim()).filter(Boolean),
|
||||
);
|
||||
}
|
||||
if (dto.phoneNumbers !== undefined) {
|
||||
data.phoneNumbers = toPrismaJsonPhoneNumbers(dto.phoneNumbers);
|
||||
}
|
||||
if (dto.socialMedia !== undefined) {
|
||||
data.socialMedia = toPrismaJsonSocialMedia({
|
||||
...DEFAULT_BUSINESS_SOCIAL_MEDIA,
|
||||
...normalizeSocialMedia(business.socialMedia),
|
||||
...dto.socialMedia,
|
||||
});
|
||||
}
|
||||
if (dto.logoMediaId !== undefined) {
|
||||
data.logoMedia =
|
||||
dto.logoMediaId === null
|
||||
? { disconnect: true }
|
||||
: { connect: { id: BigInt(dto.logoMediaId) } };
|
||||
|
||||
if (dto.logoMediaId === null) {
|
||||
data.faviconMedia = { disconnect: true };
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(data).length > 0) {
|
||||
await tx.business.update({
|
||||
where: { id: businessId },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
if (dto.categoryIds) {
|
||||
await tx.businessCategoryAssignment.deleteMany({
|
||||
where: { businessId },
|
||||
});
|
||||
await tx.businessCategoryAssignment.createMany({
|
||||
data: dto.categoryIds.map((id) => ({
|
||||
businessId,
|
||||
categoryId: BigInt(id),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (dto.addresses) {
|
||||
await this.syncAddresses(tx, businessId, dto.addresses);
|
||||
}
|
||||
});
|
||||
|
||||
if (logoChanged) {
|
||||
if (dto.logoMediaId === null) {
|
||||
await this.deleteFaviconMedia(previousFaviconMediaId);
|
||||
} else if (dto.logoMediaId != null) {
|
||||
try {
|
||||
await this.syncFaviconFromLogo(
|
||||
businessId,
|
||||
BigInt(dto.logoMediaId),
|
||||
actor.id,
|
||||
previousFaviconMediaId,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to generate favicon for business ${businessIdRaw}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.get(businessIdRaw, actor);
|
||||
}
|
||||
|
||||
private async syncFaviconFromLogo(
|
||||
businessId: bigint,
|
||||
logoMediaId: bigint,
|
||||
uploadedBy: bigint,
|
||||
previousFaviconMediaId: bigint | null,
|
||||
) {
|
||||
const business = await this.prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
select: { slug: true },
|
||||
});
|
||||
if (!business) return;
|
||||
|
||||
const logo = await this.prisma.media.findFirst({
|
||||
where: { id: logoMediaId, businessId },
|
||||
});
|
||||
if (!logo) {
|
||||
throw new BadRequestException('Logo media not found for this business');
|
||||
}
|
||||
|
||||
const sourceBuffer = await this.storage.getBuffer(
|
||||
logo.storagePath,
|
||||
logo.storageDisk,
|
||||
);
|
||||
|
||||
const innerSize = FAVICON_SIZE - FAVICON_PADDING * 2;
|
||||
const logoLayer = await sharp(sourceBuffer)
|
||||
.resize(innerSize, innerSize, {
|
||||
fit: 'contain',
|
||||
background: { r: 255, g: 255, b: 255, alpha: 0 },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
// White rounded card + centered logo, then clip to rounded alpha
|
||||
// so the tab icon shows soft corners (ChatGPT-style).
|
||||
const composed = await sharp(roundedRectSvg(FAVICON_SIZE, FAVICON_RADIUS))
|
||||
.composite([
|
||||
{
|
||||
input: logoLayer,
|
||||
top: FAVICON_PADDING,
|
||||
left: FAVICON_PADDING,
|
||||
},
|
||||
])
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const faviconBuffer = await sharp(composed)
|
||||
.composite([
|
||||
{
|
||||
input: await sharp(roundedMaskSvg(FAVICON_SIZE, FAVICON_RADIUS))
|
||||
.png()
|
||||
.toBuffer(),
|
||||
blend: 'dest-in',
|
||||
},
|
||||
])
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const fileName = `${randomUUID()}-favicon.png`;
|
||||
const storageKey = `businesses/${business.slug}/${businessId}/media/${fileName}`;
|
||||
const stored = await this.storage.upload({
|
||||
key: storageKey,
|
||||
body: faviconBuffer,
|
||||
contentType: 'image/png',
|
||||
});
|
||||
|
||||
const favicon = await this.prisma.media.create({
|
||||
data: {
|
||||
businessId,
|
||||
uploadedBy,
|
||||
mediaType: MediaType.image,
|
||||
storageDisk: stored.storageDisk,
|
||||
storagePath: stored.storagePath,
|
||||
publicUrl: stored.publicUrl,
|
||||
fileName,
|
||||
originalFileName: 'favicon.png',
|
||||
mimeType: 'image/png',
|
||||
fileSizeBytes: BigInt(faviconBuffer.length),
|
||||
width: FAVICON_SIZE,
|
||||
height: FAVICON_SIZE,
|
||||
altText: 'Business favicon',
|
||||
metadata: {
|
||||
derivedFrom: 'logo',
|
||||
sourceMediaId: logoMediaId.toString(),
|
||||
purpose: 'favicon',
|
||||
faviconStyle: 'rounded-v1',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.business.update({
|
||||
where: { id: businessId },
|
||||
data: { faviconMediaId: favicon.id },
|
||||
});
|
||||
|
||||
if (
|
||||
previousFaviconMediaId &&
|
||||
previousFaviconMediaId.toString() !== favicon.id.toString()
|
||||
) {
|
||||
await this.deleteFaviconMedia(previousFaviconMediaId);
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteFaviconMedia(faviconMediaId: bigint | null) {
|
||||
if (!faviconMediaId) return;
|
||||
|
||||
const media = await this.prisma.media.findUnique({
|
||||
where: { id: faviconMediaId },
|
||||
});
|
||||
if (!media) return;
|
||||
|
||||
await this.prisma.media.delete({ where: { id: faviconMediaId } }).catch(() => {
|
||||
// already removed or still referenced
|
||||
});
|
||||
|
||||
try {
|
||||
await this.storage.delete(media.storagePath, media.storageDisk);
|
||||
} catch {
|
||||
// orphaned object can be cleaned later
|
||||
}
|
||||
}
|
||||
|
||||
private serializeProfile(business: {
|
||||
name: string;
|
||||
nameFa: string | null;
|
||||
about: string | null;
|
||||
vision: string | null;
|
||||
emails: unknown;
|
||||
phoneNumbers: unknown;
|
||||
socialMedia: unknown;
|
||||
logoMediaId: bigint | null;
|
||||
logoMedia: { publicUrl: string } | null;
|
||||
faviconMediaId: bigint | null;
|
||||
faviconMedia: { publicUrl: string } | null;
|
||||
categoryAssignments: { categoryId: bigint }[];
|
||||
}): BusinessProfile {
|
||||
return {
|
||||
nameEn: business.name,
|
||||
nameFa: business.nameFa ?? '',
|
||||
about: business.about ?? '',
|
||||
vision: business.vision ?? '',
|
||||
emails: normalizeEmails(business.emails),
|
||||
phoneNumbers: normalizePhoneNumbers(business.phoneNumbers),
|
||||
socialMedia: normalizeSocialMedia(business.socialMedia),
|
||||
logoMediaId: business.logoMediaId?.toString() ?? null,
|
||||
logoUrl: business.logoMedia?.publicUrl ?? null,
|
||||
faviconMediaId: business.faviconMediaId?.toString() ?? null,
|
||||
faviconUrl:
|
||||
business.faviconMedia?.publicUrl ??
|
||||
business.logoMedia?.publicUrl ??
|
||||
null,
|
||||
categoryIds: business.categoryAssignments.map((item) =>
|
||||
item.categoryId.toString(),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private serializeAddress(address: {
|
||||
id: bigint;
|
||||
province: string;
|
||||
city: string;
|
||||
address: string;
|
||||
postalCode: string | null;
|
||||
landline: string | null;
|
||||
}): BusinessAddress {
|
||||
return {
|
||||
id: address.id.toString(),
|
||||
province: address.province,
|
||||
city: address.city,
|
||||
address: address.address,
|
||||
postalCode: address.postalCode,
|
||||
landline: address.landline,
|
||||
};
|
||||
}
|
||||
|
||||
private async syncAddresses(
|
||||
tx: Prisma.TransactionClient,
|
||||
businessId: bigint,
|
||||
addresses: UpdateBusinessProfileDto['addresses'],
|
||||
) {
|
||||
if (!addresses) return;
|
||||
|
||||
const existing = await tx.address.findMany({
|
||||
where: { businessId },
|
||||
select: { id: true },
|
||||
});
|
||||
const existingIds = new Set(existing.map((item) => item.id.toString()));
|
||||
const keepIds = new Set<string>();
|
||||
|
||||
for (const item of addresses) {
|
||||
const payload = {
|
||||
province: item.province.trim(),
|
||||
city: item.city.trim(),
|
||||
address: item.address.trim(),
|
||||
postalCode: item.postalCode.trim(),
|
||||
landline: item.landline?.trim() || null,
|
||||
};
|
||||
|
||||
if (item.id && existingIds.has(item.id)) {
|
||||
keepIds.add(item.id);
|
||||
await tx.address.update({
|
||||
where: { id: BigInt(item.id) },
|
||||
data: payload,
|
||||
});
|
||||
} else {
|
||||
await tx.address.create({
|
||||
data: {
|
||||
businessId,
|
||||
...payload,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const removeIds = [...existingIds].filter((id) => !keepIds.has(id));
|
||||
if (removeIds.length > 0) {
|
||||
await tx.address.deleteMany({
|
||||
where: {
|
||||
businessId,
|
||||
id: { in: removeIds.map((id) => BigInt(id)) },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async validateCategoryIds(categoryIds: number[]) {
|
||||
const found = await this.prisma.businessCategory.count({
|
||||
where: {
|
||||
id: { in: categoryIds.map((id) => BigInt(id)) },
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (found !== categoryIds.length) {
|
||||
throw new BadRequestException('One or more activity categories are invalid');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertLogoMedia(businessId: bigint, mediaId: bigint) {
|
||||
const media = await this.prisma.media.findFirst({
|
||||
where: { id: mediaId, businessId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
throw new BadRequestException('Logo media not found for this business');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertPermission(
|
||||
businessId: bigint,
|
||||
userId: bigint,
|
||||
permission: string,
|
||||
) {
|
||||
const allowed = await this.permissions.hasBusinessPermission(
|
||||
userId,
|
||||
businessId,
|
||||
permission,
|
||||
);
|
||||
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException('Insufficient permissions');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
export type BusinessPhoneType = 'landline' | 'cell';
|
||||
|
||||
export type BusinessPhoneNumber = {
|
||||
type: BusinessPhoneType;
|
||||
number: string;
|
||||
};
|
||||
|
||||
export type BusinessSocialMedia = {
|
||||
whatsapp: string;
|
||||
telegram: string;
|
||||
instagram: string;
|
||||
linkedin: string;
|
||||
youtube: string;
|
||||
aparat: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_BUSINESS_SOCIAL_MEDIA: BusinessSocialMedia = {
|
||||
whatsapp: '',
|
||||
telegram: '',
|
||||
instagram: '',
|
||||
linkedin: '',
|
||||
youtube: '',
|
||||
aparat: '',
|
||||
};
|
||||
|
||||
export type BusinessProfile = {
|
||||
nameEn: string;
|
||||
nameFa: string;
|
||||
about: string;
|
||||
vision: string;
|
||||
emails: string[];
|
||||
phoneNumbers: BusinessPhoneNumber[];
|
||||
socialMedia: BusinessSocialMedia;
|
||||
logoMediaId: string | null;
|
||||
logoUrl: string | null;
|
||||
faviconMediaId: string | null;
|
||||
faviconUrl: string | null;
|
||||
categoryIds: string[];
|
||||
};
|
||||
|
||||
export type BusinessAddress = {
|
||||
id: string;
|
||||
province: string;
|
||||
city: string;
|
||||
address: string;
|
||||
postalCode: string | null;
|
||||
landline: string | null;
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
BusinessPhoneNumber,
|
||||
BusinessSocialMedia,
|
||||
DEFAULT_BUSINESS_SOCIAL_MEDIA,
|
||||
} from './business-profile.types';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(value: unknown, fallback = '') {
|
||||
return typeof value === 'string' ? value : fallback;
|
||||
}
|
||||
|
||||
export function normalizeEmails(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function normalizePhoneNumbers(raw: unknown): BusinessPhoneNumber[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
return raw
|
||||
.filter(isRecord)
|
||||
.map((item) => ({
|
||||
type: (item.type === 'landline' ? 'landline' : 'cell') as BusinessPhoneNumber['type'],
|
||||
number: readString(item.number).trim(),
|
||||
}))
|
||||
.filter((item) => item.number.length > 0);
|
||||
}
|
||||
|
||||
export function normalizeSocialMedia(raw: unknown): BusinessSocialMedia {
|
||||
const source = isRecord(raw) ? raw : {};
|
||||
|
||||
return {
|
||||
whatsapp: readString(source.whatsapp),
|
||||
telegram: readString(source.telegram),
|
||||
instagram: readString(source.instagram),
|
||||
linkedin: readString(source.linkedin),
|
||||
youtube: readString(source.youtube),
|
||||
aparat: readString(source.aparat),
|
||||
};
|
||||
}
|
||||
|
||||
export function toPrismaJsonEmails(emails: string[]) {
|
||||
return emails;
|
||||
}
|
||||
|
||||
export function toPrismaJsonPhoneNumbers(phoneNumbers: BusinessPhoneNumber[]) {
|
||||
return phoneNumbers;
|
||||
}
|
||||
|
||||
export function toPrismaJsonSocialMedia(socialMedia: BusinessSocialMedia) {
|
||||
return socialMedia ?? DEFAULT_BUSINESS_SOCIAL_MEDIA;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
class BusinessPhoneNumberDto {
|
||||
@IsIn(['landline', 'cell'])
|
||||
type!: 'landline' | 'cell';
|
||||
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
number!: string;
|
||||
}
|
||||
|
||||
class BusinessSocialMediaDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
whatsapp?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
telegram?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
instagram?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
linkedin?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
youtube?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
aparat?: string;
|
||||
}
|
||||
|
||||
class BusinessAddressDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
id?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
province!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
city!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
address!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
postalCode!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
landline?: string | null;
|
||||
}
|
||||
|
||||
export class UpdateBusinessProfileDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
nameEn?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
nameFa?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
about?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
vision?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
emails?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BusinessPhoneNumberDto)
|
||||
phoneNumbers?: BusinessPhoneNumberDto[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => BusinessSocialMediaDto)
|
||||
socialMedia?: BusinessSocialMediaDto;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
logoMediaId?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@Type(() => Number)
|
||||
@IsInt({ each: true })
|
||||
categoryIds?: number[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BusinessAddressDto)
|
||||
addresses?: BusinessAddressDto[];
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
export const BUSINESS_PRIMARY_COLOR_IDS = [
|
||||
'red',
|
||||
'yellow',
|
||||
'black',
|
||||
'cyan',
|
||||
'purple',
|
||||
'light-blue',
|
||||
'dark-blue',
|
||||
] as const;
|
||||
|
||||
export type BusinessPrimaryColorId = (typeof BUSINESS_PRIMARY_COLOR_IDS)[number];
|
||||
|
||||
export const DEFAULT_BUSINESS_PRIMARY_COLOR_ID: BusinessPrimaryColorId =
|
||||
'dark-blue';
|
||||
|
||||
export type BusinessPrimaryColorTokens = {
|
||||
label: string;
|
||||
primary: string;
|
||||
primaryDark: string;
|
||||
primaryLight: string;
|
||||
primaryGlow: string;
|
||||
primaryRgb: string;
|
||||
};
|
||||
|
||||
export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
|
||||
BusinessPrimaryColorId,
|
||||
BusinessPrimaryColorTokens
|
||||
> = {
|
||||
red: {
|
||||
label: 'Red',
|
||||
primary: '#ef4444',
|
||||
primaryDark: '#dc2626',
|
||||
primaryLight: '#fee2e2',
|
||||
primaryGlow: '#ef4444',
|
||||
primaryRgb: '239 68 68',
|
||||
},
|
||||
yellow: {
|
||||
label: 'Yellow',
|
||||
primary: '#eab308',
|
||||
primaryDark: '#ca8a04',
|
||||
primaryLight: '#fef9c3',
|
||||
primaryGlow: '#eab308',
|
||||
primaryRgb: '234 179 8',
|
||||
},
|
||||
black: {
|
||||
label: 'Black',
|
||||
primary: '#1e293b',
|
||||
primaryDark: '#0f172a',
|
||||
primaryLight: '#e2e8f0',
|
||||
primaryGlow: '#334155',
|
||||
primaryRgb: '30 41 59',
|
||||
},
|
||||
cyan: {
|
||||
label: 'Cyan',
|
||||
primary: '#06b6d4',
|
||||
primaryDark: '#0891b2',
|
||||
primaryLight: '#cffafe',
|
||||
primaryGlow: '#06b6d4',
|
||||
primaryRgb: '6 182 212',
|
||||
},
|
||||
purple: {
|
||||
label: 'Purple',
|
||||
primary: '#a855f7',
|
||||
primaryDark: '#9333ea',
|
||||
primaryLight: '#f3e8ff',
|
||||
primaryGlow: '#a855f7',
|
||||
primaryRgb: '168 85 247',
|
||||
},
|
||||
'light-blue': {
|
||||
label: 'Light Blue',
|
||||
primary: '#38bdf8',
|
||||
primaryDark: '#0ea5e9',
|
||||
primaryLight: '#e0f2fe',
|
||||
primaryGlow: '#38bdf8',
|
||||
primaryRgb: '56 189 248',
|
||||
},
|
||||
'dark-blue': {
|
||||
label: 'Dark Blue',
|
||||
primary: '#3b82f6',
|
||||
primaryDark: '#2563eb',
|
||||
primaryLight: '#dbeafe',
|
||||
primaryGlow: '#3b82f6',
|
||||
primaryRgb: '59 130 246',
|
||||
},
|
||||
};
|
||||
|
||||
export function normalizeBusinessPrimaryColorId(
|
||||
value: unknown,
|
||||
): BusinessPrimaryColorId {
|
||||
if (
|
||||
typeof value === 'string' &&
|
||||
BUSINESS_PRIMARY_COLOR_IDS.includes(value as BusinessPrimaryColorId)
|
||||
) {
|
||||
return value as BusinessPrimaryColorId;
|
||||
}
|
||||
|
||||
return DEFAULT_BUSINESS_PRIMARY_COLOR_ID;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Body, Controller, Get, Param, Patch, UseGuards } from '@nestjs/common';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { BusinessSettingsService } from './business-settings.service';
|
||||
import { UpdateBusinessSettingsDto } from './dto/update-business-settings.dto';
|
||||
|
||||
@Controller('businesses/:businessId/settings')
|
||||
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||
export class BusinessSettingsController {
|
||||
constructor(private readonly service: BusinessSettingsService) {}
|
||||
|
||||
@Get()
|
||||
@RequireBusinessPermission('business.read')
|
||||
get(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) {
|
||||
return this.service.get(businessId, user);
|
||||
}
|
||||
|
||||
@Patch()
|
||||
@RequireBusinessPermission('business.update')
|
||||
update(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: UpdateBusinessSettingsDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.update(businessId, dto, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BusinessSettingsController } from './business-settings.controller';
|
||||
import { BusinessSettingsService } from './business-settings.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [BusinessSettingsController],
|
||||
providers: [BusinessSettingsService],
|
||||
exports: [BusinessSettingsService],
|
||||
})
|
||||
export class BusinessSettingsModule {}
|
||||
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { BusinessSettings } from './business-settings.types';
|
||||
import {
|
||||
mergeBusinessSettings,
|
||||
normalizeBusinessSettings,
|
||||
toPrismaJson,
|
||||
} from './business-settings.util';
|
||||
import { UpdateBusinessSettingsDto } from './dto/update-business-settings.dto';
|
||||
import { normalizeBusinessPrimaryColorId } from './business-primary-colors';
|
||||
|
||||
@Injectable()
|
||||
export class BusinessSettingsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
) {}
|
||||
|
||||
async get(businessIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'business.read');
|
||||
|
||||
const business = await this.prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
select: { id: true, settings: true },
|
||||
});
|
||||
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
|
||||
return {
|
||||
businessId: business.id.toString(),
|
||||
settings: normalizeBusinessSettings(business.settings),
|
||||
};
|
||||
}
|
||||
|
||||
async update(
|
||||
businessIdRaw: string,
|
||||
dto: UpdateBusinessSettingsDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'business.update');
|
||||
|
||||
const business = await this.prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
select: { id: true, settings: true },
|
||||
});
|
||||
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
|
||||
const current = normalizeBusinessSettings(business.settings);
|
||||
const patch: Partial<BusinessSettings> = {};
|
||||
|
||||
if (dto.branding) {
|
||||
patch.branding = {
|
||||
primaryColor: normalizeBusinessPrimaryColorId(
|
||||
dto.branding.primaryColor ?? current.branding.primaryColor,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (dto.dashboard) {
|
||||
patch.dashboard = {
|
||||
comments: {
|
||||
autoApprove:
|
||||
dto.dashboard.comments?.autoApprove ??
|
||||
current.dashboard.comments.autoApprove,
|
||||
},
|
||||
expertReviews: {
|
||||
autoApprove:
|
||||
dto.dashboard.expertReviews?.autoApprove ??
|
||||
current.dashboard.expertReviews.autoApprove,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (dto.store) {
|
||||
patch.store = {
|
||||
onlineSellEnabled:
|
||||
dto.store.onlineSellEnabled ?? current.store.onlineSellEnabled,
|
||||
orderProcessSteps:
|
||||
dto.store.orderProcessSteps ?? current.store.orderProcessSteps,
|
||||
};
|
||||
}
|
||||
|
||||
const next = mergeBusinessSettings(current, patch);
|
||||
|
||||
const updated = await this.prisma.business.update({
|
||||
where: { id: businessId },
|
||||
data: { settings: toPrismaJson(next) },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return {
|
||||
businessId: updated.id.toString(),
|
||||
settings: next,
|
||||
};
|
||||
}
|
||||
|
||||
async getNormalizedSettings(businessId: bigint): Promise<BusinessSettings> {
|
||||
const business = await this.prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
select: { settings: true },
|
||||
});
|
||||
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
|
||||
return normalizeBusinessSettings(business.settings);
|
||||
}
|
||||
|
||||
async isCommentsAutoApprove(businessId: bigint) {
|
||||
const settings = await this.getNormalizedSettings(businessId);
|
||||
return settings.dashboard.comments.autoApprove;
|
||||
}
|
||||
|
||||
async isExpertReviewsAutoApprove(businessId: bigint) {
|
||||
const settings = await this.getNormalizedSettings(businessId);
|
||||
return settings.dashboard.expertReviews.autoApprove;
|
||||
}
|
||||
|
||||
async isOnlineSellEnabled(businessId: bigint) {
|
||||
const settings = await this.getNormalizedSettings(businessId);
|
||||
return settings.store.onlineSellEnabled;
|
||||
}
|
||||
|
||||
async getOrderProcessSteps(businessId: bigint) {
|
||||
const settings = await this.getNormalizedSettings(businessId);
|
||||
return settings.store.orderProcessSteps;
|
||||
}
|
||||
|
||||
private async assertPermission(
|
||||
businessId: bigint,
|
||||
userId: bigint,
|
||||
permission: string,
|
||||
) {
|
||||
const allowed = await this.permissions.hasBusinessPermission(
|
||||
userId,
|
||||
businessId,
|
||||
permission,
|
||||
);
|
||||
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException('Insufficient permissions');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { BusinessPrimaryColorId } from './business-primary-colors';
|
||||
import { DEFAULT_BUSINESS_PRIMARY_COLOR_ID } from './business-primary-colors';
|
||||
|
||||
export type BrandingSettings = {
|
||||
primaryColor: BusinessPrimaryColorId;
|
||||
};
|
||||
|
||||
export type DashboardCommentsSettings = {
|
||||
autoApprove: boolean;
|
||||
};
|
||||
|
||||
export type DashboardExpertReviewsSettings = {
|
||||
autoApprove: boolean;
|
||||
};
|
||||
|
||||
/** Per-business dashboard settings. Add new sections here as the CMS grows. */
|
||||
export type DashboardSettings = {
|
||||
comments: DashboardCommentsSettings;
|
||||
expertReviews: DashboardExpertReviewsSettings;
|
||||
};
|
||||
|
||||
export type OrderProcessStep = {
|
||||
id: string;
|
||||
label: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
/** Per-business store / sales settings. */
|
||||
export type StoreSettings = {
|
||||
onlineSellEnabled: boolean;
|
||||
orderProcessSteps: OrderProcessStep[];
|
||||
};
|
||||
|
||||
/** Top-level business settings stored in `businesses.settings` JSONB. */
|
||||
export type BusinessSettings = {
|
||||
branding: BrandingSettings;
|
||||
dashboard: DashboardSettings;
|
||||
store: StoreSettings;
|
||||
};
|
||||
|
||||
export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [
|
||||
{ id: 'processing', label: 'Under processing', color: '#3B82F6' },
|
||||
{ id: 'ready-for-shipping', label: 'Ready for shipping', color: '#F59E0B' },
|
||||
{ id: 'shipped', label: 'Shipped', color: '#8B5CF6' },
|
||||
{ id: 'delivered', label: 'Delivered', color: '#22C55E' },
|
||||
];
|
||||
|
||||
export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
|
||||
branding: {
|
||||
primaryColor: DEFAULT_BUSINESS_PRIMARY_COLOR_ID,
|
||||
},
|
||||
dashboard: {
|
||||
comments: { autoApprove: false },
|
||||
expertReviews: { autoApprove: false },
|
||||
},
|
||||
store: {
|
||||
onlineSellEnabled: true,
|
||||
orderProcessSteps: DEFAULT_ORDER_PROCESS_STEPS,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
normalizeBusinessPrimaryColorId,
|
||||
} from './business-primary-colors';
|
||||
import {
|
||||
BusinessSettings,
|
||||
DEFAULT_BUSINESS_SETTINGS,
|
||||
DEFAULT_ORDER_PROCESS_STEPS,
|
||||
OrderProcessStep,
|
||||
} from './business-settings.types';
|
||||
import {
|
||||
defaultOrderStepColor,
|
||||
normalizeOrderStepColor,
|
||||
} from './order-step-colors';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readBoolean(value: unknown, fallback: boolean) {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
function readOrderProcessSteps(value: unknown): OrderProcessStep[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return DEFAULT_ORDER_PROCESS_STEPS;
|
||||
}
|
||||
|
||||
const steps = value
|
||||
.map((item, index) => {
|
||||
if (!isRecord(item)) return null;
|
||||
const id = typeof item.id === 'string' ? item.id.trim() : '';
|
||||
const label = typeof item.label === 'string' ? item.label.trim() : '';
|
||||
if (!id || !label) return null;
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
color: normalizeOrderStepColor(
|
||||
item.color,
|
||||
defaultOrderStepColor(id, index),
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((step): step is OrderProcessStep => step !== null);
|
||||
|
||||
return steps.length ? steps : DEFAULT_ORDER_PROCESS_STEPS;
|
||||
}
|
||||
|
||||
export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
|
||||
const source = isRecord(raw) ? raw : {};
|
||||
const branding = isRecord(source.branding) ? source.branding : {};
|
||||
const dashboard = isRecord(source.dashboard) ? source.dashboard : {};
|
||||
const comments = isRecord(dashboard.comments) ? dashboard.comments : {};
|
||||
const expertReviews = isRecord(dashboard.expertReviews)
|
||||
? dashboard.expertReviews
|
||||
: {};
|
||||
const store = isRecord(source.store) ? source.store : {};
|
||||
|
||||
return {
|
||||
branding: {
|
||||
primaryColor: normalizeBusinessPrimaryColorId(branding.primaryColor),
|
||||
},
|
||||
dashboard: {
|
||||
comments: {
|
||||
autoApprove: readBoolean(
|
||||
comments.autoApprove,
|
||||
DEFAULT_BUSINESS_SETTINGS.dashboard.comments.autoApprove,
|
||||
),
|
||||
},
|
||||
expertReviews: {
|
||||
autoApprove: readBoolean(
|
||||
expertReviews.autoApprove,
|
||||
DEFAULT_BUSINESS_SETTINGS.dashboard.expertReviews.autoApprove,
|
||||
),
|
||||
},
|
||||
},
|
||||
store: {
|
||||
onlineSellEnabled: readBoolean(
|
||||
store.onlineSellEnabled,
|
||||
DEFAULT_BUSINESS_SETTINGS.store.onlineSellEnabled,
|
||||
),
|
||||
orderProcessSteps: readOrderProcessSteps(store.orderProcessSteps),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeBusinessSettings(
|
||||
current: BusinessSettings,
|
||||
patch: Partial<BusinessSettings>,
|
||||
): BusinessSettings {
|
||||
return {
|
||||
branding: {
|
||||
primaryColor:
|
||||
patch.branding?.primaryColor ?? current.branding.primaryColor,
|
||||
},
|
||||
dashboard: {
|
||||
comments: {
|
||||
autoApprove:
|
||||
patch.dashboard?.comments?.autoApprove ??
|
||||
current.dashboard.comments.autoApprove,
|
||||
},
|
||||
expertReviews: {
|
||||
autoApprove:
|
||||
patch.dashboard?.expertReviews?.autoApprove ??
|
||||
current.dashboard.expertReviews.autoApprove,
|
||||
},
|
||||
},
|
||||
store: {
|
||||
onlineSellEnabled:
|
||||
patch.store?.onlineSellEnabled ?? current.store.onlineSellEnabled,
|
||||
orderProcessSteps:
|
||||
patch.store?.orderProcessSteps ?? current.store.orderProcessSteps,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function toPrismaJson(settings: BusinessSettings): Prisma.InputJsonValue {
|
||||
return settings as Prisma.InputJsonValue;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { ORDER_STEP_COLOR_HEXES } from '../order-step-colors';
|
||||
import { BUSINESS_PRIMARY_COLOR_IDS } from '../business-primary-colors';
|
||||
|
||||
class BrandingSettingsDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn([...BUSINESS_PRIMARY_COLOR_IDS])
|
||||
primaryColor?: string;
|
||||
}
|
||||
|
||||
class DashboardCommentsSettingsDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
autoApprove?: boolean;
|
||||
}
|
||||
|
||||
class DashboardExpertReviewsSettingsDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
autoApprove?: boolean;
|
||||
}
|
||||
|
||||
class DashboardSettingsDto {
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => DashboardCommentsSettingsDto)
|
||||
comments?: DashboardCommentsSettingsDto;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => DashboardExpertReviewsSettingsDto)
|
||||
expertReviews?: DashboardExpertReviewsSettingsDto;
|
||||
}
|
||||
|
||||
class OrderProcessStepDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
id!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
label!: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn([...ORDER_STEP_COLOR_HEXES])
|
||||
color!: string;
|
||||
}
|
||||
|
||||
class StoreSettingsDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
onlineSellEnabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => OrderProcessStepDto)
|
||||
orderProcessSteps?: OrderProcessStepDto[];
|
||||
}
|
||||
|
||||
export class UpdateBusinessSettingsDto {
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => BrandingSettingsDto)
|
||||
branding?: BrandingSettingsDto;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => DashboardSettingsDto)
|
||||
dashboard?: DashboardSettingsDto;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => StoreSettingsDto)
|
||||
store?: StoreSettingsDto;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export const ORDER_STEP_COLOR_HEXES = [
|
||||
'#EF4444',
|
||||
'#F97316',
|
||||
'#F59E0B',
|
||||
'#EAB308',
|
||||
'#84CC16',
|
||||
'#22C55E',
|
||||
'#10B981',
|
||||
'#14B8A6',
|
||||
'#06B6D4',
|
||||
'#0EA5E9',
|
||||
'#3B82F6',
|
||||
'#6366F1',
|
||||
'#8B5CF6',
|
||||
'#A855F7',
|
||||
'#D946EF',
|
||||
'#EC4899',
|
||||
'#F43F5E',
|
||||
'#78716C',
|
||||
'#6B7280',
|
||||
'#64748B',
|
||||
'#111827',
|
||||
'#92400E',
|
||||
'#1E3A5F',
|
||||
'#D4AF37',
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_ORDER_STEP_COLOR = '#3B82F6';
|
||||
|
||||
const DEFAULT_STEP_COLORS_BY_ID: Record<string, string> = {
|
||||
processing: '#3B82F6',
|
||||
'ready-for-shipping': '#F59E0B',
|
||||
shipped: '#8B5CF6',
|
||||
delivered: '#22C55E',
|
||||
};
|
||||
|
||||
export function isOrderStepColor(value: string): boolean {
|
||||
return ORDER_STEP_COLOR_HEXES.includes(value as (typeof ORDER_STEP_COLOR_HEXES)[number]);
|
||||
}
|
||||
|
||||
export function normalizeOrderStepColor(
|
||||
value: unknown,
|
||||
fallback = DEFAULT_ORDER_STEP_COLOR,
|
||||
): string {
|
||||
if (typeof value === 'string' && isOrderStepColor(value)) {
|
||||
return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function defaultOrderStepColor(id: string, index = 0): string {
|
||||
return (
|
||||
DEFAULT_STEP_COLORS_BY_ID[id] ??
|
||||
ORDER_STEP_COLOR_HEXES[index % ORDER_STEP_COLOR_HEXES.length]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { BusinessTeamService } from './business-team.service';
|
||||
import { AddTeamMemberDto } from './dto/add-team-member.dto';
|
||||
import { UpdateTeamMemberDto } from './dto/update-team-member.dto';
|
||||
|
||||
@Controller('businesses/:businessId/team')
|
||||
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||
export class BusinessTeamController {
|
||||
constructor(private readonly teamService: BusinessTeamService) {}
|
||||
|
||||
@Get()
|
||||
@RequireBusinessPermission('business.team.read')
|
||||
list(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) {
|
||||
return this.teamService.list(BigInt(businessId), user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequireBusinessPermission('business.team.invite')
|
||||
add(
|
||||
@Param('businessId') businessId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() dto: AddTeamMemberDto,
|
||||
) {
|
||||
return this.teamService.add(BigInt(businessId), user, dto);
|
||||
}
|
||||
|
||||
@Patch(':memberId')
|
||||
@RequireBusinessPermission('business.team.update')
|
||||
update(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('memberId') memberId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() dto: UpdateTeamMemberDto,
|
||||
) {
|
||||
return this.teamService.update(
|
||||
BigInt(businessId),
|
||||
BigInt(memberId),
|
||||
user,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':memberId')
|
||||
@RequireBusinessPermission('business.team.remove')
|
||||
remove(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('memberId') memberId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.teamService.remove(
|
||||
BigInt(businessId),
|
||||
BigInt(memberId),
|
||||
user,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BusinessTeamController } from './business-team.controller';
|
||||
import { BusinessTeamService } from './business-team.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [BusinessTeamController],
|
||||
providers: [BusinessTeamService],
|
||||
})
|
||||
export class BusinessTeamModule {}
|
||||
@@ -0,0 +1,284 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import {
|
||||
ASSIGNABLE_TEAM_ROLES,
|
||||
AssignableTeamRole,
|
||||
AuthUser,
|
||||
} from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AddTeamMemberDto } from './dto/add-team-member.dto';
|
||||
import { UpdateTeamMemberDto } from './dto/update-team-member.dto';
|
||||
|
||||
@Injectable()
|
||||
export class BusinessTeamService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
) {}
|
||||
|
||||
async list(businessId: bigint, actor: AuthUser) {
|
||||
await this.ensureCanRead(businessId, actor.id);
|
||||
|
||||
const members = await this.prisma.businessUser.findMany({
|
||||
where: { businessId },
|
||||
include: {
|
||||
user: true,
|
||||
role: true,
|
||||
inviter: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
|
||||
return {
|
||||
members: await Promise.all(
|
||||
members.map(async (m) => ({
|
||||
id: m.id,
|
||||
userId: m.user.id,
|
||||
cellNumber: m.user.cellNumber,
|
||||
firstName: m.user.firstName,
|
||||
lastName: m.user.lastName,
|
||||
email: m.user.email,
|
||||
isOwner: m.isOwner,
|
||||
teamRole: m.isOwner ? 'business_owner' : m.role?.slug ?? null,
|
||||
permissions: await this.permissions.getPermissionsForBusiness(
|
||||
m.user.id,
|
||||
businessId,
|
||||
),
|
||||
invitedBy: m.inviter,
|
||||
createdAt: m.createdAt,
|
||||
})),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async add(businessId: bigint, actor: AuthUser, dto: AddTeamMemberDto) {
|
||||
const canInvite = await this.permissions.hasBusinessPermission(
|
||||
actor.id,
|
||||
businessId,
|
||||
'business.team.invite',
|
||||
);
|
||||
if (!canInvite) {
|
||||
throw new ForbiddenException('You cannot invite team members');
|
||||
}
|
||||
|
||||
this.assertAssignableRole(dto.roleSlug);
|
||||
|
||||
const business = await this.prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
});
|
||||
if (!business?.isActive) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
|
||||
const staffRole = await this.prisma.role.findUnique({
|
||||
where: { slug: dto.roleSlug },
|
||||
});
|
||||
if (!staffRole) {
|
||||
throw new BadRequestException('Invalid team role');
|
||||
}
|
||||
|
||||
const businessStaffRole = await this.prisma.role.findUnique({
|
||||
where: { slug: 'business_staff' },
|
||||
});
|
||||
if (!businessStaffRole) {
|
||||
throw new Error('business_staff role is missing. Run migrations.');
|
||||
}
|
||||
|
||||
const existingUser = await this.prisma.user.findUnique({
|
||||
where: { cellNumber: dto.cellNumber },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
const existingMembership = await this.prisma.businessUser.findUnique({
|
||||
where: {
|
||||
businessId_userId: { businessId, userId: existingUser.id },
|
||||
},
|
||||
});
|
||||
if (existingMembership) {
|
||||
throw new ConflictException('User is already a member of this business');
|
||||
}
|
||||
}
|
||||
|
||||
if (!existingUser && !dto.password) {
|
||||
throw new BadRequestException('password is required for new users');
|
||||
}
|
||||
|
||||
const passwordHash = existingUser
|
||||
? existingUser.passwordHash
|
||||
: await bcrypt.hash(dto.password!, 10);
|
||||
|
||||
const member = await this.prisma.$transaction(async (tx) => {
|
||||
const user =
|
||||
existingUser ??
|
||||
(await tx.user.create({
|
||||
data: {
|
||||
cellNumber: dto.cellNumber,
|
||||
passwordHash,
|
||||
email: dto.email,
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
cellVerifiedAt: new Date(),
|
||||
},
|
||||
}));
|
||||
|
||||
const businessUser = await tx.businessUser.create({
|
||||
data: {
|
||||
businessId,
|
||||
userId: user.id,
|
||||
isOwner: false,
|
||||
roleId: staffRole.id,
|
||||
invitedBy: actor.id,
|
||||
},
|
||||
include: { user: true, role: true },
|
||||
});
|
||||
|
||||
const hasStaffGlobalRole = await tx.userRole.findUnique({
|
||||
where: {
|
||||
userId_roleId: {
|
||||
userId: user.id,
|
||||
roleId: businessStaffRole.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasStaffGlobalRole) {
|
||||
await tx.userRole.create({
|
||||
data: { userId: user.id, roleId: businessStaffRole.id },
|
||||
});
|
||||
}
|
||||
|
||||
return businessUser;
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Team member added',
|
||||
member: {
|
||||
id: member.id,
|
||||
userId: member.user.id,
|
||||
cellNumber: member.user.cellNumber,
|
||||
firstName: member.user.firstName,
|
||||
lastName: member.user.lastName,
|
||||
isOwner: false,
|
||||
teamRole: member.role?.slug ?? dto.roleSlug,
|
||||
permissions: await this.permissions.getPermissionsForBusiness(
|
||||
member.user.id,
|
||||
businessId,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async update(
|
||||
businessId: bigint,
|
||||
memberId: bigint,
|
||||
actor: AuthUser,
|
||||
dto: UpdateTeamMemberDto,
|
||||
) {
|
||||
const canUpdate = await this.permissions.hasBusinessPermission(
|
||||
actor.id,
|
||||
businessId,
|
||||
'business.team.update',
|
||||
);
|
||||
if (!canUpdate) {
|
||||
throw new ForbiddenException('You cannot update team members');
|
||||
}
|
||||
|
||||
this.assertAssignableRole(dto.roleSlug);
|
||||
|
||||
const member = await this.prisma.businessUser.findFirst({
|
||||
where: { id: memberId, businessId },
|
||||
include: { user: true, role: true },
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
throw new NotFoundException('Team member not found');
|
||||
}
|
||||
|
||||
if (member.isOwner) {
|
||||
throw new ForbiddenException('Cannot change the role of a business owner');
|
||||
}
|
||||
|
||||
const staffRole = await this.prisma.role.findUnique({
|
||||
where: { slug: dto.roleSlug },
|
||||
});
|
||||
if (!staffRole) {
|
||||
throw new BadRequestException('Invalid team role');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.businessUser.update({
|
||||
where: { id: memberId },
|
||||
data: { roleId: staffRole.id },
|
||||
include: { user: true, role: true },
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Team member role updated',
|
||||
member: {
|
||||
id: updated.id,
|
||||
userId: updated.user.id,
|
||||
teamRole: updated.role?.slug ?? dto.roleSlug,
|
||||
permissions: await this.permissions.getPermissionsForBusiness(
|
||||
updated.user.id,
|
||||
businessId,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async remove(businessId: bigint, memberId: bigint, actor: AuthUser) {
|
||||
const canRemove = await this.permissions.hasBusinessPermission(
|
||||
actor.id,
|
||||
businessId,
|
||||
'business.team.remove',
|
||||
);
|
||||
if (!canRemove) {
|
||||
throw new ForbiddenException('You cannot remove team members');
|
||||
}
|
||||
|
||||
const member = await this.prisma.businessUser.findFirst({
|
||||
where: { id: memberId, businessId },
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
throw new NotFoundException('Team member not found');
|
||||
}
|
||||
|
||||
if (member.isOwner) {
|
||||
throw new ForbiddenException('Cannot remove a business owner');
|
||||
}
|
||||
|
||||
if (member.userId === actor.id) {
|
||||
throw new ForbiddenException('You cannot remove yourself');
|
||||
}
|
||||
|
||||
await this.prisma.businessUser.delete({ where: { id: memberId } });
|
||||
|
||||
return { message: 'Team member removed' };
|
||||
}
|
||||
|
||||
private async ensureCanRead(businessId: bigint, userId: bigint) {
|
||||
const canRead = await this.permissions.hasBusinessPermission(
|
||||
userId,
|
||||
businessId,
|
||||
'business.team.read',
|
||||
);
|
||||
if (!canRead) {
|
||||
throw new ForbiddenException('You cannot view this business team');
|
||||
}
|
||||
}
|
||||
|
||||
private assertAssignableRole(roleSlug: string): asserts roleSlug is AssignableTeamRole {
|
||||
if (!ASSIGNABLE_TEAM_ROLES.includes(roleSlug as AssignableTeamRole)) {
|
||||
throw new BadRequestException(
|
||||
`roleSlug must be one of: ${ASSIGNABLE_TEAM_ROLES.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { IsEmail, IsIn, IsOptional, IsString, Matches, MinLength } from 'class-validator';
|
||||
import { ASSIGNABLE_TEAM_ROLES } from '../../auth/auth.types';
|
||||
|
||||
export class AddTeamMemberDto {
|
||||
@IsString()
|
||||
@Matches(/^\+[1-9]\d{6,14}$/)
|
||||
cellNumber!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
firstName!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
lastName!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn([...ASSIGNABLE_TEAM_ROLES])
|
||||
roleSlug!: string;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { IsIn, IsString } from 'class-validator';
|
||||
import { ASSIGNABLE_TEAM_ROLES } from '../../auth/auth.types';
|
||||
|
||||
export class UpdateTeamMemberDto {
|
||||
@IsString()
|
||||
@IsIn([...ASSIGNABLE_TEAM_ROLES])
|
||||
roleSlug!: string;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import {
|
||||
AddCartItemDto,
|
||||
CheckoutCartDto,
|
||||
UpdateCartItemDto,
|
||||
} from './dto/cart.dto';
|
||||
import { CartService } from './cart.service';
|
||||
|
||||
@Controller('businesses/:businessId/cart')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class CartController {
|
||||
constructor(private readonly service: CartService) {}
|
||||
|
||||
@Get()
|
||||
getCart(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) {
|
||||
return this.service.getCart(businessId, user);
|
||||
}
|
||||
|
||||
@Post('items')
|
||||
addItem(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: AddCartItemDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.addItem(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Patch('items/:itemId')
|
||||
updateItem(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('itemId') itemId: string,
|
||||
@Body() dto: UpdateCartItemDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.updateItem(businessId, itemId, dto, user);
|
||||
}
|
||||
|
||||
@Delete('items/:itemId')
|
||||
removeItem(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('itemId') itemId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.removeItem(businessId, itemId, user);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
clear(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) {
|
||||
return this.service.clear(businessId, user);
|
||||
}
|
||||
|
||||
@Post('checkout')
|
||||
checkout(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: CheckoutCartDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.checkout(businessId, dto, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { OrdersModule } from '../orders/orders.module';
|
||||
import { CartController } from './cart.controller';
|
||||
import { CartService } from './cart.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, OrdersModule],
|
||||
controllers: [CartController],
|
||||
providers: [CartService],
|
||||
})
|
||||
export class CartModule {}
|
||||
@@ -0,0 +1,404 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ContentStatus, Prisma } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
AddCartItemDto,
|
||||
CheckoutCartDto,
|
||||
ShippingAddressDto,
|
||||
UpdateCartItemDto,
|
||||
} from './dto/cart.dto';
|
||||
import { OrdersService } from '../orders/orders.service';
|
||||
|
||||
const cartVariantInclude = {
|
||||
storeItem: {
|
||||
include: {
|
||||
product: {
|
||||
include: {
|
||||
featuredMedia: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
selections: {
|
||||
include: {
|
||||
variation: true,
|
||||
option: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.StoreItemVariantInclude;
|
||||
|
||||
type CartWithItems = Prisma.CartGetPayload<{
|
||||
include: {
|
||||
items: {
|
||||
include: {
|
||||
storeItemVariant: {
|
||||
include: typeof cartVariantInclude;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
@Injectable()
|
||||
export class CartService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly orders: OrdersService,
|
||||
) {}
|
||||
|
||||
async getCart(businessIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertCustomerAccess(businessId, actor);
|
||||
|
||||
const cart = await this.getOrCreateCart(businessId, actor.id);
|
||||
return { cart: this.serializeCart(cart) };
|
||||
}
|
||||
|
||||
async addItem(businessIdRaw: string, dto: AddCartItemDto, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const storeItemVariantId = BigInt(dto.storeItemVariantId);
|
||||
const quantity = dto.quantity ?? 1;
|
||||
await this.assertCustomerAccess(businessId, actor);
|
||||
|
||||
const variant = await this.loadPurchasableVariant(businessId, storeItemVariantId);
|
||||
this.assertStockAvailable(variant.stockQuantity, quantity);
|
||||
|
||||
const cart = await this.getOrCreateCart(businessId, actor.id);
|
||||
const existing = cart.items.find(
|
||||
(item) => item.storeItemVariantId === storeItemVariantId,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
const newQuantity = existing.quantity + quantity;
|
||||
this.assertStockAvailable(variant.stockQuantity, newQuantity);
|
||||
|
||||
await this.prisma.cartItem.update({
|
||||
where: { id: existing.id },
|
||||
data: { quantity: newQuantity },
|
||||
});
|
||||
|
||||
const refreshed = await this.loadCart(cart.id);
|
||||
return {
|
||||
message: 'Cart item quantity updated',
|
||||
cart: this.serializeCart(refreshed),
|
||||
};
|
||||
}
|
||||
|
||||
await this.prisma.cartItem.create({
|
||||
data: {
|
||||
cartId: cart.id,
|
||||
storeItemVariantId,
|
||||
quantity,
|
||||
},
|
||||
});
|
||||
|
||||
const refreshed = await this.loadCart(cart.id);
|
||||
return {
|
||||
message: 'Item added to cart',
|
||||
cart: this.serializeCart(refreshed),
|
||||
};
|
||||
}
|
||||
|
||||
async updateItem(
|
||||
businessIdRaw: string,
|
||||
itemIdRaw: string,
|
||||
dto: UpdateCartItemDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const itemId = BigInt(itemIdRaw);
|
||||
await this.assertCustomerAccess(businessId, actor);
|
||||
|
||||
const cart = await this.getOrCreateCart(businessId, actor.id);
|
||||
const item = cart.items.find((entry) => entry.id === itemId);
|
||||
|
||||
if (!item) {
|
||||
throw new NotFoundException('Cart item not found');
|
||||
}
|
||||
|
||||
this.assertStockAvailable(item.storeItemVariant.stockQuantity, dto.quantity);
|
||||
|
||||
await this.prisma.cartItem.update({
|
||||
where: { id: itemId },
|
||||
data: { quantity: dto.quantity },
|
||||
});
|
||||
|
||||
const refreshed = await this.loadCart(cart.id);
|
||||
return {
|
||||
message: 'Cart item updated',
|
||||
cart: this.serializeCart(refreshed),
|
||||
};
|
||||
}
|
||||
|
||||
async removeItem(businessIdRaw: string, itemIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const itemId = BigInt(itemIdRaw);
|
||||
await this.assertCustomerAccess(businessId, actor);
|
||||
|
||||
const cart = await this.getOrCreateCart(businessId, actor.id);
|
||||
const item = cart.items.find((entry) => entry.id === itemId);
|
||||
|
||||
if (!item) {
|
||||
throw new NotFoundException('Cart item not found');
|
||||
}
|
||||
|
||||
await this.prisma.cartItem.delete({ where: { id: itemId } });
|
||||
|
||||
const refreshed = await this.loadCart(cart.id);
|
||||
return {
|
||||
message: 'Cart item removed',
|
||||
cart: this.serializeCart(refreshed),
|
||||
};
|
||||
}
|
||||
|
||||
async clear(businessIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertCustomerAccess(businessId, actor);
|
||||
|
||||
const cart = await this.getOrCreateCart(businessId, actor.id);
|
||||
await this.prisma.cartItem.deleteMany({ where: { cartId: cart.id } });
|
||||
|
||||
const refreshed = await this.loadCart(cart.id);
|
||||
return {
|
||||
message: 'Cart cleared',
|
||||
cart: this.serializeCart(refreshed),
|
||||
};
|
||||
}
|
||||
|
||||
async checkout(
|
||||
businessIdRaw: string,
|
||||
dto: CheckoutCartDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertCustomerAccess(businessId, actor);
|
||||
|
||||
const cart = await this.getOrCreateCart(businessId, actor.id);
|
||||
|
||||
if (!cart.items.length) {
|
||||
throw new BadRequestException('Cart is empty');
|
||||
}
|
||||
|
||||
const shippingAddress = await this.resolveShippingAddress(
|
||||
actor.id,
|
||||
dto.addressId,
|
||||
dto.shippingAddress,
|
||||
);
|
||||
|
||||
const order = await this.orders.createFromCart({
|
||||
businessId,
|
||||
userId: actor.id,
|
||||
createdBy: actor.id,
|
||||
source: 'website',
|
||||
cartItems: cart.items,
|
||||
shippingAddress,
|
||||
addressId: dto.addressId ? BigInt(dto.addressId) : null,
|
||||
customerNotes: dto.customerNotes?.trim() || null,
|
||||
adminNotes: null,
|
||||
status: 'pending',
|
||||
payment: {
|
||||
type: dto.payment.type,
|
||||
posType: dto.payment.posType,
|
||||
gatewayType: dto.payment.gatewayType,
|
||||
transferAccount: dto.payment.transferAccount,
|
||||
transferRefNumber: dto.payment.transferRefNumber,
|
||||
notes: dto.payment.notes?.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.cartItem.deleteMany({ where: { cartId: cart.id } });
|
||||
|
||||
return {
|
||||
message: 'Order placed successfully',
|
||||
order,
|
||||
};
|
||||
}
|
||||
|
||||
private async getOrCreateCart(businessId: bigint, userId: bigint) {
|
||||
const existing = await this.prisma.cart.findUnique({
|
||||
where: {
|
||||
businessId_userId: { businessId, userId },
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return this.loadCart(existing.id);
|
||||
}
|
||||
|
||||
const created = await this.prisma.cart.create({
|
||||
data: { businessId, userId },
|
||||
});
|
||||
|
||||
return this.loadCart(created.id);
|
||||
}
|
||||
|
||||
private async loadCart(cartId: bigint): Promise<CartWithItems> {
|
||||
return this.prisma.cart.findUniqueOrThrow({
|
||||
where: { id: cartId },
|
||||
include: {
|
||||
items: {
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: {
|
||||
storeItemVariant: {
|
||||
include: cartVariantInclude,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async loadPurchasableVariant(
|
||||
businessId: bigint,
|
||||
storeItemVariantId: bigint,
|
||||
) {
|
||||
const variant = await this.prisma.storeItemVariant.findFirst({
|
||||
where: { id: storeItemVariantId, businessId, isActive: true },
|
||||
include: {
|
||||
storeItem: {
|
||||
include: {
|
||||
product: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!variant) {
|
||||
throw new NotFoundException('Store item variant not found or unavailable');
|
||||
}
|
||||
|
||||
if (variant.storeItem.product.status !== ContentStatus.published) {
|
||||
throw new BadRequestException('Product is not available for purchase');
|
||||
}
|
||||
|
||||
if (variant.price === null) {
|
||||
throw new BadRequestException('Store item variant has no price configured');
|
||||
}
|
||||
|
||||
return variant;
|
||||
}
|
||||
|
||||
private async resolveShippingAddress(
|
||||
userId: bigint,
|
||||
addressIdRaw: string | undefined,
|
||||
inline: ShippingAddressDto | undefined,
|
||||
) {
|
||||
if (addressIdRaw) {
|
||||
const address = await this.prisma.address.findFirst({
|
||||
where: { id: BigInt(addressIdRaw), userId },
|
||||
});
|
||||
|
||||
if (!address) {
|
||||
throw new NotFoundException('Shipping address not found');
|
||||
}
|
||||
|
||||
return {
|
||||
province: address.province,
|
||||
city: address.city,
|
||||
address: address.address,
|
||||
postalCode: address.postalCode,
|
||||
landline: address.landline,
|
||||
};
|
||||
}
|
||||
|
||||
if (!inline) {
|
||||
throw new BadRequestException(
|
||||
'Provide addressId or shippingAddress for checkout',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
province: inline.province.trim(),
|
||||
city: inline.city.trim(),
|
||||
address: inline.address.trim(),
|
||||
postalCode: inline.postalCode?.trim() || null,
|
||||
landline: inline.landline?.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
private assertStockAvailable(stockQuantity: number | null, requested: number) {
|
||||
if (stockQuantity !== null && requested > stockQuantity) {
|
||||
throw new BadRequestException('Insufficient stock for this quantity');
|
||||
}
|
||||
}
|
||||
|
||||
private serializeCart(cart: CartWithItems) {
|
||||
const items = cart.items.map((item) => this.serializeCartItem(item));
|
||||
const subtotal = items.reduce((sum, item) => sum + item.lineTotal, 0);
|
||||
|
||||
return {
|
||||
id: cart.id.toString(),
|
||||
businessId: cart.businessId.toString(),
|
||||
items,
|
||||
itemCount: items.reduce((sum, item) => sum + item.quantity, 0),
|
||||
subtotal,
|
||||
updatedAt: cart.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private serializeCartItem(item: CartWithItems['items'][number]) {
|
||||
const variant = item.storeItemVariant;
|
||||
const product = variant.storeItem.product;
|
||||
const content = this.asRecord(product.content);
|
||||
const price = Number(variant.price);
|
||||
const compareAtPrice =
|
||||
variant.compareAtPrice === null ? null : Number(variant.compareAtPrice);
|
||||
const effectivePrice =
|
||||
compareAtPrice !== null && compareAtPrice < price ? compareAtPrice : price;
|
||||
const selections = variant.selections.map((selection) => ({
|
||||
variationId: selection.variation.id.toString(),
|
||||
variationName: selection.variation.name,
|
||||
optionId: selection.option.id.toString(),
|
||||
value: selection.option.label,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: item.id.toString(),
|
||||
storeItemId: variant.storeItemId.toString(),
|
||||
storeItemVariantId: variant.id.toString(),
|
||||
productId: product.id.toString(),
|
||||
productTitle: product.title,
|
||||
productNameFa: (content.nameFa as string | null | undefined) ?? '',
|
||||
productImage: product.featuredMedia?.publicUrl ?? null,
|
||||
sku: variant.sku,
|
||||
selections,
|
||||
label: selections.map((entry) => entry.value).join(' · ') || product.title,
|
||||
quantity: item.quantity,
|
||||
unitPrice: effectivePrice,
|
||||
compareAtPrice: price,
|
||||
lineTotal: effectivePrice * item.quantity,
|
||||
stockQuantity: variant.stockQuantity,
|
||||
};
|
||||
}
|
||||
|
||||
private asRecord(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
private async assertCustomerAccess(businessId: bigint, actor: AuthUser) {
|
||||
if (await this.permissions.isSuperAdmin(actor.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const membership = await this.prisma.businessCustomer.findUnique({
|
||||
where: {
|
||||
businessId_userId: { businessId, userId: actor.id },
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new ForbiddenException('You are not a customer of this business');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { CreateTransactionPaymentDto } from '../../transactions/dto/transaction.dto';
|
||||
|
||||
export class AddCartItemDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
storeItemVariantId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
quantity?: number;
|
||||
}
|
||||
|
||||
export class UpdateCartItemDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
quantity!: number;
|
||||
}
|
||||
|
||||
export class ShippingAddressDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
province!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
city!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
address!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
postalCode?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
landline?: string;
|
||||
}
|
||||
|
||||
export class CheckoutCartDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
addressId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => ShippingAddressDto)
|
||||
shippingAddress?: ShippingAddressDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
customerNotes?: string;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => CreateTransactionPaymentDto)
|
||||
payment!: CreateTransactionPaymentDto;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CategoriesService } from './categories.service';
|
||||
import { CategoryTechnicalFormService } from './category-technical-form.service';
|
||||
import { CategoryTechnicalFormAiService } from './category-technical-form-ai.service';
|
||||
import { CategoryAiService } from './category-ai.service';
|
||||
import { CategoryVariationsService } from './category-variations.service';
|
||||
import { CreateCategoryDto, ListCategoriesDto, UpdateCategoryDto } from './dto/category.dto';
|
||||
import {
|
||||
ReplaceCategoryTechnicalFormDto,
|
||||
SuggestCategoryTechnicalFormDto,
|
||||
} from './dto/category-technical-form.dto';
|
||||
import { ReplaceCategoryVariationsDto } from './dto/category-variation.dto';
|
||||
import { GenerateCategoriesDto } from './dto/category-ai.dto';
|
||||
|
||||
@Controller('businesses/:businessId/categories')
|
||||
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||
export class CategoriesController {
|
||||
constructor(
|
||||
private readonly service: CategoriesService,
|
||||
private readonly variationsService: CategoryVariationsService,
|
||||
private readonly technicalFormService: CategoryTechnicalFormService,
|
||||
private readonly technicalFormAiService: CategoryTechnicalFormAiService,
|
||||
private readonly categoryAiService: CategoryAiService,
|
||||
) {}
|
||||
|
||||
@Get('color-presets')
|
||||
@RequireBusinessPermission('categories.read')
|
||||
listColorPresets() {
|
||||
return this.variationsService.listColorPresets();
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequireBusinessPermission('categories.read')
|
||||
list(
|
||||
@Param('businessId') businessId: string,
|
||||
@Query() query: ListCategoriesDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.list(businessId, query, user);
|
||||
}
|
||||
|
||||
@Post('ai-generate')
|
||||
@RequireBusinessPermission('categories.create')
|
||||
generateWithAi(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: GenerateCategoriesDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.categoryAiService.generateProductCategories(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequireBusinessPermission('categories.create')
|
||||
create(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: CreateCategoryDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.create(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Patch(':categoryId')
|
||||
@RequireBusinessPermission('categories.update')
|
||||
update(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('categoryId') categoryId: string,
|
||||
@Body() dto: UpdateCategoryDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.update(businessId, categoryId, dto, user);
|
||||
}
|
||||
|
||||
@Get(':categoryId/variations')
|
||||
@RequireBusinessPermission('categories.read')
|
||||
listVariations(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('categoryId') categoryId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.variationsService.listForCategory(businessId, categoryId, user);
|
||||
}
|
||||
|
||||
@Put(':categoryId/variations')
|
||||
@RequireBusinessPermission('categories.update')
|
||||
replaceVariations(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('categoryId') categoryId: string,
|
||||
@Body() dto: ReplaceCategoryVariationsDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.variationsService.replaceForCategory(
|
||||
businessId,
|
||||
categoryId,
|
||||
dto,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':categoryId/technical-form')
|
||||
@RequireBusinessPermission('categories.read')
|
||||
getTechnicalForm(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('categoryId') categoryId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.technicalFormService.getForCategory(businessId, categoryId, user);
|
||||
}
|
||||
|
||||
@Put(':categoryId/technical-form')
|
||||
@RequireBusinessPermission('categories.update')
|
||||
replaceTechnicalForm(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('categoryId') categoryId: string,
|
||||
@Body() dto: ReplaceCategoryTechnicalFormDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.technicalFormService.replaceForCategory(
|
||||
businessId,
|
||||
categoryId,
|
||||
dto,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':categoryId/technical-form/ai-suggest')
|
||||
@RequireBusinessPermission('categories.update')
|
||||
suggestTechnicalForm(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('categoryId') categoryId: string,
|
||||
@Body() dto: SuggestCategoryTechnicalFormDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.technicalFormAiService.suggestForCategory(
|
||||
businessId,
|
||||
categoryId,
|
||||
dto,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':categoryId')
|
||||
@RequireBusinessPermission('categories.delete')
|
||||
remove(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('categoryId') categoryId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.remove(businessId, categoryId, user);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('tenants/:host/categories')
|
||||
export class PublicCategoriesController {
|
||||
constructor(private readonly service: CategoriesService) {}
|
||||
|
||||
@Get()
|
||||
list(@Param('host') host: string, @Query() query: ListCategoriesDto) {
|
||||
return this.service.listPublic(host, query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { TenantModule } from '../tenant/tenant.module';
|
||||
import { CategoriesController, PublicCategoriesController } from './categories.controller';
|
||||
import { CategoriesService } from './categories.service';
|
||||
import { CategoryTechnicalFormAiService } from './category-technical-form-ai.service';
|
||||
import { CategoryAiService } from './category-ai.service';
|
||||
import { CategoryTechnicalFormService } from './category-technical-form.service';
|
||||
import { CategoryVariationsService } from './category-variations.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, TenantModule],
|
||||
controllers: [CategoriesController, PublicCategoriesController],
|
||||
providers: [
|
||||
CategoriesService,
|
||||
CategoryVariationsService,
|
||||
CategoryTechnicalFormService,
|
||||
CategoryTechnicalFormAiService,
|
||||
CategoryAiService,
|
||||
],
|
||||
exports: [CategoryVariationsService, CategoryTechnicalFormService],
|
||||
})
|
||||
export class CategoriesModule {}
|
||||
@@ -0,0 +1,320 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { MediaEntityType } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
import { CreateCategoryDto, ListCategoriesDto, UpdateCategoryDto } from './dto/category.dto';
|
||||
|
||||
function slugify(value: string): string {
|
||||
return (
|
||||
value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'category'
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CategoriesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly tenant: TenantService,
|
||||
) {}
|
||||
|
||||
async list(businessIdRaw: string, query: ListCategoriesDto, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'categories.read');
|
||||
|
||||
const entityType = query.entityType ?? MediaEntityType.product;
|
||||
|
||||
const items = await this.prisma.category.findMany({
|
||||
where: { businessId, entityType, isActive: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
||||
include: {
|
||||
_count: { select: { variations: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
items: items.map((item) =>
|
||||
this.serialize(item, item._count.variations),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async listPublic(host: string, query: ListCategoriesDto) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const businessId = business.id;
|
||||
const entityType = query.entityType ?? MediaEntityType.product;
|
||||
|
||||
const items = await this.prisma.category.findMany({
|
||||
where: { businessId, entityType, isActive: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
||||
});
|
||||
|
||||
return {
|
||||
items: items.map((item) => this.serialize(item)),
|
||||
};
|
||||
}
|
||||
|
||||
async create(businessIdRaw: string, dto: CreateCategoryDto, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'categories.create');
|
||||
|
||||
const parentId = dto.parentId ? BigInt(dto.parentId) : null;
|
||||
|
||||
if (parentId) {
|
||||
const parent = await this.prisma.category.findFirst({
|
||||
where: { id: parentId, businessId, entityType: dto.entityType },
|
||||
});
|
||||
if (!parent) {
|
||||
throw new BadRequestException('Parent category not found for this business');
|
||||
}
|
||||
}
|
||||
|
||||
const slug = await this.ensureUniqueSlug(
|
||||
businessId,
|
||||
dto.entityType,
|
||||
dto.slug ?? slugify(dto.name),
|
||||
);
|
||||
|
||||
const created = await this.prisma.category.create({
|
||||
data: {
|
||||
businessId,
|
||||
entityType: dto.entityType,
|
||||
parentId,
|
||||
name: dto.name.trim(),
|
||||
nameFa: dto.nameFa?.trim() || null,
|
||||
slug,
|
||||
description: dto.description?.trim() || null,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Category created successfully',
|
||||
category: this.serialize(created),
|
||||
};
|
||||
}
|
||||
|
||||
async update(
|
||||
businessIdRaw: string,
|
||||
categoryIdRaw: string,
|
||||
dto: UpdateCategoryDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const categoryId = BigInt(categoryIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'categories.update');
|
||||
|
||||
const existing = await this.prisma.category.findFirst({
|
||||
where: { id: categoryId, businessId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Category not found');
|
||||
}
|
||||
|
||||
let parentId: bigint | null | undefined = undefined;
|
||||
|
||||
if (dto.parentId !== undefined) {
|
||||
if (dto.parentId === null || dto.parentId === '') {
|
||||
parentId = null;
|
||||
} else {
|
||||
parentId = BigInt(dto.parentId);
|
||||
if (parentId === categoryId) {
|
||||
throw new BadRequestException('Category cannot be its own parent');
|
||||
}
|
||||
const parent = await this.prisma.category.findFirst({
|
||||
where: { id: parentId, businessId, entityType: existing.entityType },
|
||||
});
|
||||
if (!parent) {
|
||||
throw new BadRequestException('Parent category not found for this business');
|
||||
}
|
||||
const descendantIds = await this.collectDescendantIds(categoryId);
|
||||
if (descendantIds.includes(parentId)) {
|
||||
throw new BadRequestException('Cannot move category under its own descendant');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let slug = existing.slug;
|
||||
if (dto.slug) {
|
||||
slug = await this.ensureUniqueSlug(
|
||||
businessId,
|
||||
existing.entityType,
|
||||
dto.slug,
|
||||
categoryId,
|
||||
);
|
||||
} else if (dto.name && dto.name !== existing.name) {
|
||||
slug = await this.ensureUniqueSlug(
|
||||
businessId,
|
||||
existing.entityType,
|
||||
slugify(dto.name),
|
||||
categoryId,
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await this.prisma.category.update({
|
||||
where: { id: categoryId },
|
||||
data: {
|
||||
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
|
||||
...(dto.nameFa !== undefined ? { nameFa: dto.nameFa?.trim() || null } : {}),
|
||||
...(dto.description !== undefined
|
||||
? { description: dto.description?.trim() || null }
|
||||
: {}),
|
||||
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
|
||||
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
|
||||
...(parentId !== undefined ? { parentId } : {}),
|
||||
slug,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Category updated successfully',
|
||||
category: this.serialize(updated),
|
||||
};
|
||||
}
|
||||
|
||||
async remove(businessIdRaw: string, categoryIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const categoryId = BigInt(categoryIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'categories.delete');
|
||||
|
||||
const existing = await this.prisma.category.findFirst({
|
||||
where: { id: categoryId, businessId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Category not found');
|
||||
}
|
||||
|
||||
const descendantIds = await this.collectDescendantIds(categoryId);
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.category.deleteMany({
|
||||
where: { id: { in: descendantIds } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
message: 'Category deleted successfully',
|
||||
deletedIds: descendantIds.map((id) => id.toString()),
|
||||
};
|
||||
}
|
||||
|
||||
private async collectDescendantIds(rootId: bigint): Promise<bigint[]> {
|
||||
const all = await this.prisma.category.findMany({
|
||||
where: { businessId: (await this.getBusinessIdForCategory(rootId))! },
|
||||
select: { id: true, parentId: true },
|
||||
});
|
||||
|
||||
const result: bigint[] = [rootId];
|
||||
const queue = [rootId];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
const children = all.filter((c) => c.parentId === current).map((c) => c.id);
|
||||
for (const childId of children) {
|
||||
result.push(childId);
|
||||
queue.push(childId);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async getBusinessIdForCategory(categoryId: bigint) {
|
||||
const category = await this.prisma.category.findUnique({
|
||||
where: { id: categoryId },
|
||||
select: { businessId: true },
|
||||
});
|
||||
return category?.businessId;
|
||||
}
|
||||
|
||||
private async ensureUniqueSlug(
|
||||
businessId: bigint,
|
||||
entityType: MediaEntityType,
|
||||
baseSlug: string,
|
||||
excludeId?: bigint,
|
||||
) {
|
||||
let slug = baseSlug;
|
||||
let suffix = 1;
|
||||
|
||||
while (true) {
|
||||
const existing = await this.prisma.category.findFirst({
|
||||
where: {
|
||||
businessId,
|
||||
entityType,
|
||||
slug,
|
||||
...(excludeId ? { NOT: { id: excludeId } } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return slug;
|
||||
}
|
||||
|
||||
suffix += 1;
|
||||
slug = `${baseSlug}-${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
private async assertPermission(
|
||||
businessId: bigint,
|
||||
userId: bigint,
|
||||
permission: string,
|
||||
) {
|
||||
const allowed = await this.permissions.hasBusinessPermission(
|
||||
userId,
|
||||
businessId,
|
||||
permission,
|
||||
);
|
||||
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(`Missing permission: ${permission} for this business`);
|
||||
}
|
||||
}
|
||||
|
||||
private serialize(
|
||||
category: {
|
||||
id: bigint;
|
||||
businessId: bigint;
|
||||
entityType: MediaEntityType;
|
||||
parentId: bigint | null;
|
||||
name: string;
|
||||
nameFa: string | null;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
},
|
||||
variationCount = 0,
|
||||
) {
|
||||
return {
|
||||
id: category.id.toString(),
|
||||
businessId: category.businessId.toString(),
|
||||
entityType: category.entityType,
|
||||
parentId: category.parentId?.toString() ?? null,
|
||||
name: category.name,
|
||||
nameFa: category.nameFa,
|
||||
slug: category.slug,
|
||||
description: category.description,
|
||||
sortOrder: category.sortOrder,
|
||||
isActive: category.isActive,
|
||||
variationCount,
|
||||
createdAt: category.createdAt,
|
||||
updatedAt: category.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MediaEntityType, Prisma } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import {
|
||||
requestAiJsonCompletion,
|
||||
resolveAiProvider,
|
||||
} from '../common/ai-provider.util';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { GenerateCategoriesDto } from './dto/category-ai.dto';
|
||||
|
||||
type AiCategoryNode = {
|
||||
nameEn?: string;
|
||||
nameFa?: string;
|
||||
description?: string;
|
||||
children?: AiCategoryNode[];
|
||||
};
|
||||
|
||||
type AiCategoryTreeResponse = {
|
||||
categories?: AiCategoryNode[];
|
||||
};
|
||||
|
||||
function slugify(value: string): string {
|
||||
return (
|
||||
value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'category'
|
||||
);
|
||||
}
|
||||
|
||||
const MAX_DEPTH = 3;
|
||||
const MAX_TOTAL = 40;
|
||||
|
||||
@Injectable()
|
||||
export class CategoryAiService {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
) {}
|
||||
|
||||
async generateProductCategories(
|
||||
businessIdRaw: string,
|
||||
dto: GenerateCategoriesDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'categories.create');
|
||||
|
||||
const tree = await this.generateTree(dto.prompt.trim());
|
||||
const created = await this.prisma.$transaction(async (tx) => {
|
||||
return this.createTree(
|
||||
tx,
|
||||
businessId,
|
||||
MediaEntityType.product,
|
||||
tree,
|
||||
null,
|
||||
0,
|
||||
{ count: 0 },
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
message: `Created ${created.length} categor${created.length === 1 ? 'y' : 'ies'} with AI.`,
|
||||
categories: created.map((item) => this.serialize(item)),
|
||||
};
|
||||
}
|
||||
|
||||
private async generateTree(prompt: string): Promise<AiCategoryNode[]> {
|
||||
const provider = resolveAiProvider(this.config);
|
||||
|
||||
const systemPrompt = `You design product category trees for Iranian e-commerce stores.
|
||||
Return ONLY valid JSON with this shape:
|
||||
{
|
||||
"categories": [
|
||||
{
|
||||
"nameEn": "Category name in English",
|
||||
"nameFa": "نام فارسی",
|
||||
"description": "Short English description",
|
||||
"children": [
|
||||
{
|
||||
"nameEn": "Subcategory",
|
||||
"nameFa": "زیردسته",
|
||||
"description": "Optional description",
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Generate a practical category hierarchy based on the user prompt.
|
||||
- Use 2-6 top-level categories when appropriate.
|
||||
- Add subcategories only where they help shoppers browse (max depth ${MAX_DEPTH}).
|
||||
- Every node needs nameEn and nameFa (Farsi in Persian script).
|
||||
- Descriptions are optional, max 120 characters, English only.
|
||||
- Do not duplicate category names at the same level.
|
||||
- Total nodes must not exceed ${MAX_TOTAL}.`;
|
||||
|
||||
const userPrompt = JSON.stringify({ prompt });
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = await requestAiJsonCompletion(provider, systemPrompt, userPrompt);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'AI request failed';
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
|
||||
let parsed: AiCategoryTreeResponse;
|
||||
try {
|
||||
parsed = JSON.parse(content) as AiCategoryTreeResponse;
|
||||
} catch {
|
||||
throw new BadRequestException('AI returned invalid JSON');
|
||||
}
|
||||
|
||||
const categories = Array.isArray(parsed.categories) ? parsed.categories : [];
|
||||
const normalized = this.normalizeNodes(categories, 0);
|
||||
|
||||
if (!normalized.length) {
|
||||
throw new BadRequestException('AI did not return any valid categories');
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private normalizeNodes(nodes: AiCategoryNode[], depth: number): AiCategoryNode[] {
|
||||
if (depth >= MAX_DEPTH) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: AiCategoryNode[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const raw of nodes) {
|
||||
const nameEn = String(raw.nameEn ?? '').trim();
|
||||
const nameFa = String(raw.nameFa ?? '').trim();
|
||||
if (!nameEn || nameEn.length < 2) continue;
|
||||
|
||||
const key = nameEn.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
const children = Array.isArray(raw.children)
|
||||
? this.normalizeNodes(raw.children, depth + 1)
|
||||
: [];
|
||||
|
||||
result.push({
|
||||
nameEn,
|
||||
nameFa: nameFa || nameEn,
|
||||
description: String(raw.description ?? '').trim().slice(0, 120) || undefined,
|
||||
children,
|
||||
});
|
||||
|
||||
if (result.length >= MAX_TOTAL) break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async createTree(
|
||||
tx: Prisma.TransactionClient,
|
||||
businessId: bigint,
|
||||
entityType: MediaEntityType,
|
||||
nodes: AiCategoryNode[],
|
||||
parentId: bigint | null,
|
||||
depth: number,
|
||||
state: { count: number },
|
||||
) {
|
||||
const created: Prisma.CategoryGetPayload<object>[] = [];
|
||||
|
||||
for (let index = 0; index < nodes.length; index += 1) {
|
||||
if (state.count >= MAX_TOTAL) break;
|
||||
|
||||
const node = nodes[index];
|
||||
const slug = await this.ensureUniqueSlug(
|
||||
tx,
|
||||
businessId,
|
||||
entityType,
|
||||
slugify(node.nameEn!),
|
||||
);
|
||||
|
||||
const category = await tx.category.create({
|
||||
data: {
|
||||
businessId,
|
||||
entityType,
|
||||
parentId,
|
||||
name: node.nameEn!,
|
||||
nameFa: node.nameFa?.trim() || null,
|
||||
slug,
|
||||
description: node.description?.trim() || null,
|
||||
sortOrder: index,
|
||||
},
|
||||
});
|
||||
|
||||
created.push(category);
|
||||
state.count += 1;
|
||||
|
||||
if (node.children?.length && depth + 1 < MAX_DEPTH) {
|
||||
const childCreated = await this.createTree(
|
||||
tx,
|
||||
businessId,
|
||||
entityType,
|
||||
node.children,
|
||||
category.id,
|
||||
depth + 1,
|
||||
state,
|
||||
);
|
||||
created.push(...childCreated);
|
||||
}
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
private async ensureUniqueSlug(
|
||||
tx: Prisma.TransactionClient,
|
||||
businessId: bigint,
|
||||
entityType: MediaEntityType,
|
||||
baseSlug: string,
|
||||
) {
|
||||
let slug = baseSlug;
|
||||
let suffix = 1;
|
||||
|
||||
while (true) {
|
||||
const existing = await tx.category.findFirst({
|
||||
where: { businessId, entityType, slug },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return slug;
|
||||
}
|
||||
|
||||
suffix += 1;
|
||||
slug = `${baseSlug}-${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
private serialize(category: Prisma.CategoryGetPayload<object>) {
|
||||
return {
|
||||
id: category.id.toString(),
|
||||
businessId: category.businessId.toString(),
|
||||
entityType: category.entityType,
|
||||
parentId: category.parentId?.toString() ?? null,
|
||||
name: category.name,
|
||||
nameFa: category.nameFa,
|
||||
slug: category.slug,
|
||||
description: category.description,
|
||||
sortOrder: category.sortOrder,
|
||||
isActive: category.isActive,
|
||||
variationCount: 0,
|
||||
createdAt: category.createdAt,
|
||||
updatedAt: category.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private async assertPermission(
|
||||
businessId: bigint,
|
||||
userId: bigint,
|
||||
permission: string,
|
||||
) {
|
||||
const allowed = await this.permissions.hasBusinessPermission(
|
||||
userId,
|
||||
businessId,
|
||||
permission,
|
||||
);
|
||||
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(`Missing permission: ${permission} for this business`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MediaEntityType } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import {
|
||||
requestAiJsonCompletion,
|
||||
resolveAiProvider,
|
||||
} from '../common/ai-provider.util';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CategoryTechnicalFormService } from './category-technical-form.service';
|
||||
import {
|
||||
SuggestCategoryTechnicalFormDto,
|
||||
TechnicalFormFieldInputDto,
|
||||
} from './dto/category-technical-form.dto';
|
||||
|
||||
type AiTechnicalFormDraft = {
|
||||
fields?: {
|
||||
label?: string;
|
||||
type?: string;
|
||||
isRequired?: boolean;
|
||||
options?: string[];
|
||||
}[];
|
||||
};
|
||||
|
||||
const ALLOWED_TYPES = new Set(['text', 'textarea', 'select', 'multi_select']);
|
||||
|
||||
@Injectable()
|
||||
export class CategoryTechnicalFormAiService {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly technicalFormService: CategoryTechnicalFormService,
|
||||
) {}
|
||||
|
||||
async suggestForCategory(
|
||||
businessIdRaw: string,
|
||||
categoryIdRaw: string,
|
||||
dto: SuggestCategoryTechnicalFormDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const existing = await this.technicalFormService.getForCategory(
|
||||
businessIdRaw,
|
||||
categoryIdRaw,
|
||||
actor,
|
||||
);
|
||||
|
||||
const categoryName = await this.getCategoryName(businessIdRaw, categoryIdRaw);
|
||||
|
||||
const fields = await this.generateFields({
|
||||
categoryName,
|
||||
hint: dto.hint?.trim(),
|
||||
existingFieldCount: existing.form?.fields.length ?? 0,
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Technical form draft generated. Review fields before saving.',
|
||||
fields,
|
||||
};
|
||||
}
|
||||
|
||||
private async getCategoryName(businessIdRaw: string, categoryIdRaw: string) {
|
||||
const category = await this.prisma.category.findFirst({
|
||||
where: {
|
||||
id: BigInt(categoryIdRaw),
|
||||
businessId: BigInt(businessIdRaw),
|
||||
entityType: MediaEntityType.product,
|
||||
isActive: true,
|
||||
},
|
||||
select: { name: true },
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
throw new NotFoundException('Product category not found');
|
||||
}
|
||||
|
||||
return category.name;
|
||||
}
|
||||
|
||||
private async generateFields(input: {
|
||||
categoryName: string;
|
||||
hint?: string;
|
||||
existingFieldCount: number;
|
||||
}): Promise<TechnicalFormFieldInputDto[]> {
|
||||
const provider = resolveAiProvider(this.config);
|
||||
|
||||
const systemPrompt = `You design technical specification forms for e-commerce product categories on an Iranian marketplace.
|
||||
Return ONLY valid JSON with this shape:
|
||||
{
|
||||
"fields": [
|
||||
{
|
||||
"label": "Field label in English",
|
||||
"type": "text | textarea | select | multi_select",
|
||||
"isRequired": true,
|
||||
"options": ["Option A", "Option B"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Generate 5-12 practical fields that buyers need for products in the given category.
|
||||
- Use a mix of text, textarea, select, and multi_select where appropriate.
|
||||
- select and multi_select fields must include 3-8 realistic options.
|
||||
- text fields are for short values (dimensions, weight, model year).
|
||||
- textarea fields are for longer specs (features, care instructions).
|
||||
- Mark important buyer-facing specs as required.
|
||||
- Labels must be unique and human-readable.
|
||||
- Do not include price, stock, SKU, or warranty period fields.
|
||||
- Prefer metric units common in Iran when relevant.`;
|
||||
|
||||
const userPrompt = JSON.stringify({
|
||||
category: input.categoryName,
|
||||
hint: input.hint || null,
|
||||
existingFieldCount: input.existingFieldCount,
|
||||
note:
|
||||
input.existingFieldCount > 0
|
||||
? 'Category already has a form; suggest a fresh complete replacement set.'
|
||||
: 'Category has no form yet.',
|
||||
});
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = await requestAiJsonCompletion(provider, systemPrompt, userPrompt);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'AI request failed';
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
|
||||
let parsed: AiTechnicalFormDraft;
|
||||
try {
|
||||
parsed = JSON.parse(content) as AiTechnicalFormDraft;
|
||||
} catch {
|
||||
throw new BadRequestException('AI returned invalid JSON');
|
||||
}
|
||||
|
||||
return this.normalizeFields(parsed);
|
||||
}
|
||||
|
||||
private normalizeFields(draft: AiTechnicalFormDraft): TechnicalFormFieldInputDto[] {
|
||||
const rawFields = Array.isArray(draft.fields) ? draft.fields : [];
|
||||
const labels = new Set<string>();
|
||||
const fields: TechnicalFormFieldInputDto[] = [];
|
||||
|
||||
for (const raw of rawFields) {
|
||||
const label = String(raw.label ?? '').trim();
|
||||
if (!label) continue;
|
||||
|
||||
const normalizedLabel = label.toLowerCase();
|
||||
if (labels.has(normalizedLabel)) continue;
|
||||
labels.add(normalizedLabel);
|
||||
|
||||
const type = ALLOWED_TYPES.has(String(raw.type))
|
||||
? (raw.type as TechnicalFormFieldInputDto['type'])
|
||||
: 'text';
|
||||
|
||||
const field: TechnicalFormFieldInputDto = {
|
||||
label,
|
||||
type,
|
||||
isRequired: Boolean(raw.isRequired),
|
||||
};
|
||||
|
||||
if (type === 'select' || type === 'multi_select') {
|
||||
const options = Array.isArray(raw.options)
|
||||
? [...new Set(raw.options.map((option) => String(option).trim()).filter(Boolean))]
|
||||
: [];
|
||||
|
||||
if (!options.length) continue;
|
||||
field.options = options.slice(0, 12);
|
||||
}
|
||||
|
||||
fields.push(field);
|
||||
if (fields.length >= 12) break;
|
||||
}
|
||||
|
||||
if (!fields.length) {
|
||||
throw new BadRequestException('AI did not return any valid technical form fields');
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { MediaEntityType, TechnicalFieldType } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
ReplaceCategoryTechnicalFormDto,
|
||||
TechnicalFormFieldInputDto,
|
||||
} from './dto/category-technical-form.dto';
|
||||
|
||||
function slugifyKey(value: string): string {
|
||||
return (
|
||||
value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'field'
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CategoryTechnicalFormService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
) {}
|
||||
|
||||
async getForCategory(
|
||||
businessIdRaw: string,
|
||||
categoryIdRaw: string,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const categoryId = BigInt(categoryIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'categories.read');
|
||||
await this.getProductCategory(businessId, categoryId);
|
||||
|
||||
const form = await this.prisma.categoryTechnicalForm.findUnique({
|
||||
where: { categoryId },
|
||||
include: {
|
||||
fields: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
options: { orderBy: { sortOrder: 'asc' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!form) {
|
||||
return { form: null };
|
||||
}
|
||||
|
||||
return { form: this.serializeForm(form) };
|
||||
}
|
||||
|
||||
async replaceForCategory(
|
||||
businessIdRaw: string,
|
||||
categoryIdRaw: string,
|
||||
dto: ReplaceCategoryTechnicalFormDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const categoryId = BigInt(categoryIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'categories.update');
|
||||
await this.getProductCategory(businessId, categoryId);
|
||||
|
||||
this.validateFieldInputs(dto.fields);
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.categoryTechnicalForm.deleteMany({
|
||||
where: { businessId, categoryId },
|
||||
});
|
||||
|
||||
if (!dto.fields.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = await tx.categoryTechnicalForm.create({
|
||||
data: { businessId, categoryId },
|
||||
});
|
||||
|
||||
const usedKeys = new Set<string>();
|
||||
|
||||
for (const [index, field] of dto.fields.entries()) {
|
||||
let fieldKey = slugifyKey(field.label);
|
||||
if (usedKeys.has(fieldKey)) {
|
||||
let suffix = 2;
|
||||
while (usedKeys.has(`${fieldKey}-${suffix}`)) {
|
||||
suffix += 1;
|
||||
}
|
||||
fieldKey = `${fieldKey}-${suffix}`;
|
||||
}
|
||||
usedKeys.add(fieldKey);
|
||||
|
||||
const createdField = await tx.categoryTechnicalFormField.create({
|
||||
data: {
|
||||
formId: form.id,
|
||||
label: field.label.trim(),
|
||||
fieldKey,
|
||||
fieldType: field.type as TechnicalFieldType,
|
||||
isRequired: field.isRequired ?? false,
|
||||
sortOrder: index,
|
||||
},
|
||||
});
|
||||
|
||||
if (field.type === 'select' || field.type === 'multi_select') {
|
||||
const uniqueOptions = [
|
||||
...new Set(field.options!.map((o) => o.trim()).filter(Boolean)),
|
||||
];
|
||||
|
||||
await tx.categoryTechnicalFormFieldOption.createMany({
|
||||
data: uniqueOptions.map((label, optionIndex) => ({
|
||||
fieldId: createdField.id,
|
||||
label,
|
||||
value: slugifyKey(label) || `option-${optionIndex + 1}`,
|
||||
sortOrder: optionIndex,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return this.getForCategory(businessIdRaw, categoryIdRaw, actor);
|
||||
}
|
||||
|
||||
async getFormForCategory(businessId: bigint, categoryId: bigint) {
|
||||
const form = await this.prisma.categoryTechnicalForm.findUnique({
|
||||
where: { categoryId },
|
||||
include: {
|
||||
fields: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
options: { orderBy: { sortOrder: 'asc' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!form || form.businessId !== businessId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.serializeForm(form);
|
||||
}
|
||||
|
||||
private validateFieldInputs(fields: TechnicalFormFieldInputDto[]) {
|
||||
const labels = new Set<string>();
|
||||
|
||||
for (const field of fields) {
|
||||
const label = field.label.trim().toLowerCase();
|
||||
if (!label) {
|
||||
throw new BadRequestException('Each field must have a label');
|
||||
}
|
||||
if (labels.has(label)) {
|
||||
throw new BadRequestException(
|
||||
`Duplicate field label "${field.label}"`,
|
||||
);
|
||||
}
|
||||
labels.add(label);
|
||||
|
||||
if (field.type === 'select' || field.type === 'multi_select') {
|
||||
const options = field.options?.map((o) => o.trim()).filter(Boolean) ?? [];
|
||||
if (!options.length) {
|
||||
throw new BadRequestException(
|
||||
`Field "${field.label}" requires at least one option`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getProductCategory(businessId: bigint, categoryId: bigint) {
|
||||
const category = await this.prisma.category.findFirst({
|
||||
where: {
|
||||
id: categoryId,
|
||||
businessId,
|
||||
entityType: MediaEntityType.product,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
throw new NotFoundException('Product category not found');
|
||||
}
|
||||
|
||||
return category;
|
||||
}
|
||||
|
||||
private async assertPermission(
|
||||
businessId: bigint,
|
||||
userId: bigint,
|
||||
permission: string,
|
||||
) {
|
||||
const allowed = await this.permissions.hasBusinessPermission(
|
||||
userId,
|
||||
businessId,
|
||||
permission,
|
||||
);
|
||||
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(
|
||||
`Missing permission: ${permission} for this business`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private serializeForm(form: {
|
||||
id: bigint;
|
||||
categoryId: bigint;
|
||||
fields: {
|
||||
id: bigint;
|
||||
label: string;
|
||||
fieldKey: string;
|
||||
fieldType: TechnicalFieldType;
|
||||
isRequired: boolean;
|
||||
sortOrder: number;
|
||||
options: {
|
||||
id: bigint;
|
||||
label: string;
|
||||
value: string;
|
||||
sortOrder: number;
|
||||
}[];
|
||||
}[];
|
||||
}) {
|
||||
return {
|
||||
id: form.id.toString(),
|
||||
categoryId: form.categoryId.toString(),
|
||||
fields: form.fields.map((field) => ({
|
||||
id: field.id.toString(),
|
||||
label: field.label,
|
||||
key: field.fieldKey,
|
||||
type: field.fieldType,
|
||||
isRequired: field.isRequired,
|
||||
sortOrder: field.sortOrder,
|
||||
options:
|
||||
field.fieldType === 'select' || field.fieldType === 'multi_select'
|
||||
? field.options.map((option) => ({
|
||||
id: option.id.toString(),
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
sortOrder: option.sortOrder,
|
||||
}))
|
||||
: [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { MediaEntityType, VariationType } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
COLOR_PRESETS,
|
||||
getColorHex,
|
||||
isColorPreset,
|
||||
} from './color-presets';
|
||||
import {
|
||||
CategoryVariationInputDto,
|
||||
ReplaceCategoryVariationsDto,
|
||||
} from './dto/category-variation.dto';
|
||||
|
||||
function slugifyValue(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CategoryVariationsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
) {}
|
||||
|
||||
listColorPresets() {
|
||||
return { items: COLOR_PRESETS };
|
||||
}
|
||||
|
||||
async listForCategory(
|
||||
businessIdRaw: string,
|
||||
categoryIdRaw: string,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const categoryId = BigInt(categoryIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'categories.read');
|
||||
await this.getProductCategory(businessId, categoryId);
|
||||
|
||||
const variations = await this.prisma.categoryVariation.findMany({
|
||||
where: { businessId, categoryId },
|
||||
include: { options: { orderBy: { sortOrder: 'asc' } } },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
return { items: variations.map((item) => this.serialize(item)) };
|
||||
}
|
||||
|
||||
async replaceForCategory(
|
||||
businessIdRaw: string,
|
||||
categoryIdRaw: string,
|
||||
dto: ReplaceCategoryVariationsDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const categoryId = BigInt(categoryIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'categories.update');
|
||||
await this.getProductCategory(businessId, categoryId);
|
||||
|
||||
this.validateVariationInputs(dto.variations);
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.categoryVariation.deleteMany({
|
||||
where: { businessId, categoryId },
|
||||
});
|
||||
|
||||
for (const [index, variation] of dto.variations.entries()) {
|
||||
const created = await tx.categoryVariation.create({
|
||||
data: {
|
||||
businessId,
|
||||
categoryId,
|
||||
name: this.resolveVariationName(variation),
|
||||
variationType: variation.type as VariationType,
|
||||
sortOrder: index,
|
||||
},
|
||||
});
|
||||
|
||||
const uniqueValues = [...new Set(variation.values.map((v) => v.trim()).filter(Boolean))];
|
||||
|
||||
await tx.categoryVariationOption.createMany({
|
||||
data: uniqueValues.map((label, optionIndex) => ({
|
||||
variationId: created.id,
|
||||
label,
|
||||
value: slugifyValue(label) || `option-${optionIndex + 1}`,
|
||||
colorHex:
|
||||
variation.type === 'color' ? getColorHex(label) : null,
|
||||
sortOrder: optionIndex,
|
||||
})),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return this.listForCategory(businessIdRaw, categoryIdRaw, actor);
|
||||
}
|
||||
|
||||
async getForProductCategory(businessId: bigint, categoryId: bigint) {
|
||||
const variations = await this.prisma.categoryVariation.findMany({
|
||||
where: { businessId, categoryId },
|
||||
include: { options: { orderBy: { sortOrder: 'asc' } } },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
return variations.map((item) => this.serialize(item));
|
||||
}
|
||||
|
||||
private validateVariationInputs(variations: CategoryVariationInputDto[]) {
|
||||
const colorCount = variations.filter((v) => v.type === 'color').length;
|
||||
const sizeCount = variations.filter((v) => v.type === 'size').length;
|
||||
|
||||
if (colorCount > 1) {
|
||||
throw new BadRequestException('Only one color variation is allowed per category');
|
||||
}
|
||||
|
||||
if (sizeCount > 1) {
|
||||
throw new BadRequestException('Only one size variation is allowed per category');
|
||||
}
|
||||
|
||||
const customNames = new Set<string>();
|
||||
|
||||
for (const variation of variations) {
|
||||
if (variation.type === 'color') {
|
||||
for (const value of variation.values) {
|
||||
if (!isColorPreset(value.trim())) {
|
||||
throw new BadRequestException(
|
||||
`Invalid color "${value}". Choose from the predefined color palette.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (variation.type === 'size' || variation.type === 'custom') {
|
||||
const values = variation.values.map((v) => v.trim()).filter(Boolean);
|
||||
if (!values.length) {
|
||||
throw new BadRequestException('Each variation must have at least one value');
|
||||
}
|
||||
}
|
||||
|
||||
if (variation.type === 'custom') {
|
||||
const name = variation.name.trim().toLowerCase();
|
||||
if (!name) {
|
||||
throw new BadRequestException('Custom variations require a name');
|
||||
}
|
||||
if (customNames.has(name)) {
|
||||
throw new BadRequestException(
|
||||
`Duplicate custom variation name "${variation.name}"`,
|
||||
);
|
||||
}
|
||||
customNames.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resolveVariationName(variation: CategoryVariationInputDto): string {
|
||||
if (variation.type === 'color') {
|
||||
return 'Color';
|
||||
}
|
||||
if (variation.type === 'size') {
|
||||
return 'Size';
|
||||
}
|
||||
return variation.name.trim();
|
||||
}
|
||||
|
||||
private async getProductCategory(businessId: bigint, categoryId: bigint) {
|
||||
const category = await this.prisma.category.findFirst({
|
||||
where: {
|
||||
id: categoryId,
|
||||
businessId,
|
||||
entityType: MediaEntityType.product,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
throw new NotFoundException('Product category not found');
|
||||
}
|
||||
|
||||
return category;
|
||||
}
|
||||
|
||||
private async assertPermission(
|
||||
businessId: bigint,
|
||||
userId: bigint,
|
||||
permission: string,
|
||||
) {
|
||||
const allowed = await this.permissions.hasBusinessPermission(
|
||||
userId,
|
||||
businessId,
|
||||
permission,
|
||||
);
|
||||
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(`Missing permission: ${permission} for this business`);
|
||||
}
|
||||
}
|
||||
|
||||
private serialize(variation: {
|
||||
id: bigint;
|
||||
name: string;
|
||||
variationType: VariationType;
|
||||
sortOrder: number;
|
||||
options: {
|
||||
id: bigint;
|
||||
label: string;
|
||||
value: string;
|
||||
colorHex: string | null;
|
||||
sortOrder: number;
|
||||
}[];
|
||||
}) {
|
||||
return {
|
||||
id: variation.id.toString(),
|
||||
name: variation.name,
|
||||
type: variation.variationType,
|
||||
sortOrder: variation.sortOrder,
|
||||
values: variation.options.map((option) => option.label),
|
||||
options: variation.options.map((option) => ({
|
||||
id: option.id.toString(),
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
colorHex: option.colorHex,
|
||||
sortOrder: option.sortOrder,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export const COLOR_PRESETS = [
|
||||
{ name: 'Red', hex: '#EF4444' },
|
||||
{ name: 'Blue', hex: '#3B82F6' },
|
||||
{ name: 'Green', hex: '#22C55E' },
|
||||
{ name: 'Black', hex: '#111827' },
|
||||
{ name: 'White', hex: '#FFFFFF' },
|
||||
{ name: 'Silver', hex: '#C0C0C0' },
|
||||
{ name: 'Gold', hex: '#D4AF37' },
|
||||
{ name: 'Navy', hex: '#1E3A5F' },
|
||||
{ name: 'Yellow', hex: '#EAB308' },
|
||||
{ name: 'Orange', hex: '#F97316' },
|
||||
{ name: 'Purple', hex: '#A855F7' },
|
||||
{ name: 'Pink', hex: '#EC4899' },
|
||||
{ name: 'Brown', hex: '#92400E' },
|
||||
{ name: 'Gray', hex: '#6B7280' },
|
||||
{ name: 'Beige', hex: '#D4C4A8' },
|
||||
] as const;
|
||||
|
||||
export const COLOR_PRESET_NAMES = COLOR_PRESETS.map((color) => color.name);
|
||||
|
||||
export function isColorPreset(value: string): boolean {
|
||||
return COLOR_PRESET_NAMES.some((name) => name === value);
|
||||
}
|
||||
|
||||
export function getColorHex(name: string): string | null {
|
||||
return COLOR_PRESETS.find((color) => color.name === name)?.hex ?? null;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class GenerateCategoriesDto {
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
prompt!: string;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class TechnicalFormFieldInputDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
label!: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['text', 'textarea', 'select', 'multi_select'])
|
||||
type!: 'text' | 'textarea' | 'select' | 'multi_select';
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isRequired?: boolean;
|
||||
|
||||
@ValidateIf((o: TechnicalFormFieldInputDto) =>
|
||||
o.type === 'select' || o.type === 'multi_select',
|
||||
)
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsString({ each: true })
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
export class ReplaceCategoryTechnicalFormDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TechnicalFormFieldInputDto)
|
||||
fields!: TechnicalFormFieldInputDto[];
|
||||
}
|
||||
|
||||
export class SuggestCategoryTechnicalFormDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
hint?: string;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsString,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CategoryVariationInputDto {
|
||||
@IsString()
|
||||
@IsIn(['color', 'size', 'custom'])
|
||||
type!: 'color' | 'size' | 'custom';
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name!: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsString({ each: true })
|
||||
values!: string[];
|
||||
}
|
||||
|
||||
export class ReplaceCategoryVariationsDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CategoryVariationInputDto)
|
||||
variations!: CategoryVariationInputDto[];
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { MediaEntityType } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsOptional, IsString, Matches, Min, MinLength } from 'class-validator';
|
||||
|
||||
export class ListCategoriesDto {
|
||||
@IsOptional()
|
||||
@IsEnum(MediaEntityType)
|
||||
entityType?: MediaEntityType;
|
||||
}
|
||||
|
||||
export class CreateCategoryDto {
|
||||
@IsEnum(MediaEntityType)
|
||||
entityType!: MediaEntityType;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentId?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
nameFa?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
||||
message: 'slug must be lowercase letters, numbers, and hyphens',
|
||||
})
|
||||
slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export class UpdateCategoryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nameFa?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
||||
slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { CitiesService } from './cities.service';
|
||||
import { ListCitiesDto } from './dto/list-cities.dto';
|
||||
|
||||
@Controller('cities')
|
||||
export class CitiesController {
|
||||
constructor(private readonly service: CitiesService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: ListCitiesDto) {
|
||||
return this.service.list(query);
|
||||
}
|
||||
|
||||
@Get(':cityId')
|
||||
getOne(@Param('cityId') cityId: string) {
|
||||
return this.service.getOne(cityId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CitiesController } from './cities.controller';
|
||||
import { CitiesService } from './cities.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CitiesController],
|
||||
providers: [CitiesService],
|
||||
exports: [CitiesService],
|
||||
})
|
||||
export class CitiesModule {}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CityLevel, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ListCitiesDto } from './dto/list-cities.dto';
|
||||
|
||||
type CityRecord = Prisma.CityGetPayload<{
|
||||
select: {
|
||||
id: true;
|
||||
parentId: true;
|
||||
level: true;
|
||||
nameFa: true;
|
||||
nameEn: true;
|
||||
landlineCode: true;
|
||||
slug: true;
|
||||
sortOrder: true;
|
||||
};
|
||||
}>;
|
||||
|
||||
@Injectable()
|
||||
export class CitiesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: ListCitiesDto) {
|
||||
const where: Prisma.CityWhereInput = {
|
||||
isActive: true,
|
||||
...(query.level ? { level: query.level } : {}),
|
||||
};
|
||||
|
||||
if (query.parentId) {
|
||||
where.parentId = BigInt(query.parentId);
|
||||
} else if (query.parentSlug) {
|
||||
const parent = await this.prisma.city.findFirst({
|
||||
where: { slug: query.parentSlug, isActive: true },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!parent) {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
where.parentId = parent.id;
|
||||
} else if (query.level === CityLevel.province || query.level === CityLevel.city) {
|
||||
throw new BadRequestException('parentId or parentSlug is required for this level');
|
||||
} else {
|
||||
where.level = CityLevel.country;
|
||||
}
|
||||
|
||||
const items = await this.prisma.city.findMany({
|
||||
where,
|
||||
orderBy: [{ sortOrder: 'asc' }, { nameEn: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
parentId: true,
|
||||
level: true,
|
||||
nameFa: true,
|
||||
nameEn: true,
|
||||
landlineCode: true,
|
||||
slug: true,
|
||||
sortOrder: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { items: items.map((item) => this.serialize(item)) };
|
||||
}
|
||||
|
||||
async getOne(cityIdRaw: string) {
|
||||
const city = await this.prisma.city.findFirst({
|
||||
where: { id: BigInt(cityIdRaw), isActive: true },
|
||||
select: {
|
||||
id: true,
|
||||
parentId: true,
|
||||
level: true,
|
||||
nameFa: true,
|
||||
nameEn: true,
|
||||
landlineCode: true,
|
||||
slug: true,
|
||||
sortOrder: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!city) {
|
||||
throw new NotFoundException('City not found');
|
||||
}
|
||||
|
||||
return { city: this.serialize(city) };
|
||||
}
|
||||
|
||||
private serialize(city: CityRecord) {
|
||||
return {
|
||||
id: city.id.toString(),
|
||||
parentId: city.parentId?.toString() ?? null,
|
||||
level: city.level,
|
||||
nameFa: city.nameFa,
|
||||
nameEn: city.nameEn,
|
||||
landlineCode: city.landlineCode,
|
||||
slug: city.slug,
|
||||
sortOrder: city.sortOrder,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { CityLevel } from '@prisma/client';
|
||||
import { IsEnum, IsOptional, IsString, Matches } from 'class-validator';
|
||||
|
||||
export class ListCitiesDto {
|
||||
@IsOptional()
|
||||
@IsEnum(CityLevel)
|
||||
level?: CityLevel;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
||||
message: 'parentSlug must be lowercase letters, numbers, and hyphens',
|
||||
})
|
||||
parentSlug?: string;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CommentsService } from './comments.service';
|
||||
import {
|
||||
CreatePublicCommentDto,
|
||||
ListCommentsDto,
|
||||
ListPublicCommentsDto,
|
||||
UpdateCommentApprovalDto,
|
||||
} from './dto/comment.dto';
|
||||
|
||||
@Controller('tenants/:host/comments')
|
||||
export class PublicCommentsController {
|
||||
constructor(private readonly service: CommentsService) {}
|
||||
|
||||
@Post()
|
||||
create(@Param('host') host: string, @Body() dto: CreatePublicCommentDto) {
|
||||
return this.service.createPublic(host, dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@Param('host') host: string, @Query() query: ListPublicCommentsDto) {
|
||||
return this.service.listPublic(host, query);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('businesses/:businessId/comments')
|
||||
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||
export class CommentsController {
|
||||
constructor(private readonly service: CommentsService) {}
|
||||
|
||||
@Get()
|
||||
@RequireBusinessPermission('comments.read')
|
||||
list(
|
||||
@Param('businessId') businessId: string,
|
||||
@Query() query: ListCommentsDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.listAdmin(businessId, query, user);
|
||||
}
|
||||
|
||||
@Patch(':commentId')
|
||||
@RequireBusinessPermission('comments.approve')
|
||||
updateApproval(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('commentId') commentId: string,
|
||||
@Body() dto: UpdateCommentApprovalDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.updateApproval(businessId, commentId, dto, user);
|
||||
}
|
||||
|
||||
@Delete(':commentId')
|
||||
@RequireBusinessPermission('comments.delete')
|
||||
remove(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('commentId') commentId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.remove(businessId, commentId, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
|
||||
import { TenantModule } from '../tenant/tenant.module';
|
||||
import { CommentsController, PublicCommentsController } from './comments.controller';
|
||||
import { CommentsService } from './comments.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, BusinessSettingsModule, TenantModule],
|
||||
controllers: [PublicCommentsController, CommentsController],
|
||||
providers: [CommentsService],
|
||||
})
|
||||
export class CommentsModule {}
|
||||
@@ -0,0 +1,258 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ContentStatus, MediaEntityType, Prisma } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { BusinessSettingsService } from '../business-settings/business-settings.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
import {
|
||||
CreatePublicCommentDto,
|
||||
ListCommentsDto,
|
||||
ListPublicCommentsDto,
|
||||
UpdateCommentApprovalDto,
|
||||
} from './dto/comment.dto';
|
||||
|
||||
type CommentRecord = Prisma.CommentGetPayload<{
|
||||
include: { approver: true };
|
||||
}>;
|
||||
|
||||
@Injectable()
|
||||
export class CommentsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly tenant: TenantService,
|
||||
private readonly businessSettings: BusinessSettingsService,
|
||||
) {}
|
||||
|
||||
async createPublic(host: string, dto: CreatePublicCommentDto) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const businessId = business.id;
|
||||
const entityId = BigInt(dto.entityId);
|
||||
|
||||
await this.assertPublishedEntityExists(businessId, dto.entityType, entityId);
|
||||
|
||||
const autoApprove = await this.businessSettings.isCommentsAutoApprove(businessId);
|
||||
const approvedAt = autoApprove ? new Date() : null;
|
||||
|
||||
const created = await this.prisma.comment.create({
|
||||
data: {
|
||||
businessId,
|
||||
entityType: dto.entityType,
|
||||
entityId,
|
||||
authorName: dto.authorName.trim(),
|
||||
authorEmail: dto.authorEmail?.trim() || null,
|
||||
text: dto.text.trim(),
|
||||
isApproved: autoApprove,
|
||||
approvedAt,
|
||||
},
|
||||
include: { approver: true },
|
||||
});
|
||||
|
||||
return {
|
||||
comment: this.serialize(created),
|
||||
message: autoApprove
|
||||
? 'Comment submitted and is approved'
|
||||
: 'Comment submitted and is pending approval',
|
||||
};
|
||||
}
|
||||
|
||||
async listPublic(host: string, query: ListPublicCommentsDto) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const businessId = business.id;
|
||||
const entityId = BigInt(query.entityId);
|
||||
|
||||
await this.assertPublishedEntityExists(businessId, query.entityType, entityId);
|
||||
|
||||
const items = await this.prisma.comment.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: query.entityType,
|
||||
entityId,
|
||||
isApproved: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { approver: true },
|
||||
});
|
||||
|
||||
return { items: items.map((item) => this.serialize(item)) };
|
||||
}
|
||||
|
||||
async listAdmin(businessIdRaw: string, query: ListCommentsDto, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'comments.read');
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.CommentWhereInput = {
|
||||
businessId,
|
||||
...(query.entityType ? { entityType: query.entityType } : {}),
|
||||
...(query.entityId ? { entityId: BigInt(query.entityId) } : {}),
|
||||
...(query.isApproved !== undefined ? { isApproved: query.isApproved } : {}),
|
||||
};
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.comment.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: { approver: true },
|
||||
}),
|
||||
this.prisma.comment.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((item) => this.serialize(item)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async updateApproval(
|
||||
businessIdRaw: string,
|
||||
commentIdRaw: string,
|
||||
dto: UpdateCommentApprovalDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const commentId = BigInt(commentIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'comments.approve');
|
||||
|
||||
const existing = await this.prisma.comment.findFirst({
|
||||
where: { id: commentId, businessId },
|
||||
include: { approver: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Comment not found');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.comment.update({
|
||||
where: { id: commentId },
|
||||
data: {
|
||||
isApproved: dto.isApproved,
|
||||
approvedAt: dto.isApproved ? new Date() : null,
|
||||
approvedBy: dto.isApproved ? actor.id : null,
|
||||
},
|
||||
include: { approver: true },
|
||||
});
|
||||
|
||||
return { comment: this.serialize(updated) };
|
||||
}
|
||||
|
||||
async remove(businessIdRaw: string, commentIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const commentId = BigInt(commentIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'comments.delete');
|
||||
|
||||
const existing = await this.prisma.comment.findFirst({
|
||||
where: { id: commentId, businessId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Comment not found');
|
||||
}
|
||||
|
||||
await this.prisma.comment.delete({ where: { id: commentId } });
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async assertPublishedEntityExists(
|
||||
businessId: bigint,
|
||||
entityType: MediaEntityType,
|
||||
entityId: bigint,
|
||||
) {
|
||||
if (entityType === MediaEntityType.product) {
|
||||
const product = await this.prisma.product.findFirst({
|
||||
where: {
|
||||
id: entityId,
|
||||
businessId,
|
||||
status: ContentStatus.published,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (entityType === MediaEntityType.blog) {
|
||||
const blog = await this.prisma.blogs.findFirst({
|
||||
where: {
|
||||
id: entityId,
|
||||
business_id: businessId,
|
||||
status: ContentStatus.published,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!blog) {
|
||||
throw new NotFoundException('Blog post not found');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = await this.prisma.$queryRaw<{ id: bigint }[]>`
|
||||
SELECT id FROM portfolios
|
||||
WHERE id = ${entityId}
|
||||
AND business_id = ${businessId}
|
||||
AND status = 'published'::content_status
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
if (rows.length === 0) {
|
||||
throw new NotFoundException('Portfolio item not found');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertPermission(
|
||||
businessId: bigint,
|
||||
userId: bigint,
|
||||
permission: string,
|
||||
) {
|
||||
const allowed = await this.permissions.hasBusinessPermission(
|
||||
userId,
|
||||
businessId,
|
||||
permission,
|
||||
);
|
||||
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException('Insufficient permissions');
|
||||
}
|
||||
}
|
||||
|
||||
private serialize(comment: CommentRecord) {
|
||||
return {
|
||||
id: comment.id.toString(),
|
||||
businessId: comment.businessId.toString(),
|
||||
entityType: comment.entityType,
|
||||
entityId: comment.entityId.toString(),
|
||||
authorName: comment.authorName,
|
||||
authorEmail: comment.authorEmail,
|
||||
text: comment.text,
|
||||
isApproved: comment.isApproved,
|
||||
approvedAt: comment.approvedAt,
|
||||
approvedBy: comment.approvedBy?.toString() ?? null,
|
||||
approver: comment.approver
|
||||
? {
|
||||
id: comment.approver.id.toString(),
|
||||
firstName: comment.approver.firstName,
|
||||
lastName: comment.approver.lastName,
|
||||
}
|
||||
: null,
|
||||
createdAt: comment.createdAt,
|
||||
updatedAt: comment.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { MediaEntityType } from '@prisma/client';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreatePublicCommentDto {
|
||||
@IsEnum(MediaEntityType)
|
||||
entityType!: MediaEntityType;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
entityId!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
authorName!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
authorEmail?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
text!: string;
|
||||
}
|
||||
|
||||
export class ListPublicCommentsDto {
|
||||
@IsEnum(MediaEntityType)
|
||||
entityType!: MediaEntityType;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
entityId!: string;
|
||||
}
|
||||
|
||||
export class ListCommentsDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(MediaEntityType)
|
||||
entityType?: MediaEntityType;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
entityId?: string;
|
||||
|
||||
/** Filter by approval: true = approved, false = pending, omit = all */
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (value === 'true' || value === true) return true;
|
||||
if (value === 'false' || value === false) return false;
|
||||
return value;
|
||||
})
|
||||
@IsBoolean()
|
||||
isApproved?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateCommentApprovalDto {
|
||||
@IsBoolean()
|
||||
isApproved!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ServiceUnavailableException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export type AiProviderConfig = {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
export function resolveAiProvider(config: ConfigService): AiProviderConfig {
|
||||
const configured = config.get<string>('AI_PROVIDER')?.trim().toLowerCase();
|
||||
const groqKey = config.get<string>('GROQ_API_KEY')?.trim();
|
||||
const openAiKey = config.get<string>('OPENAI_API_KEY')?.trim();
|
||||
|
||||
const useGroq =
|
||||
configured === 'groq' || (!configured && !!groqKey) || (!openAiKey && !!groqKey);
|
||||
|
||||
if (useGroq) {
|
||||
if (!groqKey) {
|
||||
throw new ServiceUnavailableException(
|
||||
'AI is not configured. Set GROQ_API_KEY on the server.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey: groqKey,
|
||||
baseUrl: 'https://api.groq.com/openai/v1',
|
||||
model: config.get<string>('GROQ_MODEL')?.trim() || 'llama-3.3-70b-versatile',
|
||||
};
|
||||
}
|
||||
|
||||
if (!openAiKey) {
|
||||
throw new ServiceUnavailableException(
|
||||
'AI is not configured. Set GROQ_API_KEY or OPENAI_API_KEY on the server.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey: openAiKey,
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: config.get<string>('OPENAI_MODEL')?.trim() || 'gpt-4o-mini',
|
||||
};
|
||||
}
|
||||
|
||||
export async function requestAiJsonCompletion(
|
||||
provider: AiProviderConfig,
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
temperature = 0.4,
|
||||
): Promise<string> {
|
||||
const response = await fetch(`${provider.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${provider.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: provider.model,
|
||||
temperature,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
throw new Error(`AI provider error (${response.status}): ${detail.slice(0, 240)}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
choices?: { message?: { content?: string } }[];
|
||||
};
|
||||
|
||||
const content = payload.choices?.[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new Error('AI returned an empty response');
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, map } from 'rxjs';
|
||||
|
||||
function serializeBigInt(value: unknown): unknown {
|
||||
if (typeof value === 'bigint') {
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(serializeBigInt);
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, val]) => [key, serializeBigInt(val)]),
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BigIntSerializerInterceptor implements NestInterceptor {
|
||||
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
return next.handle().pipe(map((data) => serializeBigInt(data)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { ContactSubmissionsService } from './contact-submissions.service';
|
||||
import { CreateContactSubmissionDto } from './dto/create-contact-submission.dto';
|
||||
import { ListContactSubmissionsDto } from './dto/list-contact-submissions.dto';
|
||||
|
||||
@Controller('tenants/:host/contact-submissions')
|
||||
export class PublicContactSubmissionsController {
|
||||
constructor(private readonly service: ContactSubmissionsService) {}
|
||||
|
||||
@Post()
|
||||
create(@Param('host') host: string, @Body() dto: CreateContactSubmissionDto) {
|
||||
return this.service.createPublic(host, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('businesses/:businessId/contact-submissions')
|
||||
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||
export class ContactSubmissionsController {
|
||||
constructor(private readonly service: ContactSubmissionsService) {}
|
||||
|
||||
@Get()
|
||||
@RequireBusinessPermission('business.read')
|
||||
list(
|
||||
@Param('businessId') businessId: string,
|
||||
@Query() query: ListContactSubmissionsDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.list(businessId, query, user);
|
||||
}
|
||||
|
||||
@Get(':submissionId')
|
||||
@RequireBusinessPermission('business.read')
|
||||
getOne(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('submissionId') submissionId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.getOne(businessId, submissionId, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { TenantModule } from '../tenant/tenant.module';
|
||||
import {
|
||||
ContactSubmissionsController,
|
||||
PublicContactSubmissionsController,
|
||||
} from './contact-submissions.controller';
|
||||
import { ContactSubmissionsService } from './contact-submissions.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, TenantModule],
|
||||
controllers: [ContactSubmissionsController, PublicContactSubmissionsController],
|
||||
providers: [ContactSubmissionsService],
|
||||
})
|
||||
export class ContactSubmissionsModule {}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
import { CreateContactSubmissionDto } from './dto/create-contact-submission.dto';
|
||||
import { ListContactSubmissionsDto } from './dto/list-contact-submissions.dto';
|
||||
|
||||
type ContactSubmissionRow = {
|
||||
id: bigint;
|
||||
title: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
cellNumber: string | null;
|
||||
text: string;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ContactSubmissionsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly tenant: TenantService,
|
||||
) {}
|
||||
|
||||
async createPublic(host: string, dto: CreateContactSubmissionDto) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
|
||||
const created = await this.prisma.contactSubmission.create({
|
||||
data: {
|
||||
businessId: business.id,
|
||||
title: dto.title.trim(),
|
||||
name: dto.name.trim(),
|
||||
email: dto.email?.trim() || null,
|
||||
cellNumber: dto.cellNumber?.trim() || null,
|
||||
text: dto.text.trim(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
submission: this.serialize(created),
|
||||
message: 'Contact form submitted successfully',
|
||||
};
|
||||
}
|
||||
|
||||
async list(
|
||||
businessIdRaw: string,
|
||||
query: ListContactSubmissionsDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'business.read');
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = Math.min(Math.max(query.pageSize ?? 20, 1), 100);
|
||||
const skip = (page - 1) * pageSize;
|
||||
const qLike = query.q?.trim() ? `%${query.q.trim()}%` : null;
|
||||
|
||||
const where = Prisma.sql`
|
||||
WHERE cs.business_id = ${businessId}
|
||||
${qLike ? Prisma.sql`
|
||||
AND (
|
||||
cs.title ILIKE ${qLike}
|
||||
OR cs.name ILIKE ${qLike}
|
||||
OR cs.email ILIKE ${qLike}
|
||||
OR cs.cell_number ILIKE ${qLike}
|
||||
OR cs.text ILIKE ${qLike}
|
||||
)
|
||||
` : Prisma.empty}
|
||||
`;
|
||||
|
||||
const [items, totalRow] = await Promise.all([
|
||||
this.prisma.$queryRaw<ContactSubmissionRow[]>(Prisma.sql`
|
||||
SELECT
|
||||
cs.id AS "id",
|
||||
cs.title AS "title",
|
||||
cs.name AS "name",
|
||||
cs.email AS "email",
|
||||
cs.cell_number AS "cellNumber",
|
||||
cs.text AS "text",
|
||||
cs.created_at AS "createdAt"
|
||||
FROM contact_submissions cs
|
||||
${where}
|
||||
ORDER BY cs.created_at DESC
|
||||
LIMIT ${pageSize} OFFSET ${skip}
|
||||
`),
|
||||
this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql`
|
||||
SELECT COUNT(*)::int AS "total"
|
||||
FROM contact_submissions cs
|
||||
${where}
|
||||
`),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((row) => this.serialize(row)),
|
||||
total: totalRow[0]?.total ?? 0,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async getOne(businessIdRaw: string, submissionIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const submissionId = BigInt(submissionIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'business.read');
|
||||
|
||||
const submission = await this.prisma.contactSubmission.findFirst({
|
||||
where: { id: submissionId, businessId },
|
||||
});
|
||||
|
||||
if (!submission) {
|
||||
throw new NotFoundException('Contact submission not found');
|
||||
}
|
||||
|
||||
return { submission: this.serialize(submission) };
|
||||
}
|
||||
|
||||
private serialize(row: ContactSubmissionRow | {
|
||||
id: bigint;
|
||||
title: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
cellNumber: string | null;
|
||||
text: string;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: row.id.toString(),
|
||||
title: row.title,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
cellNumber: row.cellNumber,
|
||||
text: row.text,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private async assertPermission(
|
||||
businessId: bigint,
|
||||
userId: bigint,
|
||||
permission: string,
|
||||
) {
|
||||
const allowed = await this.permissions.hasBusinessPermission(
|
||||
userId,
|
||||
businessId,
|
||||
permission,
|
||||
);
|
||||
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException('Insufficient permissions');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateContactSubmissionDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
title!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
@MaxLength(255)
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
cellNumber?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
text!: string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, IsString, Min } from 'class-validator';
|
||||
|
||||
export class ListContactSubmissionsDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CustomersService } from './customers.service';
|
||||
import { CreateCustomerDto } from './dto/create-customer.dto';
|
||||
import { ListCustomersDto } from './dto/list-customers.dto';
|
||||
import { SearchCustomersDto } from './dto/search-customers.dto';
|
||||
import { UpdateCustomerDto } from './dto/update-customer.dto';
|
||||
|
||||
@Controller('businesses/:businessId/customers')
|
||||
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||
export class CustomersController {
|
||||
constructor(private readonly service: CustomersService) {}
|
||||
|
||||
@Get()
|
||||
@RequireBusinessPermission('orders.read')
|
||||
list(
|
||||
@Param('businessId') businessId: string,
|
||||
@Query() query: ListCustomersDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.list(businessId, query, user);
|
||||
}
|
||||
|
||||
@Get('search')
|
||||
@RequireBusinessPermission('orders.create')
|
||||
search(
|
||||
@Param('businessId') businessId: string,
|
||||
@Query() query: SearchCustomersDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.search(businessId, query, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequireBusinessPermission('orders.create')
|
||||
create(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() body: CreateCustomerDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.create(businessId, body, user);
|
||||
}
|
||||
|
||||
@Patch(':userId')
|
||||
@RequireBusinessPermission('orders.update')
|
||||
update(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('userId') userId: string,
|
||||
@Body() body: UpdateCustomerDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.update(businessId, userId, body, user);
|
||||
}
|
||||
|
||||
@Delete(':userId')
|
||||
@RequireBusinessPermission('orders.update')
|
||||
remove(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('userId') userId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.remove(businessId, userId, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { CustomersController } from './customers.controller';
|
||||
import { CustomersService } from './customers.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [CustomersController],
|
||||
providers: [CustomersService],
|
||||
})
|
||||
export class CustomersModule {}
|
||||
@@ -0,0 +1,402 @@
|
||||
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateCustomerDto } from './dto/create-customer.dto';
|
||||
import { ListCustomersDto } from './dto/list-customers.dto';
|
||||
import { SearchCustomersDto } from './dto/search-customers.dto';
|
||||
import { UpdateCustomerDto } from './dto/update-customer.dto';
|
||||
|
||||
type CustomerListRow = {
|
||||
id: bigint;
|
||||
cellNumber: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
email: string | null;
|
||||
createdAt: Date;
|
||||
isEnabled: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CustomersService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
businessIdRaw: string,
|
||||
query: ListCustomersDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'orders.read');
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = Math.min(Math.max(query.pageSize ?? 24, 1), 100);
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const nameLike = query.name?.trim() ? `%${query.name.trim()}%` : null;
|
||||
const cellLike = query.cellNumber?.trim() ? `%${query.cellNumber.trim()}%` : null;
|
||||
|
||||
const where = Prisma.sql`
|
||||
WHERE bc.business_id = ${businessId}
|
||||
${nameLike ? Prisma.sql`
|
||||
AND (
|
||||
u.first_name ILIKE ${nameLike}
|
||||
OR u.last_name ILIKE ${nameLike}
|
||||
OR (COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) ILIKE ${nameLike}
|
||||
)
|
||||
` : Prisma.empty}
|
||||
${cellLike ? Prisma.sql`AND u.cell_number ILIKE ${cellLike}` : Prisma.empty}
|
||||
`;
|
||||
|
||||
const [items, totalRow] = await Promise.all([
|
||||
this.prisma.$queryRaw<CustomerListRow[]>(Prisma.sql`
|
||||
SELECT
|
||||
u.id AS "id",
|
||||
u.cell_number AS "cellNumber",
|
||||
u.first_name AS "firstName",
|
||||
u.last_name AS "lastName",
|
||||
u.email AS "email",
|
||||
bc.created_at AS "createdAt",
|
||||
bc.is_enabled AS "isEnabled"
|
||||
FROM business_customers bc
|
||||
JOIN users u ON u.id = bc.user_id
|
||||
${where}
|
||||
ORDER BY bc.created_at DESC
|
||||
LIMIT ${pageSize} OFFSET ${skip}
|
||||
`),
|
||||
this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql`
|
||||
SELECT COUNT(*)::int AS "total"
|
||||
FROM business_customers bc
|
||||
JOIN users u ON u.id = bc.user_id
|
||||
${where}
|
||||
`),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((user) => ({
|
||||
id: user.id.toString(),
|
||||
cellNumber: user.cellNumber,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email: user.email,
|
||||
label: this.formatLabel(user),
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
isEnabled: user.isEnabled,
|
||||
})),
|
||||
total: totalRow[0]?.total ?? 0,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async search(
|
||||
businessIdRaw: string,
|
||||
query: SearchCustomersDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'orders.create');
|
||||
|
||||
const q = query.q.trim();
|
||||
const limit = Math.min(Math.max(query.limit ?? 20, 1), 50);
|
||||
const like = `%${q}%`;
|
||||
|
||||
const items = await this.prisma.$queryRaw<
|
||||
{
|
||||
id: bigint;
|
||||
cellNumber: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
email: string | null;
|
||||
}[]
|
||||
>(Prisma.sql`
|
||||
SELECT
|
||||
u.id AS "id",
|
||||
u.cell_number AS "cellNumber",
|
||||
u.first_name AS "firstName",
|
||||
u.last_name AS "lastName",
|
||||
u.email AS "email"
|
||||
FROM business_customers bc
|
||||
JOIN users u ON u.id = bc.user_id
|
||||
WHERE bc.business_id = ${businessId}
|
||||
AND bc.is_enabled = TRUE
|
||||
AND u.is_active = TRUE
|
||||
AND (
|
||||
u.cell_number ILIKE ${like}
|
||||
OR u.first_name ILIKE ${like}
|
||||
OR u.last_name ILIKE ${like}
|
||||
OR u.email ILIKE ${like}
|
||||
OR (COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) ILIKE ${like}
|
||||
)
|
||||
ORDER BY u.first_name ASC NULLS LAST, u.last_name ASC NULLS LAST
|
||||
LIMIT ${limit}
|
||||
`);
|
||||
|
||||
return {
|
||||
items: items.map((user) => ({
|
||||
id: user.id.toString(),
|
||||
cellNumber: user.cellNumber,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email: user.email,
|
||||
label: this.formatLabel(user),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async create(
|
||||
businessIdRaw: string,
|
||||
dto: CreateCustomerDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'orders.create');
|
||||
|
||||
const business = await this.prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
});
|
||||
|
||||
if (!business?.isActive) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
|
||||
const customerRole = await this.prisma.role.findUnique({
|
||||
where: { slug: 'customer' },
|
||||
});
|
||||
|
||||
if (!customerRole) {
|
||||
throw new Error('Customer role is missing. Run database migrations first.');
|
||||
}
|
||||
|
||||
const existingUser = await this.prisma.user.findUnique({
|
||||
where: { cellNumber: dto.cellNumber },
|
||||
include: {
|
||||
businessCustomers: { where: { businessId } },
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser?.businessCustomers.length) {
|
||||
throw new ConflictException('User is already a customer of this business');
|
||||
}
|
||||
|
||||
if (existingUser) {
|
||||
const staffMembership = await this.prisma.businessUser.findUnique({
|
||||
where: {
|
||||
businessId_userId: { businessId, userId: existingUser.id },
|
||||
},
|
||||
});
|
||||
if (staffMembership) {
|
||||
throw new ConflictException('User is already staff of this business');
|
||||
}
|
||||
}
|
||||
|
||||
if (!existingUser && !dto.password) {
|
||||
throw new BadRequestException('password is required for new users');
|
||||
}
|
||||
|
||||
const passwordHash = existingUser
|
||||
? existingUser.passwordHash
|
||||
: await bcrypt.hash(dto.password!, 10);
|
||||
|
||||
const membership = await this.prisma.$transaction(async (tx) => {
|
||||
const account =
|
||||
existingUser ??
|
||||
(await tx.user.create({
|
||||
data: {
|
||||
cellNumber: dto.cellNumber,
|
||||
passwordHash,
|
||||
email: dto.email,
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
cellVerifiedAt: new Date(),
|
||||
},
|
||||
}));
|
||||
|
||||
if (existingUser && !existingUser.cellVerifiedAt) {
|
||||
await tx.user.update({
|
||||
where: { id: account.id },
|
||||
data: { cellVerifiedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
const link = await tx.businessCustomer.create({
|
||||
data: {
|
||||
businessId,
|
||||
userId: account.id,
|
||||
},
|
||||
});
|
||||
|
||||
const hasCustomerRole = await tx.userRole.findUnique({
|
||||
where: {
|
||||
userId_roleId: {
|
||||
userId: account.id,
|
||||
roleId: customerRole.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasCustomerRole) {
|
||||
await tx.userRole.create({
|
||||
data: {
|
||||
userId: account.id,
|
||||
roleId: customerRole.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { account, link };
|
||||
});
|
||||
|
||||
const user = membership.account;
|
||||
const verifiedAt = user.cellVerifiedAt ?? new Date();
|
||||
|
||||
return {
|
||||
id: user.id.toString(),
|
||||
cellNumber: user.cellNumber,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email: user.email,
|
||||
label: this.formatLabel(user),
|
||||
createdAt: membership.link.createdAt.toISOString(),
|
||||
isEnabled: membership.link.isEnabled,
|
||||
isVerified: verifiedAt !== null,
|
||||
role: 'customer',
|
||||
};
|
||||
}
|
||||
|
||||
async update(
|
||||
businessIdRaw: string,
|
||||
userIdRaw: string,
|
||||
dto: UpdateCustomerDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const userId = BigInt(userIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'orders.update');
|
||||
|
||||
const membership = await this.prisma.businessCustomer.findUnique({
|
||||
where: {
|
||||
businessId_userId: { businessId, userId },
|
||||
},
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFoundException('Customer not found for this business');
|
||||
}
|
||||
|
||||
if (dto.isEnabled !== undefined) {
|
||||
await this.prisma.businessCustomer.update({
|
||||
where: { id: membership.id },
|
||||
data: { isEnabled: dto.isEnabled },
|
||||
});
|
||||
}
|
||||
|
||||
const hasProfileUpdate =
|
||||
dto.firstName !== undefined ||
|
||||
dto.lastName !== undefined ||
|
||||
dto.email !== undefined ||
|
||||
dto.cellNumber !== undefined;
|
||||
|
||||
if (hasProfileUpdate) {
|
||||
const user = membership.user;
|
||||
|
||||
if (dto.cellNumber && dto.cellNumber !== user.cellNumber) {
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { cellNumber: dto.cellNumber },
|
||||
});
|
||||
if (existing && existing.id !== userId) {
|
||||
throw new BadRequestException('Cell number is already in use');
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
firstName: dto.firstName?.trim(),
|
||||
lastName: dto.lastName?.trim(),
|
||||
email: dto.email !== undefined ? dto.email.trim() || null : undefined,
|
||||
cellNumber: dto.cellNumber?.trim(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const refreshed = await this.prisma.businessCustomer.findUnique({
|
||||
where: { id: membership.id },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!refreshed) {
|
||||
throw new NotFoundException('Customer not found for this business');
|
||||
}
|
||||
|
||||
const user = refreshed.user;
|
||||
return {
|
||||
id: user.id.toString(),
|
||||
cellNumber: user.cellNumber,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email: user.email,
|
||||
label: this.formatLabel(user),
|
||||
isEnabled: refreshed.isEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
async remove(businessIdRaw: string, userIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const userId = BigInt(userIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'orders.update');
|
||||
|
||||
const membership = await this.prisma.businessCustomer.findUnique({
|
||||
where: {
|
||||
businessId_userId: { businessId, userId },
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFoundException('Customer not found for this business');
|
||||
}
|
||||
|
||||
await this.prisma.businessCustomer.delete({
|
||||
where: { id: membership.id },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private formatLabel(user: {
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
cellNumber: string;
|
||||
email: string | null;
|
||||
}) {
|
||||
const name = [user.firstName, user.lastName].filter(Boolean).join(' ').trim();
|
||||
if (name) {
|
||||
return `${name} · ${user.cellNumber}`;
|
||||
}
|
||||
return user.email ? `${user.email} · ${user.cellNumber}` : user.cellNumber;
|
||||
}
|
||||
|
||||
private async assertPermission(
|
||||
businessId: bigint,
|
||||
userId: bigint,
|
||||
permission: string,
|
||||
) {
|
||||
const allowed = await this.permissions.hasBusinessPermission(
|
||||
userId,
|
||||
businessId,
|
||||
permission,
|
||||
);
|
||||
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(
|
||||
`Missing permission: ${permission} for this business`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
IsEmail,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateCustomerDto {
|
||||
@IsString()
|
||||
@Matches(/^\+[1-9]\d{6,14}$/, {
|
||||
message: 'cellNumber must be in E.164 format (e.g. +989121234567)',
|
||||
})
|
||||
cellNumber!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
firstName!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
lastName!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, IsString, Min } from 'class-validator';
|
||||
|
||||
export class ListCustomersDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cellNumber?: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IsOptional, IsString, MinLength } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class SearchCustomersDto {
|
||||
@IsString()
|
||||
@MinLength(2, { message: 'q must be at least 2 characters' })
|
||||
q!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
limit?: number = 20;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { IsBoolean, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class UpdateCustomerDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isEnabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
firstName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
lastName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
cellNumber?: string;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Query, UseGuards } from '@nestjs/common';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { DisableDomainDto } from './dto/disable-domain.dto';
|
||||
import { ListDomainsDto } from './dto/list-domains.dto';
|
||||
import { ToggleSslDto } from './dto/toggle-ssl.dto';
|
||||
import { UpdateDomainAdminDto } from './dto/update-domain-admin.dto';
|
||||
import { DomainAdminService } from './domain-admin.service';
|
||||
|
||||
@Controller('domains')
|
||||
export class DomainAdminController {
|
||||
constructor(private readonly service: DomainAdminService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
list(@Query() query: ListDomainsDto, @CurrentUser() user: AuthUser) {
|
||||
return this.service.list(query, user);
|
||||
}
|
||||
|
||||
@Patch(':domainId')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
update(
|
||||
@Param('domainId') domainId: string,
|
||||
@Body() dto: UpdateDomainAdminDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.update(domainId, dto, user);
|
||||
}
|
||||
|
||||
@Patch(':domainId/disable')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
disable(
|
||||
@Param('domainId') domainId: string,
|
||||
@Body() dto: DisableDomainDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.disable(domainId, dto, user);
|
||||
}
|
||||
|
||||
@Patch(':domainId/ssl')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
toggleSsl(
|
||||
@Param('domainId') domainId: string,
|
||||
@Body() dto: ToggleSslDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.toggleSsl(domainId, dto, user);
|
||||
}
|
||||
|
||||
@Delete(':domainId')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
remove(@Param('domainId') domainId: string, @CurrentUser() user: AuthUser) {
|
||||
return this.service.remove(domainId, user);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user