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:
Ali Reza
2026-07-21 17:52:36 +03:30
commit bb59d5e9ba
254 changed files with 37031 additions and 0 deletions
+67
View File
@@ -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 {}
+108
View File
@@ -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);
}
}
+42
View File
@@ -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 {}
+467
View File
@@ -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();
}
}
+98
View File
@@ -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);
+10
View File
@@ -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;
}
+13
View File
@@ -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;
}
+6
View File
@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class RefreshTokenDto {
@IsString()
refreshToken!: string;
}
+30
View File
@@ -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;
}
+9
View File
@@ -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;
}
+63
View File
@@ -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;
}
+33
View File
@@ -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;
}
+14
View File
@@ -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;
}
}
+5
View File
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
+98
View File
@@ -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);
}
}
+18
View File
@@ -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 : '',
};
}
+37
View File
@@ -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');
}
}
+82
View File
@@ -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';
}
}
+109
View File
@@ -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,
};
}
}
+120
View File
@@ -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);
}
}
+13
View File
@@ -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 {}
+733
View File
@@ -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`);
}
}
}
+170
View File
@@ -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;
}
+75
View File
@@ -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);
}
}
+12
View File
@@ -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 {}
+284
View File
@@ -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`,
);
}
}
}
+86
View File
@@ -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 };
}
}
+12
View File
@@ -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,
);
}
}
+11
View File
@@ -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 {}
+284
View File
@@ -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;
}
+75
View File
@@ -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);
}
}
+12
View File
@@ -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 {}
+404
View File
@@ -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');
}
}
}
+73
View File
@@ -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;
}
+176
View File
@@ -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);
}
}
+23
View File
@@ -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 {}
+320
View File
@@ -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,
};
}
}
+280
View File
@@ -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,
})),
};
}
}
+27
View File
@@ -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;
}
+7
View File
@@ -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[];
}
+77
View File
@@ -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;
}
+18
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+100
View File
@@ -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,
};
}
}
+19
View File
@@ -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;
}
+75
View File
@@ -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);
}
}
+13
View File
@@ -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 {}
+258
View File
@@ -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,
};
}
}
+79
View File
@@ -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;
}
+83
View File
@@ -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;
}
+78
View File
@@ -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);
}
}
+11
View File
@@ -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 {}
+402
View File
@@ -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`,
);
}
}
}
+32
View File
@@ -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;
}
+24
View File
@@ -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;
}
+12
View File
@@ -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;
}
+26
View File
@@ -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