feat: add new route for user me and quota

This commit is contained in:
mahyargdz
2025-07-12 12:42:01 +03:30
parent 97383d25f6
commit 7177a13e94
12 changed files with 91 additions and 36 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
// export const AUTH_THROTTLE = "AUTH_THROTTLE"; // export const AUTH_THROTTLE = "AUTH_THROTTLE";
export const AUTH_THROTTLE_TTL = 5 * 60 * 1000; export const AUTH_THROTTLE_TTL = 1 * 60 * 1000;
export const AUTH_THROTTLE_LIMIT = 5; export const AUTH_THROTTLE_LIMIT = 5;
export const AUTH__REFRESH_THROTTLE_TTL = 10 * 60 * 1000; export const AUTH__REFRESH_THROTTLE_TTL = 10 * 60 * 1000;
export const AUTH__REFRESH_THROTTLE_LIMIT = 10; export const AUTH__REFRESH_THROTTLE_LIMIT = 10;
@@ -1,12 +1,12 @@
import { UseGuards, applyDecorators } from "@nestjs/common"; import { UseGuards, applyDecorators } from "@nestjs/common";
import { Throttle, ThrottlerGuard } from "@nestjs/throttler"; import { Throttle, ThrottlerGuard } from "@nestjs/throttler";
import { AUTH__REFRESH_THROTTLE_LIMIT, AUTH__REFRESH_THROTTLE_TTL } from "../constants"; import { AUTH_THROTTLE_LIMIT, AUTH_THROTTLE_TTL, AUTH__REFRESH_THROTTLE_LIMIT, AUTH__REFRESH_THROTTLE_TTL } from "../constants";
export const RateLimit = (limit: number, ttl: number) => applyDecorators(Throttle({ default: { limit, ttl } }), UseGuards(ThrottlerGuard)); export const RateLimit = (limit: number, ttl: number) => applyDecorators(Throttle({ default: { limit, ttl } }), UseGuards(ThrottlerGuard));
// Predefined rate limits for common scenarios // Predefined rate limits for common scenarios
export const StrictRateLimit = () => RateLimit(5, 60000); // 5 requests per minute export const StrictRateLimit = () => RateLimit(AUTH_THROTTLE_LIMIT, AUTH_THROTTLE_TTL); // 5 requests per minute
export const StandardRateLimit = () => RateLimit(30, 60000); // 30 requests per minute export const StandardRateLimit = () => RateLimit(30, 60000); // 30 requests per minute
export const MailSendRateLimit = () => RateLimit(10, 60000); // 10 emails per minute export const MailSendRateLimit = () => RateLimit(10, 60000); // 10 emails per minute
export const RefreshTokenRateLimit = () => RateLimit(AUTH__REFRESH_THROTTLE_LIMIT, AUTH__REFRESH_THROTTLE_TTL); // 10 emails per minute export const RefreshTokenRateLimit = () => RateLimit(AUTH__REFRESH_THROTTLE_LIMIT, AUTH__REFRESH_THROTTLE_TTL); // 10 emails per minute
+1 -1
View File
@@ -8,7 +8,7 @@ export function jwtConfig(): JwtModuleAsyncOptions {
useFactory: (configService: ConfigService) => { useFactory: (configService: ConfigService) => {
return { return {
global: true, global: true,
secret: configService.getOrThrow<string>("JWT_SECRET"), secret: configService.getOrThrow<string>("JWT_SECRET_KEY"),
signOptions: { signOptions: {
issuer: configService.getOrThrow<string>("JWT_ISSUER"), issuer: configService.getOrThrow<string>("JWT_ISSUER"),
}, },
+47 -19
View File
@@ -14,38 +14,34 @@ export const databaseConfig: MikroOrmModuleAsyncOptions = {
const DB_HOST = configService.getOrThrow<string>("DB_HOST"); const DB_HOST = configService.getOrThrow<string>("DB_HOST");
const DB_PORT = configService.getOrThrow<number>("DB_PORT"); const DB_PORT = configService.getOrThrow<number>("DB_PORT");
const encodedPassword = encodeURIComponent(DB_PASS); const encodedPassword = encodeURIComponent(DB_PASS);
const isProduction = configService.getOrThrow<string>("NODE_ENV") === "production";
return { return {
driver: PostgreSqlDriver, driver: PostgreSqlDriver,
autoLoadEntities: true, autoLoadEntities: true,
dbName: configService.getOrThrow<string>("DB_NAME"), dbName: configService.getOrThrow<string>("DB_NAME"),
debug: configService.get<string>("NODE_ENV") !== "production", debug: !isProduction,
clientUrl: `postgres://${DB_USER}:${encodedPassword}@${DB_HOST}:${DB_PORT}`, clientUrl: `postgres://${DB_USER}:${encodedPassword}@${DB_HOST}:${DB_PORT}`,
ensureDatabase: { forceCheck: true, create: true, schema: "update" }, ensureDatabase: isProduction
? false
: {
forceCheck: true,
create: true,
schema: "update",
},
forceUtcTimezone: true, forceUtcTimezone: true,
pool: { pool: {
min: 5, min: isProduction ? 5 : 2,
max: 25, max: isProduction ? 20 : 10,
idleTimeoutMillis: 30000, idleTimeoutMillis: 30000,
acquireTimeoutMillis: 15000, acquireTimeoutMillis: 30000,
reapIntervalMillis: 1000, reapIntervalMillis: 1000,
createTimeoutMillis: 8000, createTimeoutMillis: 15000,
destroyTimeoutMillis: 5000, destroyTimeoutMillis: 5000,
createRetryIntervalMillis: 200,
propagateCreateError: false,
}, },
driverOptions: { logger: isProduction ? (message) => logger.log(message) : (message) => logger.debug(message),
connection: {
keepAlive: true,
keepAliveInitialDelayMillis: 0,
statement_timeout: 60000,
query_timeout: 60000,
connectionTimeoutMillis: 10000,
},
},
logger: (message) => logger.debug(message),
schemaGenerator: { schemaGenerator: {
createForeignKey: true, createForeignKey: !isProduction,
disableForeignKeys: false, disableForeignKeys: false,
createIndex: true, createIndex: true,
}, },
@@ -59,6 +55,38 @@ export const databaseConfig: MikroOrmModuleAsyncOptions = {
safe: true, safe: true,
emit: "ts", emit: "ts",
}, },
connect: true,
allowGlobalContext: false,
driverOptions: {
connection: {
keepAlive: true,
keepAliveInitialDelayMillis: 10000,
statement_timeout: 60000,
query_timeout: 60000,
idle_in_transaction_session_timeout: 60000,
connectionTimeoutMillis: 10000,
application_name: "fresh-bazzar-api",
},
connectionRetries: 5,
connectionRetryDelay: 3000,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
validateConnection: (connection: any) => {
try {
return connection && !connection.closed;
} catch (e: unknown) {
logger.error(`Connection validation failed: ${(e as Error).message}`);
return false;
}
},
poolErrorHandler: (err: Error) => {
logger.error(`PostgreSQL pool error: ${err.message}`);
if (err.message.includes("ECONNRESET") || err.message.includes("Connection terminated unexpectedly")) {
logger.warn("Connection reset detected, will attempt to reconnect");
return true;
}
return false;
},
},
}; };
}, },
}; };
+2 -1
View File
@@ -5,7 +5,7 @@ import { LoginDto } from "./DTO/login.dto";
import { RefreshTokenDto } from "./DTO/refresh-token.dto"; import { RefreshTokenDto } from "./DTO/refresh-token.dto";
import { AuthService } from "./services/auth.service"; import { AuthService } from "./services/auth.service";
import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
import { RefreshTokenRateLimit } from "../../common/decorators/rate-limit.decorator"; import { RefreshTokenRateLimit, StrictRateLimit } from "../../common/decorators/rate-limit.decorator";
import { UserDec } from "../../common/decorators/user.decorator"; import { UserDec } from "../../common/decorators/user.decorator";
@ApiTags("Authentication") @ApiTags("Authentication")
@@ -13,6 +13,7 @@ import { UserDec } from "../../common/decorators/user.decorator";
export class AuthController { export class AuthController {
constructor(private readonly authService: AuthService) {} constructor(private readonly authService: AuthService) {}
@StrictRateLimit()
@Post("login") @Post("login")
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Login user" }) @ApiOperation({ summary: "Login user" })
@@ -12,7 +12,7 @@ export class LocalJwtStrategy extends PassportStrategy(Strategy, LOCAL_JWT_STRAT
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false, ignoreExpiration: false,
secretOrKey: configService.getOrThrow<string>("JWT_SECRET"), secretOrKey: configService.getOrThrow<string>("JWT_SECRET_KEY"),
issuer: configService.getOrThrow<string>("JWT_ISSUER"), issuer: configService.getOrThrow<string>("JWT_ISSUER"),
}); });
} }
+1 -1
View File
@@ -1,5 +1,5 @@
export const SUBSCRIPTIONS = Object.freeze({ export const SUBSCRIPTIONS = Object.freeze({
PROVISIONING_QUEUE_PREFIX: "provisioning", PROVISIONING_QUEUE_PREFIX: "subs",
PROVISIONING_QUEUE_NAME: "provisioning", PROVISIONING_QUEUE_NAME: "provisioning",
PROVISIONING_JOB_NAME: "business.created", PROVISIONING_JOB_NAME: "business.created",
@@ -132,7 +132,7 @@ export class DomainAutomationService {
if (!mailServerUser.success) throw new BadRequestException(MailServerMessage.FAILED_TO_CREATE_ACCOUNT); if (!mailServerUser.success) throw new BadRequestException(MailServerMessage.FAILED_TO_CREATE_ACCOUNT);
// Check if business has enough quota // Check if business has enough quota
const remainingQuota = domain.business.remainingQuota || 0; const remainingQuota = Number(domain.business.remainingQuota || 0);
if (remainingQuota < QUOTA_CONSTANTS.USER_QUOTA_DEDUCTION) throw new BadRequestException(BusinessMessage.INSUFFICIENT_QUOTA); if (remainingQuota < QUOTA_CONSTANTS.USER_QUOTA_DEDUCTION) throw new BadRequestException(BusinessMessage.INSUFFICIENT_QUOTA);
// Create user in local database // Create user in local database
@@ -152,8 +152,8 @@ export class DomainAutomationService {
}); });
// Update business quota after successful user creation // Update business quota after successful user creation
domain.business.usedQuota = (domain.business.usedQuota || 0) + QUOTA_CONSTANTS.USER_QUOTA_DEDUCTION; domain.business.usedQuota = Number(domain.business.usedQuota || 0) + QUOTA_CONSTANTS.USER_QUOTA_DEDUCTION;
domain.business.remainingQuota = (domain.business.quota || 0) - domain.business.usedQuota; domain.business.remainingQuota = Number(domain.business.quota || 0) - Number(domain.business.usedQuota);
await this.em.persistAndFlush([user, domain.business]); await this.em.persistAndFlush([user, domain.business]);
@@ -216,6 +216,8 @@ export class DomainsService {
const domain = await this.getDomainById(businessDomain.id, businessId); const domain = await this.getDomainById(businessDomain.id, businessId);
const { dnsRecords } = await this.dnsService.getDomainDnsRecords(domain.id);
// If domain is already verified, skip verification check and return DNS records directly // If domain is already verified, skip verification check and return DNS records directly
if (domain.isVerified && domain.status === DomainStatus.VERIFIED) { if (domain.isVerified && domain.status === DomainStatus.VERIFIED) {
const { dnsRecords } = await this.dnsService.getDomainDnsRecords(domain.id); const { dnsRecords } = await this.dnsService.getDomainDnsRecords(domain.id);
@@ -234,7 +236,7 @@ export class DomainsService {
}; };
} }
const { dnsRecords, overallStatus, recommendations } = await this.checkDomainVerificationStatus(businessId); const { overallStatus, recommendations } = await this.checkDomainVerificationStatus(businessId);
return { return {
dnsRecords, dnsRecords,
@@ -304,7 +306,7 @@ export class DomainsService {
} }
return { return {
dnsRecords: verificationStatuses, verificationStatuses,
overallStatus: { overallStatus: {
isVerified, isVerified,
isComplete, isComplete,
@@ -29,6 +29,7 @@ export class UserRepository extends EntityRepository<User> {
} }
return this.findAndCount(whereClause, { return this.findAndCount(whereClause, {
exclude: ["password"],
populate: ["domain", "business"], populate: ["domain", "business"],
limit, limit,
offset: skip, offset: skip,
+20 -5
View File
@@ -57,9 +57,24 @@ export class UsersService {
/*******************************/ /*******************************/
async getMe(userId: string) { async getMe(userId: string) {
const user = await this.userRepository.findOne({ id: userId }, { populate: ["role"] }); const user = await this.userRepository.findOne({ id: userId }, { exclude: ["password"], populate: ["role"] });
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND);
return { user };
return {
user,
quota: {
total: Number(user.emailQuota),
used: Number(user.emailQuotaUsed),
remaining: Number(user.emailQuota - user.emailQuotaUsed),
totalInMB: Math.round(Number(user.emailQuota) / (1024 * 1024)),
totalInGB: Math.round(Number(user.emailQuota) / (1024 * 1024 * 1024)),
usedInMB: Math.round(Number(user.emailQuotaUsed) / (1024 * 1024)),
usedInGB: Math.round(Number(user.emailQuotaUsed) / (1024 * 1024 * 1024)),
remainingInMB: Math.round(Number(user.emailQuota - user.emailQuotaUsed) / (1024 * 1024)),
remainingInGB: Math.round(Number(user.emailQuota - user.emailQuotaUsed) / (1024 * 1024 * 1024)),
usagePercentage: Math.round((Number(user.emailQuotaUsed) / Number(user.emailQuota)) * 100),
},
};
} }
/*******************************/ /*******************************/
@@ -77,7 +92,7 @@ export class UsersService {
if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND); if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND);
// Check if business has enough quota // Check if business has enough quota
const remainingQuota = business.remainingQuota || 0; const remainingQuota = Number(business.remainingQuota || 0);
if (remainingQuota < QUOTA_CONSTANTS.USER_QUOTA_DEDUCTION) { if (remainingQuota < QUOTA_CONSTANTS.USER_QUOTA_DEDUCTION) {
throw new BadRequestException("Insufficient quota to create user. Please upgrade your plan."); throw new BadRequestException("Insufficient quota to create user. Please upgrade your plan.");
} }
@@ -129,8 +144,8 @@ export class UsersService {
}); });
// Update business quota after successful user creation // Update business quota after successful user creation
business.usedQuota = business.usedQuota + QUOTA_CONSTANTS.USER_QUOTA_DEDUCTION; business.usedQuota = Number(business.usedQuota) + QUOTA_CONSTANTS.USER_QUOTA_DEDUCTION;
business.remainingQuota = business.quota - business.usedQuota; business.remainingQuota = Number(business.quota) - Number(business.usedQuota);
await this.em.persistAndFlush([user, business]); await this.em.persistAndFlush([user, business]);
+8
View File
@@ -6,6 +6,7 @@ import { UserListQueryDto } from "./DTO/user-list-query.dto";
import { UsersService } from "./services/users.service"; import { UsersService } from "./services/users.service";
import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
import { BusinessDec } from "../../common/decorators/business.decorator"; import { BusinessDec } from "../../common/decorators/business.decorator";
import { UserDec } from "../../common/decorators/user.decorator";
import { ParamDto } from "../../common/DTO/param.dto"; import { ParamDto } from "../../common/DTO/param.dto";
import { BusinessInterceptor } from "../../core/interceptors/business.interceptor"; import { BusinessInterceptor } from "../../core/interceptors/business.interceptor";
@@ -16,6 +17,13 @@ import { BusinessInterceptor } from "../../core/interceptors/business.intercepto
export class UsersController { export class UsersController {
constructor(private readonly usersService: UsersService) {} constructor(private readonly usersService: UsersService) {}
@Get("me")
@ApiOperation({ summary: "Get current user" })
@ApiResponse({ status: 200, description: "Current user retrieved successfully" })
getMe(@UserDec("wildduckUserId") userId: string) {
return this.usersService.getMe(userId);
}
@Post() @Post()
@ApiOperation({ summary: "Create a new email user" }) @ApiOperation({ summary: "Create a new email user" })
@ApiResponse({ status: 201, description: "Email user created successfully" }) @ApiResponse({ status: 201, description: "Email user created successfully" })