chore: first commit
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { MikroOrmModule } from "@mikro-orm/nestjs";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { BullModule } from "@nestjs/bullmq";
|
||||
import { CacheModule } from "@nestjs/cache-manager";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { ThrottlerModule } from "@nestjs/throttler";
|
||||
|
||||
import { bullMqConfig } from "./configs/bullmq.config";
|
||||
import { cacheConfig } from "./configs/cache.config";
|
||||
import { httpConfig } from "./configs/http.config";
|
||||
import { databaseConfig } from "./configs/mikro-orm.config";
|
||||
import { rateLimitConfig } from "./configs/rateLimit.config";
|
||||
import { AuthModule } from "./modules/auth/auth.module";
|
||||
import { DomainsModule } from "./modules/domains/domains.module";
|
||||
import { MailServerModule } from "./modules/mail-server/mail-server.module";
|
||||
import { UsersModule } from "./modules/users/users.module";
|
||||
import { UtilsModule } from "./modules/utils/utils.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true, cache: true }),
|
||||
BullModule.forRootAsync(bullMqConfig()),
|
||||
CacheModule.registerAsync(cacheConfig()),
|
||||
HttpModule.registerAsync(httpConfig()),
|
||||
ThrottlerModule.forRootAsync(rateLimitConfig()),
|
||||
MikroOrmModule.forRootAsync(databaseConfig),
|
||||
UtilsModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
DomainsModule,
|
||||
MailServerModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsNotEmpty, IsNumber, IsOptional, Max, Min } from "class-validator";
|
||||
|
||||
export class PaginationDto {
|
||||
@IsOptional()
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
@ApiPropertyOptional({ type: "number", required: false })
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(50)
|
||||
@Type(() => Number)
|
||||
@ApiPropertyOptional({ type: "number", required: false })
|
||||
limit?: number;
|
||||
}
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
import { ApiProperty, PartialType } from "@nestjs/swagger";
|
||||
import { IsNotEmpty, IsUUID } from "class-validator";
|
||||
|
||||
import { CommonMessage } from "../enums/message.enum";
|
||||
|
||||
export class ParamDto {
|
||||
@IsNotEmpty({ message: CommonMessage.ID_REQUIRED })
|
||||
@IsUUID("7", { message: CommonMessage.ID_SHOULD_BE_UUID })
|
||||
@ApiProperty({ description: "Id of the entity", example: "8b1e8b1e-8b1e-8b1e-8b1e-8b1e8b1e8b1e" })
|
||||
id: string;
|
||||
}
|
||||
|
||||
export class OptionalParamDto extends PartialType(ParamDto) {}
|
||||
|
||||
export class ParamNumberIdDto {
|
||||
@IsNotEmpty({ message: CommonMessage.ID_REQUIRED })
|
||||
@ApiProperty({ description: "Id of the entity", example: "20" })
|
||||
id: string;
|
||||
}
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
// export const AUTH_THROTTLE = "AUTH_THROTTLE";
|
||||
export const AUTH_THROTTLE_TTL = 5 * 60 * 1000;
|
||||
export const AUTH_THROTTLE_LIMIT = 5;
|
||||
export const AUTH__REFRESH_THROTTLE_TTL = 10 * 60 * 1000;
|
||||
export const AUTH__REFRESH_THROTTLE_LIMIT = 10;
|
||||
//
|
||||
export const CONSOLE_JWT_STRATEGY_NAME = "console_jwt_strategy";
|
||||
export const LOCAL_JWT_STRATEGY_NAME = "local_jwt_strategy";
|
||||
|
||||
export const DANAK_IMAPS_SERVER = "imaps.danakcorp.com";
|
||||
export const DANAK_SMTPS_SERVER = "smtps.danakcorp.com";
|
||||
export const DANAK_IMAPS_PORT = 993;
|
||||
export const DANAK_SMTPS_PORT = 465;
|
||||
export const DANAK_IMAPS_ENCRYPTION = "TLS";
|
||||
export const DANAK_SMTPS_ENCRYPTION = "TLS";
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
export const ADMIN_ROUTE = "shouldAdmin";
|
||||
import { SetMetadata } from "@nestjs/common";
|
||||
|
||||
export const AdminRoute = (isAdminRoute: boolean = true) => SetMetadata(ADMIN_ROUTE, isAdminRoute);
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { UseGuards, applyDecorators } from "@nestjs/common";
|
||||
import { ApiBearerAuth } from "@nestjs/swagger";
|
||||
|
||||
import { JwtAuthGuard } from "../../modules/auth/guards/auth.guard";
|
||||
|
||||
export function AuthGuards() {
|
||||
return applyDecorators(UseGuards(JwtAuthGuard), ApiBearerAuth("authorization"));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ExecutionContext, createParamDecorator } from "@nestjs/common";
|
||||
import { FastifyRequest } from "fastify";
|
||||
|
||||
/**
|
||||
* Extracts the business ID from the request header
|
||||
* @example
|
||||
* ```typescript
|
||||
* @Get()
|
||||
* findAll(@BusinessIdHeader() businessId: string) {
|
||||
* // Use businessId
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const BusinessIdHeader = createParamDecorator((headerName: string = "X-Business-Id", ctx: ExecutionContext) => {
|
||||
const req = ctx.switchToHttp().getRequest<FastifyRequest>();
|
||||
return req.headers[headerName.toLocaleLowerCase()];
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ExecutionContext, createParamDecorator } from "@nestjs/common";
|
||||
import { FastifyRequest } from "fastify";
|
||||
|
||||
import { Business } from "../../modules/businesses/entities/business.entity";
|
||||
|
||||
export const BusinessDec = createParamDecorator((data: keyof Business | undefined, ctx: ExecutionContext) => {
|
||||
const req = ctx.switchToHttp().getRequest<FastifyRequest>();
|
||||
const business = req.business;
|
||||
|
||||
return data ? business?.[data] : business;
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { ValidationArguments, ValidationOptions, ValidatorConstraint, ValidatorConstraintInterface, registerDecorator } from "class-validator";
|
||||
|
||||
export function isValidIranianNationalCode(nationalCode: string): boolean {
|
||||
if (!/^\d{10}$/.test(nationalCode)) return false;
|
||||
|
||||
const digits = nationalCode.split("").map(Number);
|
||||
const controlDigit = digits[9];
|
||||
const sum = digits.slice(0, 9).reduce((acc, digit, index) => acc + digit * (10 - index), 0);
|
||||
const remainder = sum % 11;
|
||||
|
||||
return (remainder < 2 && controlDigit === remainder) || (remainder >= 2 && controlDigit === 11 - remainder);
|
||||
}
|
||||
|
||||
@ValidatorConstraint({ async: false })
|
||||
export class IsNationalCodeConstraint implements ValidatorConstraintInterface {
|
||||
validate(value: unknown, _args: ValidationArguments) {
|
||||
return typeof value === "string" && isValidIranianNationalCode(value);
|
||||
}
|
||||
|
||||
defaultMessage(_args: ValidationArguments) {
|
||||
return `Invalid Iranian national code :${_args.value}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function IsNationalCode(validationOptions?: ValidationOptions) {
|
||||
return function (object: object, propertyName: string) {
|
||||
registerDecorator({
|
||||
target: object.constructor,
|
||||
propertyName: propertyName,
|
||||
options: validationOptions,
|
||||
constraints: [],
|
||||
validator: IsNationalCodeConstraint,
|
||||
});
|
||||
};
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { applyDecorators } from "@nestjs/common";
|
||||
import { ApiQuery } from "@nestjs/swagger";
|
||||
|
||||
export function Pagination() {
|
||||
return applyDecorators(
|
||||
ApiQuery({ name: "page", example: 1, required: false, type: "number" }),
|
||||
ApiQuery({ name: "limit", example: 10, required: false, type: "number" }),
|
||||
);
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
// import { SetMetadata } from "@nestjs/common";
|
||||
|
||||
// import { PermissionEnum } from "../../modules/users/enums/permission.enum";
|
||||
|
||||
// export const PERMISSION_KEY = "permissions";
|
||||
// export const PermissionsDec = (...permissions: PermissionEnum[]) => SetMetadata(PERMISSION_KEY, permissions);
|
||||
@@ -0,0 +1,12 @@
|
||||
import { UseGuards, applyDecorators } from "@nestjs/common";
|
||||
import { Throttle, ThrottlerGuard } from "@nestjs/throttler";
|
||||
|
||||
import { AUTH__REFRESH_THROTTLE_LIMIT, AUTH__REFRESH_THROTTLE_TTL } from "../constants";
|
||||
|
||||
export const RateLimit = (limit: number, ttl: number) => applyDecorators(Throttle({ default: { limit, ttl } }), UseGuards(ThrottlerGuard));
|
||||
|
||||
// Predefined rate limits for common scenarios
|
||||
export const StrictRateLimit = () => RateLimit(5, 60000); // 5 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 RefreshTokenRateLimit = () => RateLimit(AUTH__REFRESH_THROTTLE_LIMIT, AUTH__REFRESH_THROTTLE_TTL); // 10 emails per minute
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
// import { SetMetadata } from "@nestjs/common";
|
||||
|
||||
// export const ROLES_KEY = "roles";
|
||||
// export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from "@nestjs/common";
|
||||
|
||||
export const SKIP_AUTH_KEY = "skipAuth";
|
||||
|
||||
export const SkipAuth = () => SetMetadata(SKIP_AUTH_KEY, true);
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
import { ExecutionContext, createParamDecorator } from "@nestjs/common";
|
||||
import { FastifyRequest } from "fastify";
|
||||
|
||||
import { ITokenPayload } from "../../modules/auth/interfaces/IToken-payload";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
user?: ITokenPayload;
|
||||
}
|
||||
}
|
||||
|
||||
export const UserDec = createParamDecorator((data: keyof ITokenPayload | undefined, ctx: ExecutionContext) => {
|
||||
const req = ctx.switchToHttp().getRequest<FastifyRequest>();
|
||||
const user = req.user;
|
||||
|
||||
return data ? user?.[data] : user;
|
||||
});
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
import { Opt, PrimaryKey, Property, sql } from "@mikro-orm/core";
|
||||
import { v7 } from "uuid";
|
||||
|
||||
export abstract class BaseEntity {
|
||||
@PrimaryKey({ type: "uuid" })
|
||||
id = v7();
|
||||
|
||||
@Property({ default: sql.now(), type: "timestamptz" })
|
||||
createdAt: Date & Opt;
|
||||
|
||||
@Property({ default: sql.now(), type: "timestamptz" })
|
||||
updatedAt: Date & Opt;
|
||||
|
||||
@Property({ nullable: true })
|
||||
deletedAt?: Date;
|
||||
}
|
||||
Executable
+222
@@ -0,0 +1,222 @@
|
||||
export const enum AuthMessage {
|
||||
UNAUTHORIZED_ACCESS = "شما دسترسی لازم برای این عملیات را ندارید.",
|
||||
PHONE_REGISTERED = "شماره ثبت شد",
|
||||
INVALID_PHONE_FORMAT = "فرمت شماره تلفن صحیح نیست",
|
||||
OTP_SENT = "کد یکبار مصرف به شماره شما ارسال شد.",
|
||||
INVALID_PHONE = "شماره موبایل اشتباه می باشد",
|
||||
INVALID_CREDENTIAL = "شماره موبایل یا رمز عبور اشتباه می باشد",
|
||||
OTP_FAILED = "کد یا شماره موبایل اشتباه است",
|
||||
LOGIN_SUCCESS = "ورود با موفقیت انجام شد",
|
||||
PASSWORD_LOGIN_SUCCESS = "با موفقیت وارد شدید",
|
||||
FORGOT_PASSWORD_SUCCESS = "درخواست فراموشی رمز عبور با موفقیت ثبت شد",
|
||||
PASSWORD_SET_SUCCESS = "پسورد با موفقیت تنظیم شد",
|
||||
PASSWORD_UPDATE_SUCCESS = "رمز عبور شما تغییر کرد",
|
||||
INVALID_OTP = "کد صحیح نمیباشد یا منقضی شده است",
|
||||
PASSWORD_MISMATCH = "رمز عبور یکسان نیست",
|
||||
INVALID_PASSWORD = "ایمیل یا رمز عبور اشتباه است",
|
||||
INVALID_REPEAT_PASSWORD = "تکرار رمز عبور اشتباه است",
|
||||
EMAIL_NOT_EMPTY = "ایمیل نمیتواند خالی باشد.",
|
||||
INVALID_EMAIL_FORMAT = "فرمت ایمیل صحیح نیست",
|
||||
PasswordNotEmpty = "پسورد نمیتواند خالی باشد.",
|
||||
USER_NOT_FOUND = "کاربری با این شماره وجود ندارد",
|
||||
USER_EXISTS = "با این شماره قبلا ثبت نام شده است",
|
||||
USER_REGISTER_SUCCESS = "ثبت نام موفقیت آمیز بود",
|
||||
INVALID_USER = "کاربری با این مشخصات یافت نشد",
|
||||
PHONE_NOT_FOUND = "کاربری با این شماره یافت نشد",
|
||||
TOKEN_EXPIRED = "توکن منقضی شده است",
|
||||
TOKEN_INVALID = "توکن نامعتبر است",
|
||||
BANNED = "در حال حاضر شما امکان دسترسی به این سرویس را ندارید",
|
||||
PHONE_EXISTS = "شماره تلفن قبلا ثبت شده است",
|
||||
ADMIN_CREATED = "ادمین با موفقیت ایجاد شد",
|
||||
PERM_NOT_FOUND = "دسترسی یافت نشد",
|
||||
INVALID_PASS_FORMAT = "فرمت رمز عبور صحیح نیست",
|
||||
PHONE_NOT_EMPTY = "شماره تلفن نمیتواند خالی باشد",
|
||||
PASSWORD_FORMAT_INVALID = "رمز عبور باید به صورت رشته باشد",
|
||||
OTP_FORMAT_INVALID = "کد یکبار مصرف باید عددی و ۵ رقمی باشد",
|
||||
PHONE_FORMAT_INVALID = "شماره تلفن باید معتبر و به فرمت ایران باشد",
|
||||
TOKEN_NOT_EMPTY = "توکن نمیتواند خالی باشد",
|
||||
PASSWORD_NOT_EMPTY = "رمز عبور نمیتواند خالی باشد",
|
||||
PASSWORD_CHANGED_SUCCESSFULLY = "رمز عبور با موفقیت تغییر کرد",
|
||||
PASSWORD_CONFIRMATION_NOT_EMPTY = "تایید رمز عبور نمیتواند خالی باشد",
|
||||
PASSWORD_LENGTH = "رمز عبور باید حداقل ۸ کاراکتر باشد",
|
||||
OTP_NOT_EMPTY = "کد یکبار مصرف نمیتواند خالی باشد",
|
||||
LAST_PASSWORD_NOT_EMPTY = "رمز عبور قبلی نمیتواند خالی باشد",
|
||||
FIRST_NAME_NOT_EMPTY = "نام نمیتواند خالی باشد",
|
||||
LAST_NAME_NOT_EMPTY = "نام خانوادگی نمیتواند خالی باشد",
|
||||
FIRST_NAME_SHOULD_BE_BETWEEN_2_AND_50 = "نام باید بین ۲ تا ۵۰ کاراکتر باشد",
|
||||
LAST_NAME_SHOULD_BE_BETWEEN_2_AND_50 = "نام خانوادگی باید بین ۲ تا ۵۰ کاراکتر باشد",
|
||||
BIRTH_DATE_NOT_EMPTY = "تاریخ تولد نمیتواند خالی باشد",
|
||||
NATIONAL_CODE_INCORRECT = "کد ملی باید ۱۰ رقمی باشد",
|
||||
NATIONAL_NOT_EMPTY = "کد ملی نمیتواند خالی باشد",
|
||||
OTP_ALREADY_SENT = "کد یکبار مصرف قبلا ارسال شده است",
|
||||
TOO_MANY_REQUESTS = "تعداد درخواست های شما بیش از حد مجاز است",
|
||||
NOT_ADMIN = "شما دسترسی به بخش ادمین ندارید",
|
||||
PHONE_SHOULD_BE_11_DIGIT = "شماره تلفن باید ۱۱ رقم باشد",
|
||||
TIMESTAMP_NOT_EMPTY = "زمان نمیتواند خالی باشد",
|
||||
ADMIN_CAN_NOT_LOGIN = "ادمین نمیتواند وارد شود",
|
||||
NATIONAL_CODE_INVALID = "کد ملی نامعتبر است",
|
||||
INVALID_REFRESH_TOKEN = "توکن رفرش نامعتبر است",
|
||||
REFRESH_TOKEN_EXPIRED = "توکن رفرش منقضی شده است",
|
||||
LOGOUT_SUCCESS = "خروج با موفقیت انجام شد",
|
||||
CURRENT_PASSWORD_INVALID = "رمز عبور فعلی نامعتبر است",
|
||||
TOKEN_EXPIRED_OR_INVALID = "توکن منقضی شده یا نامعتبر است",
|
||||
ACCESS_DENIED = "دسترسی شما محدود شده است",
|
||||
USER_ACCOUNT_INACTIVE = "حساب کاربری شما غیر فعال است",
|
||||
}
|
||||
|
||||
export const enum UserMessage {
|
||||
USER_NOT_FOUND = "کاربری با این شماره وجود ندارد",
|
||||
USER_EXISTS = "با این شماره قبلا ثبت نام شده است",
|
||||
USER_REGISTER_SUCCESS = "ثبت نام موفقیت آمیز بود",
|
||||
INVALID_USER = "کاربری با این مشخصات یافت نشد",
|
||||
EMAIL_ADDRESS_ALREADY_EXISTS = "ایمیل قبلا ثبت شده است",
|
||||
EMAIL_USER_NOT_FOUND = "ایمیل یافت نشد",
|
||||
EMAIL_USER_DELETED_SUCCESSFULLY = "ایمیل با موفقیت حذف شد",
|
||||
FAILED_TO_DELETE_EMAIL_USER = "خطا در حذف ایمیل",
|
||||
EMAIL_USER_QUOTA_UPDATED_SUCCESSFULLY = "ایمیل با موفقیت به روز رسانی شد",
|
||||
FAILED_TO_UPDATE_EMAIL_USER_QUOTA = "خطا در به روز رسانی حجم ایمیل",
|
||||
}
|
||||
|
||||
export const enum CommonMessage {
|
||||
VALIDITY_TYPE_REQUIRED = "نوع اعتبار سنجی مورد نیاز است",
|
||||
THIS_FILED_IS_REQUIRED = "این فیلد الزامی است",
|
||||
VALID_FOR_CHOOSE = "معتبر برای انتخاب",
|
||||
UPDATE_SUCCESS = "با موفقیت به روز رسانی شد",
|
||||
CREATED = "با موفقیت ایجاد شد",
|
||||
DELETED = "با موفقیت حذف شد",
|
||||
ID_REQUIRED = "شناسه مورد نیاز است",
|
||||
ID_SHOULD_BE_UUID = "شناسه باید یک یو یو آی دی باشد",
|
||||
PaymentTypeQueryNotEmpty = "نوع پرداخت نمیتواند خالی باشد",
|
||||
SEARCH_QUERY_STRING = "رشته جستجو باید یک رشته باشد",
|
||||
UPDATED = "با موفقیت آپدیت شد",
|
||||
IS_ACTIVE_SHOULD_BE_1_0 = "وضعیت باید یکی از مقادیر ۰ و ۱ باشد",
|
||||
DATE_MUST_BE_DATE = "تاریخ باید یک تاریخ معتبر به صورت رشته باشد",
|
||||
}
|
||||
|
||||
export const enum UploaderMessage {
|
||||
UPLOAD_FILE_INVALID = "فایل معتبر نیست",
|
||||
}
|
||||
|
||||
export const enum EmailMessage {
|
||||
EMAIL_VERIFICATION = "تایید ایمیل",
|
||||
EMAIL_SENDING_FAILED = "ارسال ایمیل با خطا مواجه شد",
|
||||
LOGIN = "ورود به سیستم",
|
||||
INVOICE = "فاکتور",
|
||||
WALLET = "اعلان کیف پول",
|
||||
TICKET = "تیکت پشتیبانی",
|
||||
ANNOUNCEMENT = "اعلان",
|
||||
PAYMENT_REMINDER = "یادآوری پرداخت",
|
||||
PAYMENT_CANCELLATION = "لغو پرداخت",
|
||||
}
|
||||
|
||||
export const enum SmsMessage {
|
||||
SEND_SMS_ERROR = "خطا در ارسال پیامک",
|
||||
}
|
||||
|
||||
export const enum BusinessMessage {
|
||||
NOT_FOUND = "کسب و کار یافت نشد",
|
||||
BUSINESS_ID_REQUIRED = "شناسه کسب و کار مورد نیاز است",
|
||||
SLUG_REQUIRED = "اسلاگ مورد نیاز است",
|
||||
SLUG_MUST_BE_STRING = "اسلاگ باید یک رشته باشد",
|
||||
DOMAIN_INVALID = "دامنه وارد شده معتبر نیست",
|
||||
DOMAIN_UPDATED = "دامنه با موفقیت بهروزرسانی شد",
|
||||
DOMAIN_VERIFICATION_INITIATED = "فرآیند تایید دامنه آغاز شد",
|
||||
DOMAIN_VERIFICATION_FAILED = "تایید دامنه با خطا مواجه شد",
|
||||
DOMAIN_VERIFICATION_SUCCESS = "دامنه با موفقیت تایید شد",
|
||||
DOMAIN_VERIFICATION_METHOD_INVALID = "روش تایید دامنه نامعتبر است",
|
||||
DOMAIN_ALREADY_VERIFIED = "این دامنه قبلا تایید شده است",
|
||||
DOMAIN_REQUIRED = "دامنه مورد نیاز است",
|
||||
DOMAIN_MUST_BE_STRING = "دامنه باید یک رشته باشد",
|
||||
STAFF_NOT_FOUND = "کاربر ادمین یافت نشد",
|
||||
ZARINPAL_MERCHANT_ID_REQUIRED = "شناسه مرچنت زرین پال مورد نیاز است",
|
||||
ZARINPAL_MERCHANT_ID_STRING = "شناسه مرچنت زرین پال باید یک رشته باشد",
|
||||
LOGO_URL_REQUIRED = "آدرس تصویر لوگو مورد نیاز است",
|
||||
LOGO_URL_STRING = "آدرس تصویر لوگو باید یک رشته باشد",
|
||||
LOGO_URL_INVALID = "آدرس تصویر لوگو معتبر نیست",
|
||||
BUSINESS_SETTINGS_UPDATED = "تنظیمات کسب و کار با موفقیت به روز رسانی شد",
|
||||
NAME_REQUIRED = "نام کسب و کار مورد نیاز است",
|
||||
NAME_STRING = "نام کسب و کار باید یک رشته باشد",
|
||||
DOMAIN_CONNECTED = "دامنه با موفقیت متصل شد",
|
||||
DOMAIN_NOT_CONNECTED = "دامنه به سرور ما متصل نشد",
|
||||
DOMAIN_NOT_FOUND = "دامنه یافت نشد",
|
||||
DOMAIN_VERIFICATION_TOKEN_REQUIRED = "توکن تایید دامنه مورد نیاز است",
|
||||
}
|
||||
|
||||
export const enum DomainMessage {
|
||||
// Domain Ownership & Verification
|
||||
NOT_BELONG_TO_BUSINESS = "دامنه به این کسب و کار تعلق ندارد",
|
||||
NOT_VERIFIED = "دامنه باید تایید شده باشد",
|
||||
DOMAIN_NOT_FOUND = "دامنه یافت نشد",
|
||||
DOMAIN_ALREADY_EXISTS = "دامنه [name] قبلا ثبت شده است",
|
||||
DOMAIN_DELETED_SUCCESSFULLY = "دامنه با موفقیت حذف شد",
|
||||
VERIFICATION_NOT_FOUND = "تایید دامنه یافت نشد",
|
||||
NO_DOMAIN_FOUND = "دامنه ای یافت نشد",
|
||||
DOMAIN_CHECKED = "دامنه با موفقیت بررسی شد",
|
||||
|
||||
// Domain Creation & Setup
|
||||
DOMAIN_CREATED_WITH_RECORDS = "دامنه [name] با موفقیت ایجاد شد و تمام رکوردهای مورد نیاز تولید گردید",
|
||||
ALL_REQUIRED_RECORDS_GENERATED = "تمام رکوردهای مورد نیاز (MX, SPF, DMARC, DKIM) به طور خودکار برای شما تولید شدهاند",
|
||||
ESTIMATED_PROPAGATION_TIME = "تغییرات DNS معمولاً ۱۵ دقیقه تا ۴۸ ساعت در سراسر جهان منتشر میشود",
|
||||
|
||||
// DNS Records Instructions
|
||||
ADD_MX_RECORD = "یک رکورد MX با نام [name] که به [value] با اولویت [priority] اشاره کند اضافه کنید",
|
||||
ADD_TXT_RECORD = "یک رکورد TXT با نام [name] و مقدار [value] اضافه کنید",
|
||||
ADD_DKIM_RECORD = "یک رکورد TXT با نام [name] و مقدار [value] اضافه کنید (کلید عمومی DKIM)",
|
||||
ADD_DMARC_RECORD = "یک رکورد TXT با نام [name] و مقدار [value] اضافه کنید (سیاست DMARC)",
|
||||
ADD_CNAME_RECORD = "یک رکورد CNAME با نام [name] که به [value] اشاره کند اضافه کنید",
|
||||
ADD_GENERIC_RECORD = "یک رکورد [type] با نام [name] و مقدار [value] اضافه کنید",
|
||||
|
||||
// Domain Setup Steps
|
||||
STEP_LOGIN_DNS_PANEL = "وارد پنل مدیریت DNS ثبت کننده دامنه خود شوید",
|
||||
STEP_ADD_ALL_RECORDS = "تمام رکوردهای DNS مورد نیاز لیست شده در بالا را به زون DNS دامنه خود اضافه کنید",
|
||||
STEP_INCLUDE_ALL_TYPES = "مطمئن شوید که رکوردهای MX، SPF، DMARC و DKIM را برای عملکرد کامل ایمیل شامل کردهاید",
|
||||
STEP_WAIT_PROPAGATION = "منتظر انتشار DNS بمانید (معمولاً ۱۵ دقیقه تا ۴۸ ساعت)",
|
||||
STEP_USE_CHECK_ENDPOINT = "از نقطه پایانی 'بررسی DNS' برای تایید تنظیم صحیح رکوردهای خود استفاده کنید",
|
||||
STEP_DOMAIN_READY = "پس از تایید، دامنه شما برای سرویسهای ایمیل آماده خواهد بود!",
|
||||
|
||||
// Status Messages
|
||||
RECORDS_PENDING_VERIFICATION = "[count] رکورد DNS هنوز در انتظار تایید هستند",
|
||||
ALL_REQUIRED_RECORDS_MUST_BE_CONFIGURED = "تمام [count] رکورد مورد نیاز باید برای کار درست ایمیل تنظیم شوند",
|
||||
DOMAIN_FULLY_VERIFIED = "دامنه شما به طور کامل تایید شده و برای ایمیل آماده است!",
|
||||
EMAIL_ACCOUNTS_READY = "اکنون میتوانید حسابهای ایمیل ایجاد کرده و شروع به ارسال/دریافت ایمیل کنید",
|
||||
|
||||
// Verification Status
|
||||
DOMAIN_VERIFICATION_INITIATED = "فرآیند تایید دامنه آغاز شد",
|
||||
DOMAIN_VERIFICATION_FAILED = "تایید دامنه با خطا مواجه شد",
|
||||
DOMAIN_VERIFICATION_SUCCESS = "دامنه با موفقیت تایید شد",
|
||||
DOMAIN_VERIFICATION_PENDING = "تایید دامنه در انتظار است",
|
||||
|
||||
// DNS Record Status
|
||||
DNS_RECORD_VERIFIED = "رکورد DNS تایید شد",
|
||||
DNS_RECORD_FAILED = "رکورد DNS تایید نشد",
|
||||
DNS_RECORD_NOT_FOUND_OR_MISMATCH = "رکورد DNS یافت نشد یا مقدار مطابقت ندارد",
|
||||
|
||||
// DKIM Messages
|
||||
DKIM_ENABLED_AUTOMATICALLY = "DKIM به طور خودکار برای دامنه [name] فعال شد",
|
||||
DKIM_FAILED_TO_CREATE = "ایجاد DKIM برای دامنه [name] ناموفق بود",
|
||||
DKIM_ENABLED_SUCCESSFULLY = "DKIM برای دامنه [name] با موفقیت فعال شد",
|
||||
|
||||
// Error Messages
|
||||
FAILED_TO_VERIFY_DOMAIN = "تایید دامنه [name] ناموفق بود",
|
||||
FAILED_TO_SETUP_MAIL_SERVER = "راهاندازی دامنه [name] در سرور ایمیل ناموفق بود",
|
||||
DOMAIN_CREATION_FAILED = "ایجاد دامنه ناموفق بود",
|
||||
DNS_VERIFICATION_ATTEMPTS_EXCEEDED = "تعداد تلاشهای تایید DNS از حد مجاز بیشتر شد",
|
||||
|
||||
// Service Messages
|
||||
DOMAIN_UPDATED_SUCCESSFULLY = "دامنه بروزرسانی شد",
|
||||
VERIFICATION_TOKEN_PREFIX = "توکن تایید: [token]",
|
||||
RECORD_NEEDS_FIXING = "رکورد [type] برای [name] نیاز به اصلاح دارد: [error]",
|
||||
RECORD_PENDING_VERIFICATION_DETAIL = "رکورد [type] برای [name] هنوز در انتظار تایید است. لطفا مطمئن شوید که انتشار DNS کامل شده است.",
|
||||
RECORD_DESCRIPTION_EMAIL_FUNCTIONALITY = "رکورد [type] برای عملکرد ایمیل",
|
||||
BUSINESS_ALREADY_HAS_DOMAIN = "این کسب و کار قبلا دامنه ای دارد",
|
||||
}
|
||||
|
||||
export const enum MailServerMessage {
|
||||
FAILED_TO_CREATE_ACCOUNT = "خطا در ایجاد ایمیل",
|
||||
FAILED_TO_CREATE_DKIM_KEY = "FAILED_TO_CREATE_DKIM_KEY",
|
||||
}
|
||||
|
||||
export const enum DnsRecordMessage {
|
||||
DNS_RECORD_NOT_FOUND = "رکورد DNS یافت نشد",
|
||||
DNS_RECORD_DELETED = "رکورد DNS با موفقیت حذف شد",
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface ErrorResponse {
|
||||
statusCode: number;
|
||||
success: boolean;
|
||||
error: {
|
||||
message: string | string[];
|
||||
timestamp?: string;
|
||||
path?: string;
|
||||
};
|
||||
}
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
export interface IPageFormat {
|
||||
page: number;
|
||||
limit: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
prevPage: string | boolean;
|
||||
nextPage: string | boolean;
|
||||
}
|
||||
|
||||
export interface PaginationQuery {
|
||||
page?: string;
|
||||
limit?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse {
|
||||
paginate?: boolean;
|
||||
count?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ValueProvider } from "@nestjs/common";
|
||||
import { Logger } from "@nestjs/common";
|
||||
|
||||
export const MIKRO_ORM_QUERY_LOGGER = "MikroOrmQueryLogger";
|
||||
|
||||
export const MikroOrmQueryLogger: ValueProvider = {
|
||||
provide: MIKRO_ORM_QUERY_LOGGER,
|
||||
useValue: new Logger("mikro-orm"),
|
||||
};
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
import { OnWorkerEvent, WorkerHost } from "@nestjs/bullmq";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { Job } from "bullmq";
|
||||
|
||||
export abstract class WorkerProcessor extends WorkerHost {
|
||||
protected readonly logger = new Logger(WorkerProcessor.name);
|
||||
|
||||
@OnWorkerEvent("completed")
|
||||
onCompleted(job: Job) {
|
||||
const { id, name, queueName, finishedOn, returnvalue } = job;
|
||||
const completionTime = finishedOn ? new Date(finishedOn).toISOString() : "";
|
||||
this.logger.log(`Job id: ${id}, name: ${name} completed in queue ${queueName} on ${completionTime}. Result: ${returnvalue}`);
|
||||
}
|
||||
|
||||
@OnWorkerEvent("progress")
|
||||
onProgress(job: Job) {
|
||||
const { id, name, progress } = job;
|
||||
this.logger.log(`Job id: ${id}, name: ${name} completes ${progress}%`);
|
||||
}
|
||||
|
||||
@OnWorkerEvent("failed")
|
||||
onFailed(job: Job) {
|
||||
const { id, name, queueName, failedReason } = job;
|
||||
this.logger.error(`Job id: ${id}, name: ${name} failed in queue ${queueName}. Failed reason: ${failedReason}`);
|
||||
}
|
||||
|
||||
@OnWorkerEvent("active")
|
||||
onActive(job: Job) {
|
||||
const { id, name, queueName, timestamp } = job;
|
||||
const startTime = timestamp ? new Date(timestamp).toISOString() : "";
|
||||
this.logger.log(`Job id: ${id}, name: ${name} starts in queue ${queueName} on ${startTime}.`);
|
||||
}
|
||||
}
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
import { SharedBullAsyncConfiguration } from "@nestjs/bullmq";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
export function bullMqConfig(): SharedBullAsyncConfiguration {
|
||||
return {
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
connection: {
|
||||
url: configService.getOrThrow<string>("REDIS_URI"),
|
||||
},
|
||||
prefix: "dmail",
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
attempts: 5,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
import KeyvRedis from "@keyv/redis";
|
||||
import { CacheModuleAsyncOptions } from "@nestjs/cache-manager";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
export function cacheConfig(): CacheModuleAsyncOptions {
|
||||
return {
|
||||
inject: [ConfigService],
|
||||
isGlobal: true,
|
||||
useFactory: async (configService: ConfigService) => {
|
||||
return {
|
||||
ttl: configService.getOrThrow<string>("CACHE_TTL"),
|
||||
stores: [new KeyvRedis(configService.getOrThrow<string>("REDIS_URI"), { namespace: "dmail:" })],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
import { HttpModuleAsyncOptions } from "@nestjs/axios";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
export function httpConfig(): HttpModuleAsyncOptions {
|
||||
return {
|
||||
inject: [ConfigService],
|
||||
global: true,
|
||||
useFactory: async (configService: ConfigService) => {
|
||||
return {
|
||||
timeout: configService.getOrThrow<number>("AXIOS_TIMEOUT"),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
global: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { JwtModuleAsyncOptions } from "@nestjs/jwt";
|
||||
|
||||
export function jwtConfig(): JwtModuleAsyncOptions {
|
||||
return {
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => {
|
||||
return {
|
||||
secret: configService.getOrThrow<string>("JWT_SECRET"),
|
||||
signOptions: {
|
||||
issuer: configService.getOrThrow<string>("JWT_ISSUER"),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
import { MikroOrmModuleAsyncOptions } from "@mikro-orm/nestjs";
|
||||
import { PostgreSqlDriver } from "@mikro-orm/postgresql";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
import { MIKRO_ORM_QUERY_LOGGER, MikroOrmQueryLogger } from "../common/providers/mikro-orm-logger";
|
||||
|
||||
export const databaseConfig: MikroOrmModuleAsyncOptions = {
|
||||
inject: [ConfigService, MIKRO_ORM_QUERY_LOGGER],
|
||||
providers: [MikroOrmQueryLogger],
|
||||
useFactory: (configService: ConfigService, logger: Logger) => {
|
||||
const DB_PASS = configService.getOrThrow<string>("DB_PASS");
|
||||
const DB_USER = configService.getOrThrow<string>("DB_USER");
|
||||
const DB_HOST = configService.getOrThrow<string>("DB_HOST");
|
||||
const DB_PORT = configService.getOrThrow<number>("DB_PORT");
|
||||
const encodedPassword = encodeURIComponent(DB_PASS);
|
||||
|
||||
return {
|
||||
driver: PostgreSqlDriver,
|
||||
autoLoadEntities: true,
|
||||
dbName: configService.getOrThrow<string>("DB_NAME"),
|
||||
debug: configService.get<string>("NODE_ENV") !== "production",
|
||||
clientUrl: `postgres://${DB_USER}:${encodedPassword}@${DB_HOST}:${DB_PORT}`,
|
||||
ensureDatabase: { forceCheck: true, create: true, schema: "update" },
|
||||
forceUtcTimezone: true,
|
||||
pool: {
|
||||
min: 2,
|
||||
max: 15,
|
||||
idleTimeoutMillis: 10000,
|
||||
acquireTimeoutMillis: 10000,
|
||||
reapIntervalMillis: 1000,
|
||||
createTimeoutMillis: 3000,
|
||||
destroyTimeoutMillis: 5000,
|
||||
},
|
||||
logger: (message) => logger.debug(message),
|
||||
schemaGenerator: {
|
||||
createForeignKey: true,
|
||||
disableForeignKeys: false,
|
||||
createIndex: true,
|
||||
},
|
||||
migrations: {
|
||||
path: "./database/migrations",
|
||||
pathTs: "./database/migrations",
|
||||
tableName: "migrations",
|
||||
transactional: true,
|
||||
allOrNothing: true,
|
||||
dropTables: false,
|
||||
safe: true,
|
||||
emit: "ts",
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ThrottlerAsyncOptions } from "@nestjs/throttler";
|
||||
|
||||
import { AuthMessage } from "../common/enums/message.enum";
|
||||
|
||||
export function rateLimitConfig(): ThrottlerAsyncOptions {
|
||||
return {
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
throttlers: [
|
||||
{
|
||||
ttl: configService.getOrThrow("THROTTLE_TTL"),
|
||||
limit: configService.getOrThrow("THROTTLE_LIMIT"),
|
||||
},
|
||||
],
|
||||
errorMessage: AuthMessage.TOO_MANY_REQUESTS,
|
||||
storage: new ThrottlerStorageRedisService(configService.getOrThrow("REDIS_URI")),
|
||||
}),
|
||||
};
|
||||
}
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
export function S3Configs() {
|
||||
return {
|
||||
inject: [ConfigService],
|
||||
useFactory(configService: ConfigService) {
|
||||
return {
|
||||
accessKeyId: configService.getOrThrow<string>("BUCKET_ACCESS_KEY"),
|
||||
secretAccessKey: configService.getOrThrow<string>("BUCKET_SECRET_KEY"),
|
||||
endpoint: configService.getOrThrow<string>("BUCKET_URL"),
|
||||
region: configService.getOrThrow<string>("BUCKET_REGION"),
|
||||
bucket: configService.getOrThrow<string>("BUCKET_NAME"),
|
||||
url: configService.getOrThrow<string>("BUCKET_UPLOAD_URL"),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface IS3Configs {
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
endpoint: string;
|
||||
region: string;
|
||||
bucket: string;
|
||||
url: string;
|
||||
}
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
export function smsConfigs() {
|
||||
return {
|
||||
inject: [ConfigService],
|
||||
useFactory(configService: ConfigService) {
|
||||
return {
|
||||
API_URL: configService.getOrThrow<string>("SMS_API_URL"),
|
||||
API_KEY: configService.getOrThrow<string>("SMS_API_KEY"),
|
||||
SMS_PATTERN_OTP: configService.getOrThrow<string>("SMS_PATTERN_OTP"),
|
||||
SMS_PATTERN_INVOICE: configService.getOrThrow<string>("SMS_PATTERN_INVOICE"),
|
||||
SMS_PATTERN_LOGIN: configService.getOrThrow<string>("SMS_PATTERN_LOGIN"),
|
||||
SMS_PATTERN_INVOICE_CREATED: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_CREATED"),
|
||||
SMS_PATTERN_ANNOUNCEMENT: configService.getOrThrow<string>("SMS_PATTERN_ANNOUNCEMENT"),
|
||||
SMS_PATTERN_TICKET_CREATED: configService.getOrThrow<string>("SMS_PATTERN_TICKET_CREATED"),
|
||||
SMS_PATTERN_TICKET_ASSIGNED_ADMIN: configService.getOrThrow<string>("SMS_PATTERN_TICKET_ASSIGNED_ADMIN"),
|
||||
SMS_PATTERN_INVOICE_APPROVED: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_APPROVED"),
|
||||
SMS_PATTERN_INVOICE_PAID: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_PAID"),
|
||||
SMS_PATTERN_RECURRING_INVOICE_DRAFT: configService.getOrThrow<string>("SMS_PATTERN_RECURRING_INVOICE_DRAFT"),
|
||||
SMS_PATTERN_SUBSCRIPTION_CANCELLED: configService.getOrThrow<string>("SMS_PATTERN_SUBSCRIPTION_CANCELLED"),
|
||||
SMS_PATTERN_INVOICE_REMINDER: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_REMINDER"),
|
||||
SMS_PATTERN_INVOICE_OVERDUE: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_OVERDUE"),
|
||||
SMS_PATTERN_PAYMENT_REMINDER: configService.getOrThrow<string>("SMS_PATTERN_PAYMENT_REMINDER"),
|
||||
SMS_PATTERN_PAYMENT_CANCELLATION: configService.getOrThrow<string>("SMS_PATTERN_PAYMENT_CANCELLATION"),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface ISmsConfigs {
|
||||
API_URL: string;
|
||||
API_KEY: string;
|
||||
SMS_PATTERN_OTP: string;
|
||||
SMS_PATTERN_INVOICE: string;
|
||||
SMS_PATTERN_LOGIN: string;
|
||||
SMS_PATTERN_INVOICE_CREATED: string;
|
||||
SMS_PATTERN_ANNOUNCEMENT: string;
|
||||
SMS_PATTERN_TICKET_CREATED: string;
|
||||
SMS_PATTERN_TICKET_ANSWERED: string;
|
||||
SMS_PATTERN_TICKET_ASSIGNED_ADMIN: string;
|
||||
SMS_PATTERN_INVOICE_APPROVED: string;
|
||||
SMS_PATTERN_INVOICE_PAID: string;
|
||||
SMS_PATTERN_RECURRING_INVOICE_DRAFT: string;
|
||||
SMS_PATTERN_SUBSCRIPTION_CANCELLED: string;
|
||||
SMS_PATTERN_INVOICE_REMINDER: string;
|
||||
SMS_PATTERN_INVOICE_OVERDUE: string;
|
||||
SMS_PATTERN_PAYMENT_REMINDER: string;
|
||||
SMS_PATTERN_PAYMENT_CANCELLATION: string;
|
||||
}
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
import { NestFastifyApplication } from "@nestjs/platform-fastify";
|
||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||
|
||||
export function getSwaggerDocument(app: NestFastifyApplication) {
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle("The dmail api document")
|
||||
.setDescription("The dmail API description")
|
||||
.addBearerAuth(
|
||||
{
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "JWT",
|
||||
name: "authorization",
|
||||
in: "header",
|
||||
},
|
||||
"authorization",
|
||||
)
|
||||
|
||||
.setVersion("1.0.0")
|
||||
.build();
|
||||
|
||||
const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig);
|
||||
SwaggerModule.setup("api-docs", app, swaggerDocument);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
export const wildduckConfig = () => ({
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
baseUrl: configService.getOrThrow<string>("WILDDUCK_BASE_URL"),
|
||||
accessToken: configService.getOrThrow<string>("WILDDUCK_ACCESS_TOKEN"),
|
||||
timeout: configService.getOrThrow<number>("WILDDUCK_TIMEOUT"),
|
||||
retries: configService.getOrThrow<number>("WILDDUCK_RETRIES"),
|
||||
}),
|
||||
});
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
export function zarinpalConfig() {
|
||||
return {
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
gatewayApiUrl: configService.getOrThrow<string>("ZARINPAL_API_URL"),
|
||||
callBackUrl: configService.getOrThrow<string>("CALLBACK_URL"),
|
||||
ipgType: configService.getOrThrow<string>("IPG_TYPE"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface IZarinpalConfig {
|
||||
gatewayApiUrl: string;
|
||||
callBackUrl: string;
|
||||
ipgType: string;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { HttpException, HttpStatus } from "@nestjs/common";
|
||||
|
||||
export class MailServerException extends HttpException {
|
||||
constructor(message: string, status: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR) {
|
||||
super(message, status);
|
||||
}
|
||||
}
|
||||
|
||||
export class UserNotFoundException extends MailServerException {
|
||||
constructor(userId: string) {
|
||||
super(`User with ID '${userId}' not found`, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
export class MailboxNotFoundException extends MailServerException {
|
||||
constructor(mailboxId: string) {
|
||||
super(`Mailbox with ID '${mailboxId}' not found`, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
export class MessageNotFoundException extends MailServerException {
|
||||
constructor(messageId: number) {
|
||||
super(`Message with ID '${messageId}' not found`, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
export class UserAlreadyExistsException extends MailServerException {
|
||||
constructor(username: string) {
|
||||
super(`User '${username}' already exists`, HttpStatus.CONFLICT);
|
||||
}
|
||||
}
|
||||
|
||||
export class AddressAlreadyExistsException extends MailServerException {
|
||||
constructor(address: string) {
|
||||
super(`Address '${address}' already exists`, HttpStatus.CONFLICT);
|
||||
}
|
||||
}
|
||||
|
||||
export class MailServerConnectionException extends MailServerException {
|
||||
constructor(details?: string) {
|
||||
super(`Mail server connection failed${details ? `: ${details}` : ""}`, HttpStatus.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidMailServerResponseException extends MailServerException {
|
||||
constructor() {
|
||||
super("Invalid response from mail server", HttpStatus.BAD_GATEWAY);
|
||||
}
|
||||
}
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from "@nestjs/common";
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
|
||||
import { ErrorResponse } from "../../common/interfaces/IError-response";
|
||||
|
||||
@Catch(HttpException)
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: HttpException, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const reply = ctx.getResponse<FastifyReply>();
|
||||
const request = ctx.getRequest<FastifyRequest>();
|
||||
const status = exception.getStatus() || HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
const exceptionResponse = exception.getResponse();
|
||||
const message = this.extractMessage(exceptionResponse);
|
||||
|
||||
const response: ErrorResponse = {
|
||||
statusCode: status,
|
||||
success: false,
|
||||
error: {
|
||||
message,
|
||||
path: request.url,
|
||||
},
|
||||
};
|
||||
|
||||
reply.status(status).send(response);
|
||||
}
|
||||
|
||||
private extractMessage(response: string | Record<string, any>): string[] {
|
||||
if (typeof response === "string") {
|
||||
return [response];
|
||||
}
|
||||
|
||||
if (typeof response === "object" && response !== null) {
|
||||
if ("message" in response) {
|
||||
const message = response.message;
|
||||
return Array.isArray(message) ? message : [message];
|
||||
}
|
||||
|
||||
if ("error" in response) {
|
||||
return [response.error];
|
||||
}
|
||||
}
|
||||
|
||||
return ["An unexpected error occurred"];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
|
||||
import { MailServerException } from "../exceptions/mail-server.exceptions";
|
||||
|
||||
@Catch(MailServerException)
|
||||
export class MailServerExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(MailServerExceptionFilter.name);
|
||||
|
||||
catch(exception: MailServerException, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<FastifyReply>();
|
||||
const request = ctx.getRequest<FastifyRequest>();
|
||||
|
||||
const status = exception.getStatus();
|
||||
const message = exception.message;
|
||||
|
||||
// Log the error for monitoring
|
||||
this.logger.error({
|
||||
message: "Mail server exception occurred",
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
status,
|
||||
error: message,
|
||||
userId: (request.params as any)?.userId || (request.params as any)?.id,
|
||||
userAgent: request.headers["user-agent"],
|
||||
ip: request.ip,
|
||||
});
|
||||
|
||||
// Return a consistent error response
|
||||
response.status(status).send({
|
||||
success: false,
|
||||
error: {
|
||||
code: exception.constructor.name,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { BadRequestException, CallHandler, ExecutionContext, ForbiddenException, Injectable, Logger, NestInterceptor } from "@nestjs/common";
|
||||
import { FastifyRequest } from "fastify";
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { BusinessMessage } from "../../common/enums/message.enum";
|
||||
import { Business } from "../../modules/businesses/entities/business.entity";
|
||||
import { BusinessesService } from "../../modules/businesses/services/businesses.service";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
business?: Business;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BusinessInterceptor implements NestInterceptor {
|
||||
private readonly headerName = "x-business-id";
|
||||
private readonly logger = new Logger(BusinessInterceptor.name);
|
||||
|
||||
constructor(private readonly businessesService: BusinessesService) {}
|
||||
|
||||
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
|
||||
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
||||
|
||||
const businessId = request.headers[this.headerName.toLocaleLowerCase()] as string;
|
||||
|
||||
if (businessId) {
|
||||
try {
|
||||
const business = await this.businessesService.getBusinessByDanakSubscriptionId(businessId);
|
||||
|
||||
request.business = business;
|
||||
} catch (error) {
|
||||
this.logger.error(error);
|
||||
throw new BadRequestException(BusinessMessage.NOT_FOUND);
|
||||
}
|
||||
} else {
|
||||
throw new ForbiddenException(BusinessMessage.BUSINESS_ID_REQUIRED);
|
||||
}
|
||||
|
||||
return next.handle();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from "@nestjs/common";
|
||||
import { Observable } from "rxjs";
|
||||
import { catchError, tap } from "rxjs/operators";
|
||||
|
||||
@Injectable()
|
||||
export class LoggingInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger(LoggingInterceptor.name);
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const response = context.switchToHttp().getResponse();
|
||||
const { method, url, body, query, params, headers } = request;
|
||||
|
||||
const startTime = Date.now();
|
||||
const requestId = this.generateRequestId();
|
||||
|
||||
// Log request
|
||||
this.logger.log({
|
||||
message: "Incoming request",
|
||||
requestId,
|
||||
method,
|
||||
url,
|
||||
query,
|
||||
params,
|
||||
userAgent: headers["user-agent"],
|
||||
ip: request.ip,
|
||||
// Don't log sensitive data
|
||||
body: this.sanitizeRequestBody(body),
|
||||
});
|
||||
|
||||
return next.handle().pipe(
|
||||
tap((data) => {
|
||||
const duration = Date.now() - startTime;
|
||||
this.logger.log({
|
||||
message: "Request completed",
|
||||
requestId,
|
||||
method,
|
||||
url,
|
||||
statusCode: response.statusCode,
|
||||
duration: `${duration}ms`,
|
||||
responseSize: JSON.stringify(data).length,
|
||||
});
|
||||
}),
|
||||
catchError((error) => {
|
||||
const duration = Date.now() - startTime;
|
||||
this.logger.error({
|
||||
message: "Request failed",
|
||||
requestId,
|
||||
method,
|
||||
url,
|
||||
statusCode: response.statusCode,
|
||||
duration: `${duration}ms`,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private generateRequestId(): string {
|
||||
return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
private sanitizeRequestBody(body: any): any {
|
||||
if (!body || typeof body !== "object") return body;
|
||||
|
||||
const sanitized = { ...body };
|
||||
const sensitiveFields = ["password", "token", "authorization", "secret"];
|
||||
|
||||
for (const field of sensitiveFields) {
|
||||
if (sanitized[field]) {
|
||||
sanitized[field] = "***REDACTED***";
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from "@nestjs/common";
|
||||
import { Observable } from "rxjs";
|
||||
import { tap } from "rxjs/operators";
|
||||
|
||||
interface RequestMetrics {
|
||||
method: string;
|
||||
url: string;
|
||||
statusCode: number;
|
||||
responseTime: number;
|
||||
timestamp: string;
|
||||
userAgent?: string;
|
||||
ip?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MetricsInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger(MetricsInterceptor.name);
|
||||
private static metrics: Map<string, any> = new Map();
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const response = context.switchToHttp().getResponse();
|
||||
const startTime = Date.now();
|
||||
|
||||
return next.handle().pipe(
|
||||
tap(() => {
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
const metrics: RequestMetrics = {
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
statusCode: response.statusCode,
|
||||
responseTime,
|
||||
timestamp: new Date().toISOString(),
|
||||
userAgent: request.headers["user-agent"],
|
||||
ip: request.ip,
|
||||
};
|
||||
|
||||
this.collectMetrics(metrics);
|
||||
|
||||
// Log slow requests
|
||||
if (responseTime > 1000) {
|
||||
this.logger.warn(`Slow request detected: ${request.method} ${request.url} - ${responseTime}ms`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private collectMetrics(metrics: RequestMetrics): void {
|
||||
const key = `${metrics.method}:${metrics.url}`;
|
||||
|
||||
if (!MetricsInterceptor.metrics.has(key)) {
|
||||
MetricsInterceptor.metrics.set(key, {
|
||||
count: 0,
|
||||
totalResponseTime: 0,
|
||||
avgResponseTime: 0,
|
||||
maxResponseTime: 0,
|
||||
minResponseTime: Infinity,
|
||||
errorCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const existing = MetricsInterceptor.metrics.get(key);
|
||||
existing.count++;
|
||||
existing.totalResponseTime += metrics.responseTime;
|
||||
existing.avgResponseTime = existing.totalResponseTime / existing.count;
|
||||
existing.maxResponseTime = Math.max(existing.maxResponseTime, metrics.responseTime);
|
||||
existing.minResponseTime = Math.min(existing.minResponseTime, metrics.responseTime);
|
||||
|
||||
if (metrics.statusCode >= 400) {
|
||||
existing.errorCount++;
|
||||
}
|
||||
|
||||
MetricsInterceptor.metrics.set(key, existing);
|
||||
}
|
||||
|
||||
static getMetrics(): Map<string, any> {
|
||||
return MetricsInterceptor.metrics;
|
||||
}
|
||||
|
||||
static resetMetrics(): void {
|
||||
MetricsInterceptor.metrics.clear();
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from "@nestjs/common";
|
||||
import { FastifyRequest } from "fastify";
|
||||
import { Observable, map } from "rxjs";
|
||||
|
||||
import { IPageFormat, PaginatedResponse, PaginationQuery } from "../../common/interfaces/IPagination";
|
||||
|
||||
@Injectable()
|
||||
export class PaginationInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger(PaginationInterceptor.name);
|
||||
private readonly DEFAULT_PAGE = 1;
|
||||
private readonly DEFAULT_LIMIT = 10;
|
||||
private readonly MAX_LIMIT = 100;
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
||||
const { page, limit } = this.extractPaginationParams(request.query as PaginationQuery);
|
||||
const { className, handlerName } = this.getContextInfo(context);
|
||||
|
||||
return next.handle().pipe(
|
||||
map((data: PaginatedResponse) => {
|
||||
if (!this.isPaginatedResponse(data)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
this.logger.log(`Paginating response from ${className}.${handlerName}`);
|
||||
const { count, paginate, ...response } = data;
|
||||
const pager = this.formatPage(page, limit, count, request);
|
||||
|
||||
return {
|
||||
pager,
|
||||
...response,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
//******* Extract Pagination Params *******//
|
||||
private extractPaginationParams(query: PaginationQuery): { page: number; limit: number } {
|
||||
const page = this.validateNumber(query.page, this.DEFAULT_PAGE);
|
||||
const limit = this.validateNumber(query.limit, this.DEFAULT_LIMIT);
|
||||
|
||||
return {
|
||||
page: Math.max(1, page),
|
||||
limit: Math.min(Math.max(1, limit), this.MAX_LIMIT),
|
||||
};
|
||||
}
|
||||
|
||||
//******* Validate Number *******//
|
||||
private validateNumber(value: string | undefined, defaultValue: number): number {
|
||||
const parsed = parseInt(value || "", 10);
|
||||
return isNaN(parsed) ? defaultValue : parsed;
|
||||
}
|
||||
|
||||
//******* Get Context Info *******//
|
||||
private getContextInfo(context: ExecutionContext): { className: string; handlerName: string } {
|
||||
return {
|
||||
className: context.getClass().name,
|
||||
handlerName: context.getHandler().name,
|
||||
};
|
||||
}
|
||||
|
||||
//******* Check if Response is Paginated *******//
|
||||
private isPaginatedResponse(data: PaginatedResponse): data is PaginatedResponse {
|
||||
return data && (data.paginate || typeof data.count === "number");
|
||||
}
|
||||
|
||||
//******* Format Page *******//
|
||||
private formatPage(page: number, limit: number, totalItems: number | undefined, request: FastifyRequest): IPageFormat {
|
||||
const count = totalItems || 0;
|
||||
const totalPages = Math.ceil(count / limit);
|
||||
const prevPage = page > 1 ? page - 1 : false;
|
||||
const nextPage = page < totalPages ? page + 1 : false;
|
||||
|
||||
return {
|
||||
page,
|
||||
limit,
|
||||
totalItems: count,
|
||||
totalPages,
|
||||
prevPage: this.generatePageLink(prevPage, limit, request),
|
||||
nextPage: this.generatePageLink(nextPage, limit, request),
|
||||
};
|
||||
}
|
||||
|
||||
//******* Generate Page Link *******//
|
||||
private generatePageLink(page: number | boolean, limit: number, request: FastifyRequest): string | boolean {
|
||||
if (!page) return false;
|
||||
|
||||
const { protocol, hostname, url } = request;
|
||||
const baseUrl = url.split("?")[0];
|
||||
const queryParams = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
});
|
||||
|
||||
return `${protocol}://${hostname}${baseUrl}?${queryParams.toString()}`;
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
|
||||
import { FastifyReply } from "fastify";
|
||||
import { Observable } from "rxjs";
|
||||
import { map } from "rxjs/operators";
|
||||
|
||||
@Injectable()
|
||||
export class ResponseInterceptor implements NestInterceptor {
|
||||
//
|
||||
intercept(context: ExecutionContext, next: CallHandler<Record<string, unknown>>): Observable<unknown> {
|
||||
const ctx = context.switchToHttp().getResponse<FastifyReply>();
|
||||
const statusCode = ctx.statusCode;
|
||||
|
||||
return next.handle().pipe(
|
||||
map((data) => {
|
||||
if (data && data.data !== undefined) {
|
||||
return {
|
||||
statusCode,
|
||||
success: true,
|
||||
data: data.data,
|
||||
};
|
||||
}
|
||||
return {
|
||||
statusCode,
|
||||
success: true,
|
||||
data,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
import { Injectable, Logger, NestMiddleware } from "@nestjs/common";
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
|
||||
@Injectable()
|
||||
export class HTTPLogger implements NestMiddleware {
|
||||
private readonly logger = new Logger(HTTPLogger.name);
|
||||
|
||||
use(req: FastifyRequest, rep: FastifyReply["raw"], next: () => void): void {
|
||||
const startAt = process.hrtime();
|
||||
const { method, originalUrl } = req;
|
||||
const ip = req.ip || req.socket.remoteAddress || "-";
|
||||
const userAgent = req.headers["user-agent"] || "-";
|
||||
const referer = req.headers.referer || "-";
|
||||
|
||||
rep.on("finish", () => {
|
||||
const { statusCode } = rep;
|
||||
const contentLength = rep.getHeader("content-length") || 0;
|
||||
const dif = process.hrtime(startAt);
|
||||
const responseTime = dif[0] * 1e3 + dif[1] * 1e-6;
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// Apache-like format: IP - - [timestamp] "METHOD URL" STATUS SIZE "REFERER" "USER-AGENT" RESPONSE_TIME
|
||||
this.logger.log(
|
||||
`${ip} - - [${timestamp}] "${method} ${originalUrl}" ${statusCode} ${contentLength} "${referer}" "${userAgent}" ${responseTime.toFixed(2)}ms`,
|
||||
);
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { MikroORM } from "@mikro-orm/core";
|
||||
import { Logger, ValidationPipe } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { FastifyAdapter, NestFastifyApplication } from "@nestjs/platform-fastify";
|
||||
|
||||
import { AppModule } from "./app.module";
|
||||
import { getSwaggerDocument } from "./configs/swagger.config";
|
||||
import { HttpExceptionFilter } from "./core/filters/http-exception.filters";
|
||||
import { MailServerExceptionFilter } from "./core/filters/mail-server-exception.filter";
|
||||
import { PaginationInterceptor } from "./core/interceptors/pagination.interceptor";
|
||||
import { ResponseInterceptor } from "./core/interceptors/response.interceptor";
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger("APP");
|
||||
|
||||
const fastifyAdapter = new FastifyAdapter({
|
||||
bodyLimit: 10 * 1024 * 1024,
|
||||
trustProxy: true,
|
||||
});
|
||||
|
||||
fastifyAdapter.getInstance().addContentTypeParser("multipart/form-data", (_request, _payload, done) => {
|
||||
done(null);
|
||||
});
|
||||
|
||||
fastifyAdapter.getInstance().addContentTypeParser("text/plain", (_request, _payload, done) => {
|
||||
done(null);
|
||||
});
|
||||
|
||||
const app = await NestFactory.create<NestFastifyApplication>(AppModule, fastifyAdapter);
|
||||
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }));
|
||||
|
||||
app.useGlobalInterceptors(new ResponseInterceptor(), new PaginationInterceptor());
|
||||
|
||||
app.useGlobalFilters(new MailServerExceptionFilter(), new HttpExceptionFilter());
|
||||
|
||||
app.enableCors({
|
||||
origin: true,
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
||||
credentials: true,
|
||||
optionsSuccessStatus: 204,
|
||||
});
|
||||
const configService = app.get<ConfigService>(ConfigService);
|
||||
|
||||
getSwaggerDocument(app);
|
||||
|
||||
const orm = app.get(MikroORM);
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
await app.get(MikroORM).getSchemaGenerator().ensureDatabase();
|
||||
await orm.getSchemaGenerator().updateSchema();
|
||||
}
|
||||
|
||||
const PORT = configService.getOrThrow<number>("PORT");
|
||||
await app.listen(PORT, "0.0.0.0", () => {
|
||||
logger.log(`Application is running on: http://localhost:${PORT}`);
|
||||
logger.log(`Swagger documentation: http://localhost:${PORT}/api-docs`);
|
||||
});
|
||||
}
|
||||
bootstrap().catch((error) => {
|
||||
const logger = new Logger("Bootstrap");
|
||||
logger.error("Failed to start application", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEmail, IsNotEmpty, IsString } from "class-validator";
|
||||
|
||||
import { AuthMessage } from "../../../common/enums/message.enum";
|
||||
|
||||
export class LoginDto {
|
||||
@IsNotEmpty({ message: AuthMessage.EMAIL_NOT_EMPTY })
|
||||
@IsEmail({}, { message: AuthMessage.INVALID_EMAIL_FORMAT })
|
||||
@ApiProperty({ description: "User's email address", example: "john.doe@example.com" })
|
||||
email: string;
|
||||
|
||||
@IsNotEmpty({ message: AuthMessage.PASSWORD_NOT_EMPTY })
|
||||
@IsString({ message: AuthMessage.INVALID_PASS_FORMAT })
|
||||
@ApiProperty({ description: "User's password", example: "SecurePassword123!" })
|
||||
password: string;
|
||||
}
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsNotEmpty, IsString } from "class-validator";
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@ApiProperty({ description: "Refresh token" })
|
||||
@IsString({ message: "Refresh token must be a string" })
|
||||
@IsNotEmpty({ message: "Refresh token is required" })
|
||||
refreshToken: string;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Body, Controller, HttpCode, HttpStatus, Post } from "@nestjs/common";
|
||||
import { ApiBadRequestResponse, ApiOperation, ApiResponse, ApiTags, ApiUnauthorizedResponse } from "@nestjs/swagger";
|
||||
|
||||
import { LoginDto } from "./DTO/login.dto";
|
||||
import { RefreshTokenDto } from "./DTO/refresh-token.dto";
|
||||
import { AuthService } from "./services/auth.service";
|
||||
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
|
||||
import { RefreshTokenRateLimit } from "../../common/decorators/rate-limit.decorator";
|
||||
import { UserDec } from "../../common/decorators/user.decorator";
|
||||
|
||||
@ApiTags("Authentication")
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post("login")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Login user" })
|
||||
@ApiResponse({ status: HttpStatus.OK, description: "User successfully logged in" })
|
||||
@ApiUnauthorizedResponse({ description: "Invalid credentials" })
|
||||
@ApiBadRequestResponse({ description: "Invalid input data" })
|
||||
login(@Body() loginDto: LoginDto) {
|
||||
return this.authService.login(loginDto);
|
||||
}
|
||||
|
||||
@RefreshTokenRateLimit()
|
||||
@ApiOperation({ summary: "refresh the user access token / refresh token" })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post("refresh")
|
||||
refreshToken(@Body() refreshTokenDto: RefreshTokenDto) {
|
||||
return this.authService.refreshToken(refreshTokenDto.refreshToken);
|
||||
}
|
||||
|
||||
@AuthGuards()
|
||||
@ApiOperation({ summary: "logout the user" })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post("logout")
|
||||
logout(@UserDec("id") userId: string) {
|
||||
return this.authService.logout(userId);
|
||||
}
|
||||
}
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
import { MikroOrmModule } from "@mikro-orm/nestjs";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { PassportModule } from "@nestjs/passport";
|
||||
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { jwtConfig } from "../../configs/jwt.config";
|
||||
import { UtilsModule } from "../utils/utils.module";
|
||||
import { AuthService } from "./services/auth.service";
|
||||
import { TokensService } from "./services/tokens.service";
|
||||
import { UsersModule } from "../users/users.module";
|
||||
import { ConsoleJwtStrategy } from "./strategies/console-jwt.strategy";
|
||||
import { LocalJwtStrategy } from "./strategies/local-jwt.strategy";
|
||||
import { BusinessesModule } from "../businesses/businesses.module";
|
||||
import { User } from "../users/entities/user.entity";
|
||||
|
||||
@Module({
|
||||
imports: [MikroOrmModule.forFeature([User]), UtilsModule, UsersModule, PassportModule, JwtModule.registerAsync(jwtConfig()), BusinessesModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, TokensService, LocalJwtStrategy, ConsoleJwtStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { FastifyRequest } from "fastify";
|
||||
|
||||
import { ADMIN_ROUTE } from "../../../common/decorators/admin.decorator";
|
||||
import { AuthMessage } from "../../../common/enums/message.enum";
|
||||
|
||||
@Injectable()
|
||||
export class AdminRouteGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredAdmin = this.reflector.getAllAndOverride<boolean>(ADMIN_ROUTE, [context.getHandler(), context.getClass()]);
|
||||
if (!requiredAdmin) return true;
|
||||
|
||||
const req = context.switchToHttp().getRequest<FastifyRequest>();
|
||||
const user = req.user;
|
||||
|
||||
if (!user) throw new ForbiddenException(AuthMessage.UNAUTHORIZED_ACCESS);
|
||||
|
||||
if (!user.role) throw new ForbiddenException(AuthMessage.UNAUTHORIZED_ACCESS);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
import { ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { CONSOLE_JWT_STRATEGY_NAME, LOCAL_JWT_STRATEGY_NAME } from "../../../common/constants";
|
||||
import { SKIP_AUTH_KEY } from "../../../common/decorators/skip-auth.decorator";
|
||||
import { AuthMessage } from "../../../common/enums/message.enum";
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard([CONSOLE_JWT_STRATEGY_NAME, LOCAL_JWT_STRATEGY_NAME]) {
|
||||
constructor(private reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
|
||||
const skipAuth = this.reflector.getAllAndOverride<boolean>(SKIP_AUTH_KEY, [context.getHandler(), context.getClass()]);
|
||||
|
||||
if (skipAuth) return true;
|
||||
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
handleRequest(err: unknown, user: any, _info: unknown) {
|
||||
if (err || !user) throw new UnauthorizedException(AuthMessage.TOKEN_EXPIRED_OR_INVALID);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
// import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
|
||||
// import { Reflector } from "@nestjs/core";
|
||||
// import { FastifyRequest } from "fastify";
|
||||
|
||||
// import { ROLES_KEY } from "../../../common/decorators/roles.decorator";
|
||||
// import { AuthMessage } from "../../../common/enums/message.enum";
|
||||
// import { RoleEnum } from "../../users/enums/role.enum";
|
||||
|
||||
// @Injectable()
|
||||
// export class RoleGuard implements CanActivate {
|
||||
// constructor(private reflector: Reflector) {}
|
||||
|
||||
// canActivate(context: ExecutionContext): boolean {
|
||||
// const requiredRole = this.reflector.getAllAndOverride<RoleEnum[]>(ROLES_KEY, [context.getHandler(), context.getClass()]);
|
||||
// if (!requiredRole) return true;
|
||||
|
||||
// const req = context.switchToHttp().getRequest<FastifyRequest>();
|
||||
// const user = req.user;
|
||||
|
||||
// if (!user) throw new ForbiddenException(AuthMessage.UNAUTHORIZED_ACCESS);
|
||||
|
||||
// const hasRequiredRole = requiredRole.some((role) => user.roles.includes(role));
|
||||
// if (!hasRequiredRole) throw new ForbiddenException(AuthMessage.UNAUTHORIZED_ACCESS);
|
||||
|
||||
// return true;
|
||||
// }
|
||||
// }
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { RoleEnum } from "../../users/enums/role.enum";
|
||||
|
||||
export interface ILocalTokenPayload {
|
||||
id: string;
|
||||
role: RoleEnum;
|
||||
}
|
||||
|
||||
export interface IConsoleTokenPayload {
|
||||
id: string;
|
||||
permissions?: string[];
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export interface ITokenPayload extends ILocalTokenPayload, IConsoleTokenPayload {}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { EntityRepository } from "@mikro-orm/core";
|
||||
import { InjectRepository } from "@mikro-orm/nestjs";
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
|
||||
import { TokensService } from "./tokens.service";
|
||||
import { AuthMessage } from "../../../common/enums/message.enum";
|
||||
import { User } from "../../users/entities/user.entity";
|
||||
import { PasswordService } from "../../utils/services/password.service";
|
||||
import { LoginDto } from "../DTO/login.dto";
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: EntityRepository<User>,
|
||||
private readonly tokensService: TokensService,
|
||||
private readonly passwordService: PasswordService,
|
||||
) {}
|
||||
|
||||
//==============================================
|
||||
//==============================================
|
||||
async login(loginDto: LoginDto) {
|
||||
const { email, password } = loginDto;
|
||||
|
||||
const user = await this.checkUserLoginCredentialWithEmail(email, password);
|
||||
|
||||
const tokens = await this.tokensService.generateTokens(user);
|
||||
|
||||
return {
|
||||
message: AuthMessage.PASSWORD_LOGIN_SUCCESS,
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
//==============================================
|
||||
//==============================================
|
||||
async refreshToken(oldRefreshToken: string) {
|
||||
return this.tokensService.refreshToken(oldRefreshToken);
|
||||
}
|
||||
//==============================================
|
||||
//==============================================
|
||||
async logout(userId: string) {
|
||||
await this.tokensService.invalidateRefreshToken(userId);
|
||||
return {
|
||||
message: AuthMessage.LOGOUT_SUCCESS,
|
||||
};
|
||||
}
|
||||
//==============================================
|
||||
|
||||
private async checkUserLoginCredentialWithEmail(email: string, password: string) {
|
||||
const user = await this.userRepository.findOne({ emailAddress: email, isActive: true });
|
||||
if (!user) throw new BadRequestException(AuthMessage.INVALID_PASSWORD);
|
||||
|
||||
const passCompare = await this.passwordService.comparePasswords(password, user.password);
|
||||
if (!passCompare) throw new BadRequestException(AuthMessage.INVALID_PASSWORD);
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import { EntityManager } from "@mikro-orm/postgresql";
|
||||
import { BadRequestException, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { AuthMessage, UserMessage } from "../../../common/enums/message.enum";
|
||||
import { RefreshToken } from "../../users/entities/refresh-token.entity";
|
||||
import { User } from "../../users/entities/user.entity";
|
||||
import { ITokenPayload } from "../interfaces/IToken-payload";
|
||||
|
||||
@Injectable()
|
||||
export class TokensService {
|
||||
private readonly logger = new Logger(TokensService.name);
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly em: EntityManager,
|
||||
) {}
|
||||
|
||||
async generateTokens(user: User, em?: EntityManager) {
|
||||
return this.generateAccessAndRefreshToken(
|
||||
{
|
||||
id: user.id,
|
||||
role: user.role.name,
|
||||
},
|
||||
em,
|
||||
);
|
||||
}
|
||||
|
||||
private async generateAccessAndRefreshToken(payload: ITokenPayload, em?: EntityManager) {
|
||||
const accessExpire = this.configService.getOrThrow<number>("ACCESS_TOKEN_EXPIRE");
|
||||
const refreshExpire = this.configService.getOrThrow<number>("REFRESH_TOKEN_EXPIRE");
|
||||
|
||||
const accessToken = await this.jwtService.signAsync(payload, { expiresIn: `${accessExpire}m` });
|
||||
const refreshToken = await this.generateRefreshToken();
|
||||
|
||||
await this.storeRefreshToken(payload.id, refreshToken, em);
|
||||
|
||||
return {
|
||||
accessToken: { token: accessToken, expire: dayjs().add(accessExpire, "minute").valueOf() },
|
||||
refreshToken: { token: refreshToken, expire: dayjs().add(refreshExpire, "day").valueOf() },
|
||||
};
|
||||
}
|
||||
|
||||
async storeRefreshToken(userId: string, refreshToken: string, em?: EntityManager) {
|
||||
const entityManager = em || this.em.fork();
|
||||
let transactionStarted = false;
|
||||
|
||||
try {
|
||||
if (!entityManager.isInTransaction()) {
|
||||
await entityManager.begin();
|
||||
transactionStarted = true;
|
||||
}
|
||||
|
||||
const refreshExpire = this.configService.getOrThrow<number>("REFRESH_TOKEN_EXPIRE");
|
||||
const expiresAt = dayjs().add(refreshExpire, "day").toDate();
|
||||
|
||||
const user = await entityManager.findOne(User, { id: userId });
|
||||
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND);
|
||||
|
||||
const token = entityManager.create(RefreshToken, {
|
||||
token: refreshToken,
|
||||
user,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
await entityManager.persistAndFlush(token);
|
||||
|
||||
if (transactionStarted) await entityManager.commit();
|
||||
} catch (error) {
|
||||
this.logger.error(error);
|
||||
if (transactionStarted) await entityManager.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async refreshToken(oldRefreshToken: string) {
|
||||
const entityManager = this.em.fork();
|
||||
let transactionStarted = false;
|
||||
|
||||
try {
|
||||
await entityManager.begin();
|
||||
transactionStarted = true;
|
||||
|
||||
const token = await entityManager.findOne(
|
||||
RefreshToken,
|
||||
{ token: oldRefreshToken },
|
||||
{
|
||||
populate: ["user", "user.role"],
|
||||
},
|
||||
);
|
||||
|
||||
if (!token) throw new UnauthorizedException(AuthMessage.INVALID_REFRESH_TOKEN);
|
||||
|
||||
if (dayjs(token.expiresAt).isBefore(dayjs())) {
|
||||
await entityManager.removeAndFlush(token);
|
||||
throw new UnauthorizedException(AuthMessage.REFRESH_TOKEN_EXPIRED);
|
||||
}
|
||||
|
||||
await entityManager.removeAndFlush(token);
|
||||
const tokens = await this.generateTokens(token.user, entityManager);
|
||||
|
||||
await entityManager.commit();
|
||||
return tokens;
|
||||
} catch (error) {
|
||||
this.logger.error(error);
|
||||
if (transactionStarted) {
|
||||
await entityManager.rollback();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async invalidateRefreshToken(userId: string) {
|
||||
const entityManager = this.em.fork();
|
||||
let transactionStarted = false;
|
||||
|
||||
try {
|
||||
await entityManager.begin();
|
||||
transactionStarted = true;
|
||||
|
||||
await entityManager.nativeDelete(RefreshToken, { user: { id: userId } });
|
||||
this.logger.log(`Successfully deleted all refresh tokens for user ${userId}`);
|
||||
|
||||
await entityManager.commit();
|
||||
} catch (error) {
|
||||
this.logger.error(error);
|
||||
if (transactionStarted) {
|
||||
await entityManager.rollback();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async generateRefreshToken() {
|
||||
return randomBytes(32).toString("hex");
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { ExtractJwt, Strategy } from "passport-jwt";
|
||||
|
||||
import { CONSOLE_JWT_STRATEGY_NAME } from "../../../common/constants";
|
||||
import { IConsoleTokenPayload } from "../interfaces/IToken-payload";
|
||||
|
||||
@Injectable()
|
||||
export class ConsoleJwtStrategy extends PassportStrategy(Strategy, CONSOLE_JWT_STRATEGY_NAME) {
|
||||
constructor() {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: readFileSync(`${process.cwd()}/keys/public.pem`, "utf8"),
|
||||
algorithms: ["RS256"],
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: IConsoleTokenPayload) {
|
||||
return { id: payload.id, permissions: payload.permissions, isAdmin: payload.isAdmin };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { ExtractJwt, Strategy } from "passport-jwt";
|
||||
|
||||
import { LOCAL_JWT_STRATEGY_NAME } from "../../../common/constants";
|
||||
import { ITokenPayload } from "../interfaces/IToken-payload";
|
||||
|
||||
@Injectable()
|
||||
export class LocalJwtStrategy extends PassportStrategy(Strategy, LOCAL_JWT_STRATEGY_NAME) {
|
||||
constructor(configService: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.getOrThrow<string>("JWT_SECRET"),
|
||||
issuer: configService.getOrThrow<string>("JWT_ISSUER"),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: ITokenPayload) {
|
||||
return { id: payload.id, role: payload.role };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsNotEmpty, IsString } from "class-validator";
|
||||
|
||||
import { BusinessMessage } from "../../../common/enums/message.enum";
|
||||
|
||||
export class BusinessSlugParamDto {
|
||||
@IsNotEmpty({ message: BusinessMessage.SLUG_REQUIRED })
|
||||
@IsString({ message: BusinessMessage.SLUG_MUST_BE_STRING })
|
||||
@ApiProperty({ description: "The slug of the business", example: "example-business" })
|
||||
slug: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsNotEmpty, IsOptional, IsString, IsUrl } from "class-validator";
|
||||
|
||||
import { BusinessMessage } from "../../../common/enums/message.enum";
|
||||
|
||||
export class UpdateBusinessSettingsDto {
|
||||
@IsOptional()
|
||||
@IsNotEmpty({ message: BusinessMessage.ZARINPAL_MERCHANT_ID_REQUIRED })
|
||||
@IsString({ message: BusinessMessage.ZARINPAL_MERCHANT_ID_STRING })
|
||||
@ApiProperty({ description: "Zarinpal merchant id", example: "1234567890" })
|
||||
zarinpalMerchantId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNotEmpty({ message: BusinessMessage.LOGO_URL_REQUIRED })
|
||||
@IsString({ message: BusinessMessage.LOGO_URL_STRING })
|
||||
@IsUrl({ protocols: ["https"] }, { message: BusinessMessage.LOGO_URL_INVALID })
|
||||
@ApiProperty({ description: "Logo url", example: "https://example.com/logo.png" })
|
||||
logoUrl?: string;
|
||||
|
||||
// @IsOptional()
|
||||
// @IsNotEmpty({ message: BusinessMessage.NAME_REQUIRED })
|
||||
// @IsString({ message: BusinessMessage.NAME_STRING })
|
||||
// @ApiProperty({ description: "Name", example: "Example" })
|
||||
// name?: string;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Body, Controller, Get, Param, Patch, UseInterceptors } from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation } from "@nestjs/swagger";
|
||||
|
||||
import { BusinessSlugParamDto } from "./DTO/business-slug-param.dto";
|
||||
import { UpdateBusinessSettingsDto } from "./DTO/update-business-settings.dto";
|
||||
import { BusinessesService } from "./services/businesses.service";
|
||||
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
|
||||
import { BusinessDec } from "../../common/decorators/business.decorator";
|
||||
import { BusinessInterceptor } from "../../core/interceptors/business.interceptor";
|
||||
|
||||
@Controller("business")
|
||||
@ApiHeader({ name: "x-business-id", description: "Business ID" })
|
||||
export class BusinessesController {
|
||||
constructor(private readonly businessesService: BusinessesService) {}
|
||||
|
||||
@Get("slug/:slug")
|
||||
@ApiOperation({ summary: "Get business by slug" })
|
||||
getBusinessBySlug(@Param() params: BusinessSlugParamDto) {
|
||||
return this.businessesService.getBusinessBySlug(params.slug);
|
||||
}
|
||||
|
||||
@Patch("settings")
|
||||
@UseInterceptors(BusinessInterceptor)
|
||||
@ApiOperation({ summary: "Update business settings" })
|
||||
@AuthGuards()
|
||||
updateBusinessSettings(@BusinessDec("id") businessId: string, @Body() settingsDto: UpdateBusinessSettingsDto) {
|
||||
return this.businessesService.updateBusinessSettings(businessId, settingsDto);
|
||||
}
|
||||
|
||||
@Get("settings")
|
||||
@UseInterceptors(BusinessInterceptor)
|
||||
@ApiOperation({ summary: "Get business settings" })
|
||||
@AuthGuards()
|
||||
getBusinessSettings(@BusinessDec("id") businessId: string) {
|
||||
return this.businessesService.getBusinessSettings(businessId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MikroOrmModule } from "@mikro-orm/nestjs";
|
||||
import { BullModule } from "@nestjs/bullmq";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { BusinessesController } from "./businesses.controller";
|
||||
import { SUBSCRIPTIONS } from "./constant";
|
||||
import { Business } from "./entities/business.entity";
|
||||
import { BusinessProvisioningProcessor } from "./queue/business-provisioning.processor";
|
||||
import { BusinessesService } from "./services/businesses.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MikroOrmModule.forFeature([Business]),
|
||||
BullModule.registerQueue({
|
||||
name: SUBSCRIPTIONS.PROVISIONING_QUEUE_NAME,
|
||||
prefix: SUBSCRIPTIONS.PROVISIONING_QUEUE_PREFIX,
|
||||
}),
|
||||
],
|
||||
providers: [BusinessesService, BusinessProvisioningProcessor],
|
||||
exports: [BusinessesService],
|
||||
controllers: [BusinessesController],
|
||||
})
|
||||
export class BusinessesModule {}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const SUBSCRIPTIONS = Object.freeze({
|
||||
PROVISIONING_QUEUE_PREFIX: "provisioning",
|
||||
PROVISIONING_QUEUE_NAME: "provisioning",
|
||||
PROVISIONING_JOB_NAME: "business.created",
|
||||
|
||||
//
|
||||
SERVICE_SLUG: "Dmail",
|
||||
SERVICE_ID: "e51afdc3-ea0b-49cf-8f49-2a6f131b024e",
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Entity, EntityRepositoryType, ManyToOne, Property } from "@mikro-orm/core";
|
||||
|
||||
import { Business } from "./business.entity";
|
||||
import { BaseEntity } from "../../../common/entities/base.entity";
|
||||
import { BusinessStaffRepository } from "../repositories/business-staff.repository";
|
||||
|
||||
@Entity({ repository: () => BusinessStaffRepository })
|
||||
export class BusinessStaff extends BaseEntity {
|
||||
@Property({ type: "uuid", nullable: false })
|
||||
danakUserId: string;
|
||||
|
||||
@Property({ type: "varchar", length: 255, nullable: false })
|
||||
fullName: string;
|
||||
|
||||
@Property({ type: "varchar", length: 255, nullable: true })
|
||||
email?: string;
|
||||
|
||||
@Property({ type: "varchar", length: 255, nullable: false })
|
||||
phone: string;
|
||||
|
||||
@ManyToOne(() => Business, { deleteRule: "cascade", nullable: false })
|
||||
business: Business;
|
||||
|
||||
[EntityRepositoryType]?: BusinessStaffRepository;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Cascade, Collection, Entity, EntityRepositoryType, OneToMany, OneToOne, Property, Unique } from "@mikro-orm/core";
|
||||
|
||||
import { BusinessStaff } from "./business-staff.entity";
|
||||
import { BaseEntity } from "../../../common/entities/base.entity";
|
||||
import { Domain } from "../../domains/entities/domain.entity";
|
||||
import { BusinessRepository } from "../repositories/business.repository";
|
||||
|
||||
@Entity({ repository: () => BusinessRepository })
|
||||
export class Business extends BaseEntity {
|
||||
@Unique()
|
||||
@Property({ type: "uuid", nullable: false })
|
||||
danakSubscriptionId!: string;
|
||||
|
||||
@Property({ type: "varchar", length: 255, nullable: false })
|
||||
name!: string;
|
||||
|
||||
@Property({ type: "varchar", length: 255, nullable: false })
|
||||
slug!: string;
|
||||
|
||||
@Property({ type: "varchar", length: 255, nullable: true })
|
||||
logoUrl?: string;
|
||||
|
||||
//=========================
|
||||
|
||||
@OneToOne(() => Domain, (domain) => domain.business, { nullable: true, cascade: [Cascade.ALL] })
|
||||
domain?: Domain;
|
||||
|
||||
//=========================
|
||||
|
||||
// @OneToMany(() => User, (user) => user.business)
|
||||
// users = new Collection<User>(this);
|
||||
|
||||
@OneToMany(() => BusinessStaff, (businessStaff) => businessStaff.business)
|
||||
businessStaffs = new Collection<BusinessStaff>(this);
|
||||
|
||||
[EntityRepositoryType]?: BusinessRepository;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface IBusinessProvisioningJob {
|
||||
subscriptionId: string;
|
||||
serviceId: string;
|
||||
serviceName: string;
|
||||
businessName: string;
|
||||
userId: string;
|
||||
slug: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// src/business/queue/business-provisioning.processor.ts
|
||||
import { EntityManager } from "@mikro-orm/postgresql";
|
||||
import { Processor } from "@nestjs/bullmq";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { Job } from "bullmq";
|
||||
|
||||
import { WorkerProcessor } from "../../../common/queues/worker.processor";
|
||||
import { SUBSCRIPTIONS } from "../constant";
|
||||
import { BusinessStaff } from "../entities/business-staff.entity";
|
||||
import { Business } from "../entities/business.entity";
|
||||
import { IBusinessProvisioningJob } from "../interfaces/IBusiness";
|
||||
|
||||
@Processor(SUBSCRIPTIONS.PROVISIONING_QUEUE_NAME)
|
||||
export class BusinessProvisioningProcessor extends WorkerProcessor {
|
||||
protected readonly logger = new Logger(BusinessProvisioningProcessor.name);
|
||||
|
||||
constructor(private readonly em: EntityManager) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job) {
|
||||
switch (job.name) {
|
||||
case SUBSCRIPTIONS.PROVISIONING_JOB_NAME:
|
||||
await this.handleBusinessCreated(job);
|
||||
break;
|
||||
default:
|
||||
this.logger.warn(`Unknown job name: ${job.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleBusinessCreated(job: Job<IBusinessProvisioningJob>) {
|
||||
const em = this.em.fork();
|
||||
const { subscriptionId, serviceId, serviceName, businessName, slug, userId, fullName, phone, email } = job.data;
|
||||
|
||||
// Only act if the event is for this service
|
||||
if (serviceId !== SUBSCRIPTIONS.SERVICE_ID) {
|
||||
this.logger.debug(`Skipping job for service: ${serviceName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already exists
|
||||
let business = await em.findOne(Business, { danakSubscriptionId: subscriptionId });
|
||||
if (!business) {
|
||||
business = em.create(Business, {
|
||||
danakSubscriptionId: subscriptionId,
|
||||
name: businessName,
|
||||
slug: slug,
|
||||
});
|
||||
await em.persistAndFlush(business);
|
||||
this.logger.log(`Created business for subscription ${subscriptionId}`);
|
||||
} else {
|
||||
this.logger.debug(`Business already exists for subscription ${subscriptionId}`);
|
||||
}
|
||||
|
||||
const user = await em.findOne(BusinessStaff, { danakUserId: userId });
|
||||
if (!user) {
|
||||
const user = em.create(BusinessStaff, {
|
||||
danakUserId: userId,
|
||||
fullName,
|
||||
email,
|
||||
phone,
|
||||
business,
|
||||
});
|
||||
await em.persistAndFlush(user);
|
||||
this.logger.log(`Created business staff for subscription ${subscriptionId}`);
|
||||
} else {
|
||||
this.logger.debug(`Business staff already exists for subscription ${subscriptionId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { EntityRepository } from "@mikro-orm/core";
|
||||
|
||||
import { BusinessStaff } from "../entities/business-staff.entity";
|
||||
|
||||
export class BusinessStaffRepository extends EntityRepository<BusinessStaff> {}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { EntityRepository } from "@mikro-orm/postgresql";
|
||||
|
||||
import { Business } from "../entities/business.entity";
|
||||
|
||||
export class BusinessRepository extends EntityRepository<Business> {}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { EntityManager } from "@mikro-orm/postgresql";
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
|
||||
import { BusinessMessage } from "../../../common/enums/message.enum";
|
||||
import { UpdateBusinessSettingsDto } from "../DTO/update-business-settings.dto";
|
||||
import { BusinessStaff } from "../entities/business-staff.entity";
|
||||
import { BusinessRepository } from "../repositories/business.repository";
|
||||
|
||||
@Injectable()
|
||||
export class BusinessesService {
|
||||
constructor(
|
||||
private readonly businessRepository: BusinessRepository,
|
||||
private readonly em: EntityManager,
|
||||
) {}
|
||||
|
||||
async getBusinessByDanakSubscriptionId(danakSubscriptionId: string) {
|
||||
const business = await this.businessRepository.findOne({ danakSubscriptionId, deletedAt: null });
|
||||
if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND);
|
||||
|
||||
return business;
|
||||
}
|
||||
//************************************************** */
|
||||
|
||||
async getBusinessBySlug(slug: string) {
|
||||
const business = await this.businessRepository.findOne({ slug, deletedAt: null });
|
||||
if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND);
|
||||
|
||||
return { business };
|
||||
}
|
||||
//************************************************** */
|
||||
async updateBusinessSettings(businessId: string, settingsDto: UpdateBusinessSettingsDto) {
|
||||
const business = await this.businessRepository.findOne({ id: businessId, deletedAt: null });
|
||||
if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND);
|
||||
|
||||
this.businessRepository.assign(business, settingsDto);
|
||||
await this.em.flush();
|
||||
|
||||
return {
|
||||
message: BusinessMessage.BUSINESS_SETTINGS_UPDATED,
|
||||
business,
|
||||
};
|
||||
}
|
||||
//************************************************** */
|
||||
|
||||
async getBusinessSettings(businessId: string) {
|
||||
const business = await this.businessRepository.findOne({ id: businessId, deletedAt: null });
|
||||
console.log("business", business);
|
||||
if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND);
|
||||
|
||||
return { business };
|
||||
}
|
||||
//************************************************** */
|
||||
|
||||
async getBusinessById(id: string) {
|
||||
const business = await this.businessRepository.findOne({ id, deletedAt: null });
|
||||
if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND);
|
||||
|
||||
return business;
|
||||
}
|
||||
|
||||
//************************************************** */
|
||||
|
||||
async findAdminWithLeastTickets() {
|
||||
// First query to get the ID of the business staff with the least tickets
|
||||
const result = await this.em.getConnection().execute(
|
||||
`
|
||||
SELECT bs.id
|
||||
FROM business_staff bs
|
||||
WHERE bs.deleted_at IS NULL
|
||||
ORDER BY (
|
||||
SELECT COUNT(*) FROM ticket t WHERE t.assigned_to_id = bs.id
|
||||
) ASC
|
||||
LIMIT 1
|
||||
`,
|
||||
);
|
||||
|
||||
if (result.length === 0) return null;
|
||||
|
||||
return this.em.findOne(BusinessStaff, { id: result[0].id });
|
||||
}
|
||||
|
||||
/*******************************/
|
||||
async findOneByIdWithEntityManager(staffId: string, em: EntityManager) {
|
||||
const staff = await em.findOne(BusinessStaff, { id: staffId });
|
||||
if (!staff) throw new BadRequestException(BusinessMessage.STAFF_NOT_FOUND);
|
||||
return staff;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from "class-validator";
|
||||
|
||||
import { DNSRecordType } from "../enums/domain-status.enum";
|
||||
|
||||
export class CreateDnsRecordDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
@ApiProperty({ description: "The name of the DNS record", example: "mail" })
|
||||
name!: string;
|
||||
|
||||
@IsEnum(DNSRecordType)
|
||||
@ApiProperty({ description: "The type of the DNS record", example: "MX" })
|
||||
type!: DNSRecordType;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ApiProperty({ description: "The value of the DNS record", example: "10 mail.example.com" })
|
||||
value!: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Min(60)
|
||||
@Max(86400)
|
||||
@ApiProperty({ description: "The TTL of the DNS record", example: 300 })
|
||||
ttl?: number = 300;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Min(0)
|
||||
@Max(65535)
|
||||
@ApiProperty({ description: "The priority of the DNS record", example: 10 })
|
||||
priority?: number;
|
||||
|
||||
@IsUUID()
|
||||
@ApiProperty({ description: "The ID of the domain", example: "123e4567-e89b-12d3-a456-426614174000" })
|
||||
domainId!: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@ApiProperty({ description: "Whether the DNS record is required", example: true })
|
||||
isRequired?: boolean = true;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(500)
|
||||
@ApiProperty({ description: "The description of the DNS record", example: "This is a test DNS record" })
|
||||
description?: string;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID, MaxLength } from "class-validator";
|
||||
|
||||
import { VerificationType } from "../enums/domain-status.enum";
|
||||
|
||||
export class CreateDomainVerificationDto {
|
||||
@IsEnum(VerificationType)
|
||||
@ApiProperty({ description: "The type of the domain verification", example: "DNS_TXT" })
|
||||
type!: VerificationType;
|
||||
|
||||
@IsUUID()
|
||||
@ApiProperty({ description: "The ID of the domain", example: "123e4567-e89b-12d3-a456-426614174000" })
|
||||
domainId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(255)
|
||||
@ApiProperty({ description: "The name of the record", example: "mail" })
|
||||
recordName?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiProperty({ description: "The value of the record", example: "10 mail.example.com" })
|
||||
recordValue?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(1000)
|
||||
@ApiProperty({ description: "The instructions for the domain verification", example: "This is a test domain verification" })
|
||||
instructions?: string;
|
||||
}
|
||||
|
||||
export class VerifyDomainDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ApiProperty({ description: "The token for the domain verification", example: "123e4567-e89b-12d3-a456-426614174000" })
|
||||
token!: string;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from "class-validator";
|
||||
|
||||
export class CreateDomainDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
@Matches(/^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9](?:\.[a-zA-Z]{2,})+$/, { message: "Domain name must be a valid domain format" })
|
||||
@ApiProperty({ description: "The name of the domain", example: "example.com" })
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(1000)
|
||||
@ApiProperty({ description: "The notes of the domain", example: "This is a test domain" })
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from "@nestjs/swagger";
|
||||
|
||||
import { CreateDomainDto } from "./create-domain.dto";
|
||||
|
||||
export class UpdateDomainDto extends PartialType(CreateDomainDto) {}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseInterceptors } from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiResponse } from "@nestjs/swagger";
|
||||
|
||||
import { CreateDomainDto } from "./DTO/create-domain.dto";
|
||||
import { UpdateDomainDto } from "./DTO/update-domain.dto";
|
||||
import { DomainsService } from "./services/domains.service";
|
||||
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
|
||||
import { BusinessDec } from "../../common/decorators/business.decorator";
|
||||
import { PaginationDto } from "../../common/DTO/pagination.dto";
|
||||
import { ParamDto } from "../../common/DTO/param.dto";
|
||||
import { BusinessInterceptor } from "../../core/interceptors/business.interceptor";
|
||||
|
||||
@Controller("domains")
|
||||
@ApiHeader({ name: "x-business-id", description: "Business ID" })
|
||||
@UseInterceptors(BusinessInterceptor)
|
||||
@AuthGuards()
|
||||
export class DomainsController {
|
||||
constructor(private readonly domainsService: DomainsService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: "Create a new domain and get setup instructions" })
|
||||
@ApiResponse({ status: 201, description: "Domain created with setup instructions" })
|
||||
createDomain(@Body() createDomainDto: CreateDomainDto, @BusinessDec("id") businessId: string) {
|
||||
return this.domainsService.createDomain(createDomainDto, businessId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "Get domain for business" })
|
||||
@ApiResponse({ status: 200, description: "Domains retrieved successfully" })
|
||||
getBusinessDomain(@BusinessDec("id") businessId: string, @Query() _query: PaginationDto) {
|
||||
return this.domainsService.getBusinessDomain(businessId);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@ApiOperation({ summary: "Update domain" })
|
||||
@ApiResponse({ status: 200, description: "Domain updated successfully" })
|
||||
updateDomain(@Param() paramDto: ParamDto, @Body() updateDomainDto: UpdateDomainDto) {
|
||||
return this.domainsService.updateDomain(paramDto.id, updateDomainDto);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@ApiOperation({ summary: "Delete domain" })
|
||||
@ApiResponse({ status: 200, description: "Domain deleted successfully" })
|
||||
deleteDomain(@Param() paramDto: ParamDto) {
|
||||
return this.domainsService.deleteDomain(paramDto.id);
|
||||
}
|
||||
|
||||
@Get(":id/setup")
|
||||
@ApiOperation({ summary: "Get domain setup instructions" })
|
||||
@ApiResponse({ status: 200, description: "Domain setup instructions retrieved" })
|
||||
getDomainSetupInstructions(@Param() paramDto: ParamDto) {
|
||||
return this.domainsService.getDomainSetupInstructions(paramDto.id);
|
||||
}
|
||||
|
||||
@Get(":id/dns-records")
|
||||
@ApiOperation({ summary: "Get DNS records for domain" })
|
||||
@ApiResponse({ status: 200, description: "DNS records retrieved successfully" })
|
||||
getDomainDnsRecords(@Param() paramDto: ParamDto) {
|
||||
return this.domainsService.getDomainDnsRecords(paramDto.id);
|
||||
}
|
||||
|
||||
@Get(":id/verification-status")
|
||||
@ApiOperation({ summary: "Check domain verification status" })
|
||||
@ApiResponse({ status: 200, description: "Domain verification status checked" })
|
||||
checkDomainVerificationStatus(@Param() paramDto: ParamDto) {
|
||||
return this.domainsService.checkDomainVerificationStatus(paramDto.id);
|
||||
}
|
||||
|
||||
@Post(":id/check-dns")
|
||||
@ApiOperation({ summary: "Check DNS records and update verification status" })
|
||||
@ApiResponse({ status: 200, description: "DNS records checked and updated" })
|
||||
async checkDnsRecords(@Param() paramDto: ParamDto) {
|
||||
// Trigger DNS verification
|
||||
await this.domainsService.verifyDomain(paramDto.id);
|
||||
// Return updated status
|
||||
return this.domainsService.checkDomainVerificationStatus(paramDto.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { MikroOrmModule } from "@mikro-orm/nestjs";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { DomainsController } from "./domains.controller";
|
||||
import { MailServerModule } from "../mail-server/mail-server.module";
|
||||
import { DnsRecord } from "./entities/dns-record.entity";
|
||||
import { Domain } from "./entities/domain.entity";
|
||||
import { DnsService } from "./services/dns.service";
|
||||
import { DomainsService } from "./services/domains.service";
|
||||
import { BusinessesModule } from "../businesses/businesses.module";
|
||||
|
||||
@Module({
|
||||
imports: [MikroOrmModule.forFeature([Domain, DnsRecord]), MailServerModule, BusinessesModule],
|
||||
controllers: [DomainsController],
|
||||
providers: [DomainsService, DnsService],
|
||||
exports: [DomainsService, DnsService],
|
||||
})
|
||||
export class DomainsModule {}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Entity, EntityRepositoryType, Enum, ManyToOne, Opt, Property } from "@mikro-orm/core";
|
||||
|
||||
import { Domain } from "./domain.entity";
|
||||
import { BaseEntity } from "../../../common/entities/base.entity";
|
||||
import { DNSRecordType, VerificationStatus } from "../enums/domain-status.enum";
|
||||
import { DnsRecordRepository } from "../repositories/dns-record.repository";
|
||||
|
||||
@Entity({ repository: () => DnsRecordRepository })
|
||||
export class DnsRecord extends BaseEntity {
|
||||
@Property({ type: "varchar", length: 255, nullable: true })
|
||||
wildduckId?: string;
|
||||
|
||||
@Property({ type: "varchar", length: 255, nullable: false })
|
||||
name!: string;
|
||||
|
||||
@Enum({ items: () => DNSRecordType, nativeEnumName: "dns_record_type_enum" })
|
||||
type!: DNSRecordType;
|
||||
|
||||
@Property({ type: "text", nullable: false })
|
||||
value!: string;
|
||||
|
||||
@Property({ type: "integer", default: 300 })
|
||||
ttl: number & Opt = 300;
|
||||
|
||||
@Property({ type: "integer", nullable: true })
|
||||
priority?: number;
|
||||
|
||||
@Enum({ items: () => VerificationStatus, nativeEnumName: "verification_status_enum", default: VerificationStatus.PENDING })
|
||||
status: VerificationStatus & Opt;
|
||||
|
||||
@Property({ type: "boolean", default: true })
|
||||
isRequired: boolean & Opt;
|
||||
|
||||
@Property({ type: "boolean", default: true })
|
||||
isActive: boolean & Opt;
|
||||
|
||||
@Property({ type: "datetime", nullable: true })
|
||||
lastVerifiedAt?: Date;
|
||||
|
||||
@Property({ type: "datetime", nullable: true })
|
||||
lastCheckedAt?: Date;
|
||||
|
||||
@Property({ type: "text", nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Property({ type: "text", nullable: true })
|
||||
errorMessage?: string;
|
||||
|
||||
@Property({ type: "integer", default: 0 })
|
||||
verificationAttempts: number & Opt;
|
||||
|
||||
@Property({ type: "datetime", nullable: true })
|
||||
nextVerificationAt?: Date;
|
||||
|
||||
//==================================
|
||||
@ManyToOne(() => Domain, { nullable: false })
|
||||
domain!: Domain;
|
||||
|
||||
[EntityRepositoryType]?: DnsRecordRepository;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Collection, Entity, EntityRepositoryType, Enum, OneToMany, OneToOne, Opt, Property, Unique } from "@mikro-orm/core";
|
||||
|
||||
import { DnsRecord } from "./dns-record.entity";
|
||||
import { BaseEntity } from "../../../common/entities/base.entity";
|
||||
import { Business } from "../../businesses/entities/business.entity";
|
||||
import { DomainStatus } from "../enums/domain-status.enum";
|
||||
import { DomainRepository } from "../repositories/domain.repository";
|
||||
|
||||
@Entity({ repository: () => DomainRepository })
|
||||
export class Domain extends BaseEntity {
|
||||
@Unique()
|
||||
@Property({ type: "varchar", length: 255, nullable: false })
|
||||
name!: string;
|
||||
|
||||
@Enum({ items: () => DomainStatus, nativeEnumName: "domain_status_enum", default: DomainStatus.PENDING })
|
||||
status: DomainStatus & Opt;
|
||||
|
||||
@Property({ type: "boolean", default: false })
|
||||
isVerified: boolean & Opt;
|
||||
|
||||
@Property({ type: "boolean", default: true })
|
||||
isActive: boolean & Opt;
|
||||
|
||||
@Property({ type: "datetime", nullable: true })
|
||||
verifiedAt?: Date;
|
||||
|
||||
@Property({ type: "datetime", nullable: true })
|
||||
lastCheckedAt?: Date;
|
||||
|
||||
@Property({ type: "text", nullable: true })
|
||||
notes?: string;
|
||||
|
||||
// DKIM Configuration
|
||||
@Property({ type: "boolean", default: false })
|
||||
dkimEnabled: boolean & Opt;
|
||||
|
||||
@Property({ type: "varchar", length: 255, nullable: true })
|
||||
dkimSelector?: string;
|
||||
|
||||
@Property({ type: "text", nullable: true })
|
||||
dkimPrivateKey?: string;
|
||||
|
||||
@Property({ type: "text", nullable: true })
|
||||
dkimPublicKey?: string;
|
||||
|
||||
// SPF Configuration
|
||||
@Property({ type: "boolean", default: false })
|
||||
spfEnabled: boolean & Opt;
|
||||
|
||||
@Property({ type: "text", nullable: true })
|
||||
spfRecord?: string;
|
||||
|
||||
// DMARC Configuration
|
||||
@Property({ type: "boolean", default: false })
|
||||
dmarcEnabled: boolean & Opt;
|
||||
|
||||
@Property({ type: "text", nullable: true })
|
||||
dmarcPolicy?: string;
|
||||
|
||||
//==================================
|
||||
@OneToOne(() => Business, (business) => business.domain, { owner: true, orphanRemoval: true })
|
||||
business!: Business;
|
||||
|
||||
//==================================
|
||||
|
||||
@OneToMany(() => DnsRecord, (dnsRecord) => dnsRecord.domain)
|
||||
dnsRecords = new Collection<DnsRecord>(this);
|
||||
|
||||
[EntityRepositoryType]?: DomainRepository;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export enum DomainStatus {
|
||||
PENDING = "PENDING",
|
||||
VERIFIED = "VERIFIED",
|
||||
FAILED = "FAILED",
|
||||
SUSPENDED = "SUSPENDED",
|
||||
}
|
||||
|
||||
export enum DNSRecordType {
|
||||
A = "A",
|
||||
AAAA = "AAAA",
|
||||
CNAME = "CNAME",
|
||||
MX = "MX",
|
||||
TXT = "TXT",
|
||||
SPF = "SPF",
|
||||
DKIM = "DKIM",
|
||||
DMARC = "DMARC",
|
||||
SRV = "SRV",
|
||||
}
|
||||
|
||||
export enum VerificationStatus {
|
||||
PENDING = "PENDING",
|
||||
VERIFIED = "VERIFIED",
|
||||
FAILED = "FAILED",
|
||||
EXPIRED = "EXPIRED",
|
||||
}
|
||||
export enum VerificationType {
|
||||
DNS_TXT = "DNS_TXT",
|
||||
DNS_CNAME = "DNS_CNAME",
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { EntityRepository } from "@mikro-orm/postgresql";
|
||||
|
||||
import { DnsRecord } from "../entities/dns-record.entity";
|
||||
import { DNSRecordType, VerificationStatus } from "../enums/domain-status.enum";
|
||||
|
||||
export class DnsRecordRepository extends EntityRepository<DnsRecord> {
|
||||
/**
|
||||
* Find DNS records by domain ID
|
||||
*/
|
||||
async findByDomainId(domainId: string): Promise<DnsRecord[]> {
|
||||
return this.find({ domain: domainId, deletedAt: null });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find DNS records by type
|
||||
*/
|
||||
async findByType(domainId: string, type: DNSRecordType): Promise<DnsRecord[]> {
|
||||
return this.find({ domain: domainId, type });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find required DNS records that are not verified
|
||||
*/
|
||||
async findUnverifiedRequired(domainId: string): Promise<DnsRecord[]> {
|
||||
return this.find({
|
||||
domain: domainId,
|
||||
isRequired: true,
|
||||
status: { $ne: VerificationStatus.VERIFIED },
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find DNS records that need verification
|
||||
*/
|
||||
async findNeedingVerification(): Promise<DnsRecord[]> {
|
||||
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
|
||||
|
||||
return this.find(
|
||||
{
|
||||
$and: [
|
||||
{
|
||||
$or: [{ lastCheckedAt: null }, { lastCheckedAt: { $lt: oneHourAgo } }],
|
||||
},
|
||||
{
|
||||
$or: [{ status: VerificationStatus.PENDING }, { status: VerificationStatus.FAILED }],
|
||||
},
|
||||
{ isActive: true },
|
||||
{ verificationAttempts: { $lt: 5 } },
|
||||
],
|
||||
},
|
||||
{ populate: ["domain"] },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get DNS record statistics for a domain
|
||||
*/
|
||||
async getDomainDnsStats(domainId: string): Promise<{
|
||||
total: number;
|
||||
verified: number;
|
||||
pending: number;
|
||||
failed: number;
|
||||
required: number;
|
||||
}> {
|
||||
const records = await this.find({ domain: domainId, isActive: true });
|
||||
|
||||
return {
|
||||
total: records.length,
|
||||
verified: records.filter((r) => r.status === VerificationStatus.VERIFIED).length,
|
||||
pending: records.filter((r) => r.status === VerificationStatus.PENDING).length,
|
||||
failed: records.filter((r) => r.status === VerificationStatus.FAILED).length,
|
||||
required: records.filter((r) => r.isRequired).length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find MX records for a domain
|
||||
*/
|
||||
async findMXRecords(domainId: string): Promise<DnsRecord[]> {
|
||||
return this.find(
|
||||
{
|
||||
domain: domainId,
|
||||
type: DNSRecordType.MX,
|
||||
isActive: true,
|
||||
},
|
||||
{ orderBy: { priority: "ASC" } },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find SPF record for a domain
|
||||
*/
|
||||
async findSPFRecord(domainId: string): Promise<DnsRecord | null> {
|
||||
return this.findOne({
|
||||
domain: domainId,
|
||||
type: DNSRecordType.SPF,
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find DKIM records for a domain
|
||||
*/
|
||||
async findDKIMRecords(domainId: string): Promise<DnsRecord[]> {
|
||||
return this.find({
|
||||
domain: domainId,
|
||||
type: DNSRecordType.DKIM,
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { EntityRepository } from "@mikro-orm/postgresql";
|
||||
|
||||
import { Domain } from "../entities/domain.entity";
|
||||
import { DomainStatus } from "../enums/domain-status.enum";
|
||||
|
||||
export class DomainRepository extends EntityRepository<Domain> {
|
||||
/**
|
||||
* Find domain by name
|
||||
*/
|
||||
async findByName(name: string): Promise<Domain | null> {
|
||||
return this.findOne({ name, deletedAt: null }, { populate: ["business", "dnsRecords"] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find domains by business ID
|
||||
*/
|
||||
async findByBusinessId(businessId: string): Promise<Domain | null> {
|
||||
return this.findOne({ business: businessId, deletedAt: null }, { populate: ["dnsRecords"] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find verified domains
|
||||
*/
|
||||
async findVerified(): Promise<Domain | null> {
|
||||
return this.findOne({ status: DomainStatus.VERIFIED, isActive: true, deletedAt: null });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find domains pending verification
|
||||
*/
|
||||
async findPendingVerification(): Promise<Domain | null> {
|
||||
return this.findOne({ status: DomainStatus.PENDING, isActive: true, deletedAt: null });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find domains that need checking
|
||||
*/
|
||||
async findNeedingCheck(): Promise<Domain | null> {
|
||||
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
|
||||
|
||||
return this.findOne({
|
||||
deletedAt: null,
|
||||
$or: [{ lastCheckedAt: null }, { lastCheckedAt: { $lt: oneHourAgo } }],
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get domain statistics for a business
|
||||
*/
|
||||
async getBusinessDomainStats(businessId: string): Promise<{
|
||||
total: number;
|
||||
verified: number;
|
||||
pending: number;
|
||||
failed: number;
|
||||
}> {
|
||||
const domains = await this.find({ business: businessId, deletedAt: null });
|
||||
|
||||
return {
|
||||
total: domains.length,
|
||||
verified: domains.filter((d) => d.status === DomainStatus.VERIFIED).length,
|
||||
pending: domains.filter((d) => d.status === DomainStatus.PENDING).length,
|
||||
failed: domains.filter((d) => d.status === DomainStatus.FAILED).length,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import * as crypto from "crypto";
|
||||
import * as dns from "dns";
|
||||
import { promisify } from "util";
|
||||
|
||||
import { EntityManager } from "@mikro-orm/core";
|
||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { DnsRecordMessage, DomainMessage } from "../../../common/enums/message.enum";
|
||||
import { MailServerService } from "../../mail-server/services/mail-server.service";
|
||||
import { CreateDnsRecordDto } from "../DTO/create-dns-record.dto";
|
||||
import { DnsRecord } from "../entities/dns-record.entity";
|
||||
import { Domain } from "../entities/domain.entity";
|
||||
import { DNSRecordType, VerificationStatus } from "../enums/domain-status.enum";
|
||||
import { DnsRecordRepository } from "../repositories/dns-record.repository";
|
||||
import { DomainRepository } from "../repositories/domain.repository";
|
||||
|
||||
@Injectable()
|
||||
export class DnsService {
|
||||
private readonly logger = new Logger(DnsService.name);
|
||||
|
||||
private readonly mailServerDomain: string = "mail.danakcorp.com";
|
||||
private readonly spfRecord: string = `v=spf1 include:${this.mailServerDomain} ~all`;
|
||||
private readonly dmarcRecord: string = `v=DMARC1; p=quarantine; rua=mailto:dmarc@${this.mailServerDomain}; ruf=mailto:dmarc@${this.mailServerDomain}`;
|
||||
|
||||
private readonly resolveTxt = promisify(dns.resolveTxt);
|
||||
private readonly resolveCname = promisify(dns.resolveCname);
|
||||
private readonly resolveMx = promisify(dns.resolveMx);
|
||||
|
||||
constructor(
|
||||
private readonly dnsRecordRepository: DnsRecordRepository,
|
||||
private readonly domainRepository: DomainRepository,
|
||||
private readonly mailServerService: MailServerService,
|
||||
private readonly em: EntityManager,
|
||||
) {}
|
||||
|
||||
/*******************************/
|
||||
async createDnsRecord(createDnsRecordDto: CreateDnsRecordDto) {
|
||||
const domain = await this.domainRepository.findOne({ id: createDnsRecordDto.domainId });
|
||||
if (!domain) throw new BadRequestException(DomainMessage.DOMAIN_NOT_FOUND);
|
||||
|
||||
const dnsRecord = this.dnsRecordRepository.create({
|
||||
...createDnsRecordDto,
|
||||
domain,
|
||||
});
|
||||
|
||||
await this.em.persistAndFlush(dnsRecord);
|
||||
this.logger.log(`DNS record created: ${dnsRecord.name} ${dnsRecord.type}`);
|
||||
|
||||
return { dnsRecord };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get DNS records for domain
|
||||
*/
|
||||
async getDomainDnsRecords(domainId: string) {
|
||||
const dnsRecords = await this.dnsRecordRepository.findByDomainId(domainId);
|
||||
return { dnsRecords };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update DNS record
|
||||
*/
|
||||
async updateDnsRecord(id: string, updates: Partial<DnsRecord>) {
|
||||
const dnsRecord = await this.dnsRecordRepository.findOne({ id });
|
||||
if (!dnsRecord) throw new BadRequestException(DnsRecordMessage.DNS_RECORD_NOT_FOUND);
|
||||
|
||||
this.em.assign(dnsRecord, updates);
|
||||
await this.em.persistAndFlush(dnsRecord);
|
||||
|
||||
return { dnsRecord };
|
||||
}
|
||||
|
||||
/*******************************/
|
||||
async deleteDnsRecord(id: string) {
|
||||
const dnsRecord = await this.dnsRecordRepository.findOne({ id });
|
||||
if (!dnsRecord) throw new BadRequestException(DnsRecordMessage.DNS_RECORD_NOT_FOUND);
|
||||
|
||||
dnsRecord.isActive = false;
|
||||
dnsRecord.deletedAt = new Date();
|
||||
await this.em.persistAndFlush(dnsRecord);
|
||||
|
||||
return { message: DnsRecordMessage.DNS_RECORD_DELETED };
|
||||
}
|
||||
|
||||
/*******************************/
|
||||
async generateRequiredDnsRecords(domain: Domain) {
|
||||
const requiredRecords: Partial<DnsRecord>[] = [];
|
||||
|
||||
// MX Record
|
||||
requiredRecords.push({
|
||||
name: domain.name,
|
||||
type: DNSRecordType.MX,
|
||||
value: this.mailServerDomain,
|
||||
priority: 10,
|
||||
isRequired: true,
|
||||
description: "Mail exchange record for email delivery",
|
||||
});
|
||||
|
||||
// SPF Record
|
||||
requiredRecords.push({
|
||||
name: domain.name,
|
||||
type: DNSRecordType.SPF,
|
||||
value: this.spfRecord,
|
||||
isRequired: true,
|
||||
description: "Sender Policy Framework record",
|
||||
});
|
||||
|
||||
// DMARC Record
|
||||
requiredRecords.push({
|
||||
name: `_dmarc.${domain.name}`,
|
||||
type: DNSRecordType.DMARC,
|
||||
value: this.dmarcRecord,
|
||||
isRequired: true,
|
||||
description: "DMARC policy record",
|
||||
});
|
||||
|
||||
const dnsRecords: DnsRecord[] = [];
|
||||
|
||||
for (const recordData of requiredRecords) {
|
||||
const dnsRecord = this.dnsRecordRepository.create({
|
||||
name: recordData.name!,
|
||||
type: recordData.type!,
|
||||
value: recordData.value!,
|
||||
domain,
|
||||
isRequired: recordData.isRequired ?? true,
|
||||
description: recordData.description,
|
||||
priority: recordData.priority,
|
||||
ttl: recordData.ttl,
|
||||
});
|
||||
dnsRecords.push(dnsRecord);
|
||||
}
|
||||
|
||||
await this.em.persistAndFlush(dnsRecords);
|
||||
this.logger.log(`Generated ${dnsRecords.length} required DNS records for ${domain.name}`);
|
||||
|
||||
return { dnsRecords };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create DKIM record
|
||||
*/
|
||||
async createDKIMRecord(domain: Domain, selector: string, dkimName: string, dkimValue: string, wildduckId: string) {
|
||||
const dkimRecord = this.dnsRecordRepository.create({
|
||||
name: dkimName,
|
||||
type: DNSRecordType.DKIM,
|
||||
value: dkimValue,
|
||||
domain,
|
||||
isRequired: true,
|
||||
description: `DKIM public key for selector ${selector}`,
|
||||
wildduckId,
|
||||
});
|
||||
|
||||
await this.em.persistAndFlush(dkimRecord);
|
||||
this.logger.log(`DKIM record created for ${domain.name} with selector ${selector}`);
|
||||
|
||||
return { dkimRecord };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate DKIM keys
|
||||
*/
|
||||
async generateDKIMKeysByMailServer(domainName: string, selector: string) {
|
||||
const data = await firstValueFrom(this.mailServerService.dkim.createDKIMKey({ domain: domainName, selector }));
|
||||
return { publicKey: data.publicKey, privateKey: data.dnsTxt.value, dnsTxt: data.dnsTxt, wildduckId: data.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate DKIM keys native
|
||||
*/
|
||||
async generateDKIMKeysByNative() {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: {
|
||||
type: "spki",
|
||||
format: "pem",
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: "pkcs8",
|
||||
format: "pem",
|
||||
},
|
||||
});
|
||||
|
||||
// Extract the public key without headers and newlines for DNS
|
||||
const publicKeyForDNS = publicKey
|
||||
.replace(/-----BEGIN PUBLIC KEY-----/g, "")
|
||||
.replace(/-----END PUBLIC KEY-----/g, "")
|
||||
.replace(/\n/g, "");
|
||||
|
||||
return {
|
||||
privateKey,
|
||||
publicKey: publicKeyForDNS,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify domain DNS configuration
|
||||
*/
|
||||
async verifyDomainDNS(domain: Domain) {
|
||||
const dnsRecords = await this.dnsRecordRepository.findByDomainId(domain.id);
|
||||
const requiredRecords = dnsRecords.filter((record) => record.isRequired && record.isActive);
|
||||
|
||||
let allVerified = true;
|
||||
|
||||
for (const record of requiredRecords) {
|
||||
const isVerified = await this.verifyDnsRecord(record);
|
||||
if (!isVerified) {
|
||||
allVerified = false;
|
||||
}
|
||||
}
|
||||
|
||||
return allVerified;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify individual DNS record
|
||||
*/
|
||||
async verifyDnsRecord(dnsRecord: DnsRecord) {
|
||||
try {
|
||||
dnsRecord.verificationAttempts += 1;
|
||||
dnsRecord.lastCheckedAt = new Date();
|
||||
|
||||
let isVerified = false;
|
||||
|
||||
switch (dnsRecord.type) {
|
||||
case DNSRecordType.TXT:
|
||||
case DNSRecordType.SPF:
|
||||
case DNSRecordType.DMARC:
|
||||
case DNSRecordType.DKIM:
|
||||
isVerified = await this.verifyTxtRecord(dnsRecord);
|
||||
break;
|
||||
case DNSRecordType.MX:
|
||||
isVerified = await this.verifyMxRecord(dnsRecord);
|
||||
break;
|
||||
case DNSRecordType.CNAME:
|
||||
isVerified = await this.verifyCnameRecord(dnsRecord);
|
||||
break;
|
||||
default:
|
||||
this.logger.warn(`DNS record type ${dnsRecord.type} verification not implemented`);
|
||||
isVerified = false;
|
||||
}
|
||||
|
||||
if (isVerified) {
|
||||
dnsRecord.status = VerificationStatus.VERIFIED;
|
||||
dnsRecord.lastVerifiedAt = new Date();
|
||||
dnsRecord.errorMessage = undefined;
|
||||
} else {
|
||||
dnsRecord.status = VerificationStatus.FAILED;
|
||||
dnsRecord.errorMessage = "DNS record not found or value mismatch";
|
||||
}
|
||||
|
||||
await this.em.persistAndFlush(dnsRecord);
|
||||
return isVerified;
|
||||
} catch (error) {
|
||||
dnsRecord.status = VerificationStatus.FAILED;
|
||||
dnsRecord.errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
await this.em.persistAndFlush(dnsRecord);
|
||||
|
||||
this.logger.error(`DNS verification failed for ${dnsRecord.name}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify TXT record
|
||||
*/
|
||||
private async verifyTxtRecord(dnsRecord: DnsRecord) {
|
||||
try {
|
||||
const txtRecords = await this.resolveTxt(dnsRecord.name);
|
||||
const flatRecords = txtRecords.flat();
|
||||
|
||||
return flatRecords.some((record) => record.includes(dnsRecord.value) || record === dnsRecord.value);
|
||||
} catch (error) {
|
||||
this.logger.debug(`TXT record verification failed for ${dnsRecord.name}:`, error instanceof Error ? error.message : "Unknown error");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify MX record
|
||||
*/
|
||||
private async verifyMxRecord(dnsRecord: DnsRecord) {
|
||||
try {
|
||||
const mxRecords = await this.resolveMx(dnsRecord.name);
|
||||
|
||||
return mxRecords.some((mx) => mx.exchange === dnsRecord.value && mx.priority === (dnsRecord.priority || 10));
|
||||
} catch (error) {
|
||||
this.logger.debug(`MX record verification failed for ${dnsRecord.name}:`, error instanceof Error ? error.message : "Unknown error");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify CNAME record
|
||||
*/
|
||||
private async verifyCnameRecord(dnsRecord: DnsRecord) {
|
||||
try {
|
||||
const cnameRecords = await this.resolveCname(dnsRecord.name);
|
||||
|
||||
return cnameRecords.includes(dnsRecord.value);
|
||||
} catch (error) {
|
||||
this.logger.debug(`CNAME record verification failed for ${dnsRecord.name}:`, error instanceof Error ? error.message : "Unknown error");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get DNS record statistics
|
||||
*/
|
||||
async getDnsRecordStats(domainId: string): Promise<{
|
||||
total: number;
|
||||
verified: number;
|
||||
pending: number;
|
||||
failed: number;
|
||||
required: number;
|
||||
}> {
|
||||
return this.dnsRecordRepository.getDomainDnsStats(domainId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify all DNS records that need checking
|
||||
*/
|
||||
async verifyPendingDnsRecords(): Promise<void> {
|
||||
const records = await this.dnsRecordRepository.findNeedingVerification();
|
||||
|
||||
for (const record of records) {
|
||||
try {
|
||||
await this.verifyDnsRecord(record);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to verify DNS record ${record.name}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
import { EntityManager } from "@mikro-orm/core";
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { DnsService } from "./dns.service";
|
||||
import { BusinessMessage, DomainMessage, MailServerMessage } from "../../../common/enums/message.enum";
|
||||
import { Business } from "../../businesses/entities/business.entity";
|
||||
import { MailServerService } from "../../mail-server/services/mail-server.service";
|
||||
import { formatDomainMessage } from "../../utils/services/message.utils";
|
||||
import { CreateDomainDto } from "../DTO/create-domain.dto";
|
||||
import { UpdateDomainDto } from "../DTO/update-domain.dto";
|
||||
import { DnsRecord } from "../entities/dns-record.entity";
|
||||
import { Domain } from "../entities/domain.entity";
|
||||
import { DomainStatus, VerificationStatus } from "../enums/domain-status.enum";
|
||||
import { DomainRepository } from "../repositories/domain.repository";
|
||||
|
||||
@Injectable()
|
||||
export class DomainsService {
|
||||
private readonly logger = new Logger(DomainsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly domainRepository: DomainRepository,
|
||||
private readonly em: EntityManager,
|
||||
private readonly dnsService: DnsService,
|
||||
private readonly mailServerService: MailServerService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new domain
|
||||
*/
|
||||
async createDomain(createDomainDto: CreateDomainDto, businessId: string) {
|
||||
const { name, notes } = createDomainDto;
|
||||
|
||||
const existBusinessDomain = await this.domainRepository.findOne({ business: { id: businessId }, deletedAt: null });
|
||||
if (existBusinessDomain) throw new BadRequestException(DomainMessage.BUSINESS_ALREADY_HAS_DOMAIN);
|
||||
|
||||
// Check if domain already exists
|
||||
const existingDomain = await this.domainRepository.findByName(name);
|
||||
if (existingDomain) throw new BadRequestException(formatDomainMessage(DomainMessage.DOMAIN_ALREADY_EXISTS, { name }));
|
||||
|
||||
// Verify business exists
|
||||
const business = await this.em.findOne(Business, { id: businessId });
|
||||
if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND);
|
||||
|
||||
const domain = this.domainRepository.create({
|
||||
name,
|
||||
business,
|
||||
notes,
|
||||
dkimEnabled: true,
|
||||
dkimSelector: "default",
|
||||
});
|
||||
|
||||
await this.em.persistAndFlush(domain);
|
||||
|
||||
// Generate required DNS records (MX, SPF, DMARC)
|
||||
await this.dnsService.generateRequiredDnsRecords(domain);
|
||||
|
||||
// Generate and create DKIM record
|
||||
try {
|
||||
const dkimKeys = await this.dnsService.generateDKIMKeysByMailServer(domain.name, "default");
|
||||
|
||||
// Update domain with DKIM keys
|
||||
domain.dkimPrivateKey = dkimKeys.privateKey;
|
||||
domain.dkimPublicKey = dkimKeys.publicKey;
|
||||
|
||||
// Create DKIM DNS record
|
||||
await this.dnsService.createDKIMRecord(domain, "default", dkimKeys.dnsTxt.name, dkimKeys.dnsTxt.value, dkimKeys.wildduckId);
|
||||
|
||||
await this.em.persistAndFlush(domain);
|
||||
|
||||
this.logger.log(`DKIM automatically enabled for domain ${name}`);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to create DKIM for domain ${name}:`, error);
|
||||
// Continue with domain creation even if DKIM fails
|
||||
domain.dkimEnabled = false;
|
||||
await this.em.persistAndFlush(domain);
|
||||
}
|
||||
|
||||
// Create initial verification
|
||||
|
||||
this.logger.log(`Domain ${name} created for business ${businessId} with all required records`);
|
||||
|
||||
// Get updated DNS records including DKIM
|
||||
const { dnsRecords: allDnsRecords } = await this.dnsService.getDomainDnsRecords(domain.id);
|
||||
|
||||
// Return domain setup response with complete instructions
|
||||
return this.formatDomainSetupResponse(domain, allDnsRecords);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get domain by ID
|
||||
*/
|
||||
async getDomainById(id: string): Promise<Domain> {
|
||||
const domain = await this.domainRepository.findOne({ id, deletedAt: null }, { populate: ["business", "dnsRecords"] });
|
||||
|
||||
if (!domain) throw new NotFoundException(DomainMessage.DOMAIN_NOT_FOUND);
|
||||
|
||||
return domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get domain by name
|
||||
*/
|
||||
async getDomainByName(name: string) {
|
||||
const domain = await this.domainRepository.findByName(name);
|
||||
if (!domain) throw new BadRequestException(DomainMessage.DOMAIN_NOT_FOUND);
|
||||
|
||||
return { domain };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get domains for a business
|
||||
*/
|
||||
async getBusinessDomain(businessId: string) {
|
||||
const domain = await this.domainRepository.findByBusinessId(businessId);
|
||||
return { domain };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update domain
|
||||
*/
|
||||
async updateDomain(id: string, updateDomainDto: UpdateDomainDto) {
|
||||
const domain = await this.getDomainById(id);
|
||||
|
||||
this.em.assign(domain, updateDomainDto);
|
||||
|
||||
await this.em.flush();
|
||||
|
||||
this.logger.log(`Domain ${domain.name} updated`);
|
||||
return { domain, message: DomainMessage.DOMAIN_UPDATED_SUCCESSFULLY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete domain
|
||||
*/
|
||||
async deleteDomain(id: string) {
|
||||
const domain = await this.getDomainById(id);
|
||||
|
||||
// Soft delete
|
||||
domain.deletedAt = new Date();
|
||||
domain.isActive = false;
|
||||
|
||||
await this.em.persistAndFlush(domain);
|
||||
this.logger.log(`Domain ${domain.name} deleted`);
|
||||
return { message: DomainMessage.DOMAIN_DELETED_SUCCESSFULLY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify domain ownership
|
||||
*/
|
||||
async verifyDomain(id: string) {
|
||||
const domain = await this.getDomainById(id);
|
||||
|
||||
// Check DNS records for verification
|
||||
const isVerified = await this.dnsService.verifyDomainDNS(domain);
|
||||
|
||||
if (isVerified) {
|
||||
domain.status = DomainStatus.VERIFIED;
|
||||
domain.isVerified = true;
|
||||
domain.verifiedAt = new Date();
|
||||
|
||||
// Setup domain in mail server
|
||||
await this.setupDomainInMailServer(domain);
|
||||
|
||||
await this.em.persistAndFlush(domain);
|
||||
this.logger.log(`Domain ${domain.name} verified successfully`);
|
||||
} else {
|
||||
domain.status = DomainStatus.FAILED;
|
||||
await this.em.persistAndFlush(domain);
|
||||
this.logger.warn(`Domain ${domain.name} verification failed`);
|
||||
}
|
||||
|
||||
return { domain };
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup domain in mail server
|
||||
*/
|
||||
private async setupDomainInMailServer(domain: Domain) {
|
||||
try {
|
||||
// Create DKIM keys if enabled
|
||||
if (domain.dkimEnabled && domain.dkimSelector) {
|
||||
const dkimKey = await firstValueFrom(
|
||||
this.mailServerService.dkim.createDKIMKey({
|
||||
domain: domain.name,
|
||||
selector: domain.dkimSelector,
|
||||
description: `DKIM for ${domain.name}`,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!dkimKey.success) throw new BadRequestException(MailServerMessage.FAILED_TO_CREATE_DKIM_KEY);
|
||||
}
|
||||
|
||||
this.logger.log(`Mail server setup completed for domain ${domain.name}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to setup domain ${domain.name} in mail server:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable DKIM for domain
|
||||
*/
|
||||
async enableDKIM(id: string, selector?: string) {
|
||||
const domain = await this.getDomainById(id);
|
||||
|
||||
const dkimSelector = selector || "default";
|
||||
|
||||
// Generate DKIM keys
|
||||
const dkimKeys = await this.dnsService.generateDKIMKeysByMailServer(domain.name, dkimSelector);
|
||||
|
||||
domain.dkimEnabled = true;
|
||||
domain.dkimSelector = dkimSelector;
|
||||
domain.dkimPrivateKey = dkimKeys.privateKey;
|
||||
domain.dkimPublicKey = dkimKeys.publicKey;
|
||||
|
||||
// Create DKIM DNS record
|
||||
await this.dnsService.createDKIMRecord(domain, dkimSelector, dkimKeys.dnsTxt.name, dkimKeys.dnsTxt.value, dkimKeys.wildduckId);
|
||||
|
||||
await this.em.persistAndFlush(domain);
|
||||
this.logger.log(`DKIM enabled for domain ${domain.name}`);
|
||||
|
||||
return { domain };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get domain statistics
|
||||
*/
|
||||
async getDomainStats(businessId: string) {
|
||||
return this.domainRepository.getBusinessDomainStats(businessId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all domains that need verification
|
||||
*/
|
||||
async checkDomainsNeedingVerification() {
|
||||
const domain = await this.domainRepository.findNeedingCheck();
|
||||
|
||||
if (!domain) return { message: DomainMessage.NO_DOMAIN_FOUND };
|
||||
await this.verifyDomain(domain.id);
|
||||
|
||||
return { message: DomainMessage.DOMAIN_CHECKED, domain: domain };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get domain setup instructions
|
||||
*/
|
||||
async getDomainSetupInstructions(domainId: string) {
|
||||
const domain = await this.getDomainById(domainId);
|
||||
const { dnsRecords } = await this.dnsService.getDomainDnsRecords(domainId);
|
||||
|
||||
return this.formatDomainSetupResponse(domain, dnsRecords);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get DNS records for domain
|
||||
*/
|
||||
async getDomainDnsRecords(domainId: string) {
|
||||
// Verify domain exists
|
||||
await this.getDomainById(domainId);
|
||||
return this.dnsService.getDomainDnsRecords(domainId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check domain verification status
|
||||
*/
|
||||
async checkDomainVerificationStatus(domainId: string) {
|
||||
await this.getDomainById(domainId);
|
||||
const { dnsRecords } = await this.dnsService.getDomainDnsRecords(domainId);
|
||||
|
||||
const verificationStatuses = [];
|
||||
let verified = 0;
|
||||
let pending = 0;
|
||||
let failed = 0;
|
||||
const recommendations: string[] = [];
|
||||
|
||||
for (const record of dnsRecords) {
|
||||
// Verify each DNS record
|
||||
await this.dnsService.verifyDnsRecord(record);
|
||||
|
||||
const status = {
|
||||
recordId: record.id,
|
||||
name: record.name,
|
||||
type: record.type,
|
||||
expectedValue: record.value,
|
||||
currentValue: undefined,
|
||||
status: record.status,
|
||||
lastChecked: record.lastCheckedAt || new Date(),
|
||||
errorMessage: record.errorMessage,
|
||||
isCorrect: record.status === VerificationStatus.VERIFIED,
|
||||
};
|
||||
|
||||
verificationStatuses.push(status);
|
||||
|
||||
if (record.status === VerificationStatus.VERIFIED) {
|
||||
verified++;
|
||||
} else if (record.status === VerificationStatus.FAILED) {
|
||||
failed++;
|
||||
if (record.errorMessage) {
|
||||
recommendations.push(
|
||||
formatDomainMessage(DomainMessage.RECORD_NEEDS_FIXING, {
|
||||
type: record.type,
|
||||
name: record.name,
|
||||
error: record.errorMessage,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
pending++;
|
||||
recommendations.push(
|
||||
formatDomainMessage(DomainMessage.RECORD_PENDING_VERIFICATION_DETAIL, {
|
||||
type: record.type,
|
||||
name: record.name,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isVerified = failed === 0 && pending === 0 && verified > 0;
|
||||
|
||||
return {
|
||||
dnsRecords: verificationStatuses,
|
||||
overallStatus: {
|
||||
isVerified,
|
||||
verified,
|
||||
pending,
|
||||
failed,
|
||||
total: dnsRecords.length,
|
||||
lastChecked: new Date(),
|
||||
},
|
||||
recommendations,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format domain setup response with instructions
|
||||
*/
|
||||
private formatDomainSetupResponse(domain: Domain, dnsRecords: DnsRecord[]) {
|
||||
const dnsInstructions = dnsRecords.map((record) => ({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
type: record.type,
|
||||
value: record.value,
|
||||
ttl: record.ttl,
|
||||
priority: record.priority,
|
||||
description:
|
||||
record.description ||
|
||||
formatDomainMessage(DomainMessage.RECORD_DESCRIPTION_EMAIL_FUNCTIONALITY, {
|
||||
type: record.type,
|
||||
}),
|
||||
isRequired: record.isRequired,
|
||||
status: record.status,
|
||||
instructions: this.generateRecordInstructions(record),
|
||||
}));
|
||||
|
||||
const verified = dnsRecords.filter((r) => r.status === VerificationStatus.VERIFIED).length;
|
||||
const pending = dnsRecords.filter((r) => r.status === VerificationStatus.PENDING).length;
|
||||
const failed = dnsRecords.filter((r) => r.status === VerificationStatus.FAILED).length;
|
||||
|
||||
const nextSteps = this.generateNextSteps(domain, dnsRecords);
|
||||
|
||||
return {
|
||||
domain,
|
||||
dnsRecords: dnsInstructions,
|
||||
setupStatus: {
|
||||
isComplete: failed === 0 && pending === 0 && verified > 0,
|
||||
verified,
|
||||
pending,
|
||||
failed,
|
||||
total: dnsRecords.length,
|
||||
},
|
||||
nextSteps,
|
||||
estimatedPropagationTime: DomainMessage.ESTIMATED_PROPAGATION_TIME,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate instructions for a specific DNS record
|
||||
*/
|
||||
private generateRecordInstructions(record: DnsRecord): string {
|
||||
switch (record.type) {
|
||||
case "MX":
|
||||
return formatDomainMessage(DomainMessage.ADD_MX_RECORD, {
|
||||
name: record.name,
|
||||
value: record.value,
|
||||
priority: record.priority,
|
||||
});
|
||||
case "TXT":
|
||||
case "SPF":
|
||||
return formatDomainMessage(DomainMessage.ADD_TXT_RECORD, {
|
||||
name: record.name,
|
||||
value: record.value,
|
||||
});
|
||||
case "DKIM":
|
||||
return formatDomainMessage(DomainMessage.ADD_DKIM_RECORD, {
|
||||
name: record.name,
|
||||
value: record.value,
|
||||
});
|
||||
case "DMARC":
|
||||
return formatDomainMessage(DomainMessage.ADD_DMARC_RECORD, {
|
||||
name: record.name,
|
||||
value: record.value,
|
||||
});
|
||||
case "CNAME":
|
||||
return formatDomainMessage(DomainMessage.ADD_CNAME_RECORD, {
|
||||
name: record.name,
|
||||
value: record.value,
|
||||
});
|
||||
default:
|
||||
return formatDomainMessage(DomainMessage.ADD_GENERIC_RECORD, {
|
||||
type: record.type,
|
||||
name: record.name,
|
||||
value: record.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate next steps for domain setup
|
||||
*/
|
||||
private generateNextSteps(domain: Domain, dnsRecords: DnsRecord[]): string[] {
|
||||
const steps: string[] = [];
|
||||
|
||||
if (domain.status === DomainStatus.PENDING) {
|
||||
steps.push(`1. ${DomainMessage.STEP_LOGIN_DNS_PANEL}`);
|
||||
steps.push(`2. ${DomainMessage.STEP_ADD_ALL_RECORDS}`);
|
||||
steps.push(`3. ${DomainMessage.STEP_INCLUDE_ALL_TYPES}`);
|
||||
steps.push(`4. ${DomainMessage.STEP_WAIT_PROPAGATION}`);
|
||||
steps.push(`5. ${DomainMessage.STEP_USE_CHECK_ENDPOINT}`);
|
||||
steps.push(`6. ${DomainMessage.STEP_DOMAIN_READY}`);
|
||||
}
|
||||
|
||||
const pendingRecords = dnsRecords.filter((r) => r.status === VerificationStatus.PENDING);
|
||||
if (pendingRecords.length > 0) {
|
||||
steps.push(`📋 ${formatDomainMessage(DomainMessage.RECORDS_PENDING_VERIFICATION, { count: pendingRecords.length })}`);
|
||||
}
|
||||
|
||||
const requiredRecords = dnsRecords.filter((r) => r.isRequired);
|
||||
if (requiredRecords.length > 0) {
|
||||
steps.push(`⚠️ ${formatDomainMessage(DomainMessage.ALL_REQUIRED_RECORDS_MUST_BE_CONFIGURED, { count: requiredRecords.length })}`);
|
||||
}
|
||||
|
||||
if (domain.status === DomainStatus.VERIFIED) {
|
||||
steps.push(`✅ ${DomainMessage.DOMAIN_FULLY_VERIFIED}`);
|
||||
steps.push(`🚀 ${DomainMessage.EMAIL_ACCOUNTS_READY}`);
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsBoolean, IsEmail, IsOptional } from "class-validator";
|
||||
|
||||
export class CreateAddressDto {
|
||||
@ApiProperty({
|
||||
description: "Email address to use as an alias",
|
||||
example: "alias@example.com",
|
||||
})
|
||||
@IsEmail()
|
||||
address: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Whether this is the default address for the user",
|
||||
default: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
main?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateAddressDto {
|
||||
@ApiProperty({
|
||||
description: "Set as the default address for the user",
|
||||
example: true,
|
||||
})
|
||||
@IsBoolean()
|
||||
main: boolean;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsEnum, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export enum AuthScope {
|
||||
MASTER = "master",
|
||||
IMAP = "imap",
|
||||
POP3 = "pop3",
|
||||
SMTP = "smtp",
|
||||
}
|
||||
|
||||
export class AuthenticateDto {
|
||||
@ApiProperty({
|
||||
description: "Username or email address of the user",
|
||||
example: "testuser",
|
||||
})
|
||||
@IsString()
|
||||
username: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Password for the user (master or application specific)",
|
||||
example: "secretpassword123",
|
||||
})
|
||||
@IsString()
|
||||
password: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Scope to request for",
|
||||
enum: AuthScope,
|
||||
default: AuthScope.MASTER,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(AuthScope)
|
||||
scope?: AuthScope;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Application type this authentication is made from",
|
||||
example: "API",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
protocol?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Session identifier for logging",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sess?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "IP address the request was made from",
|
||||
example: "192.168.1.1",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ip?: string;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsArray, IsBoolean, IsEmail, IsNumber, IsOptional, IsString, IsUrl } from "class-validator";
|
||||
|
||||
export class CreateUserDto {
|
||||
@ApiProperty({ description: "Username for the user (letters and numbers only)", example: "testuser" })
|
||||
@IsString()
|
||||
username: string;
|
||||
|
||||
@ApiProperty({ description: "Password for the user", example: "secretpassword123" })
|
||||
@IsString()
|
||||
password: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Main email address for the user", example: "testuser@example.com" })
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
address?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "If true, do not set up an address for the user", default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
emptyAddress?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: "Display name for the user", example: "Test User" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Array of email addresses to forward all messages to",
|
||||
example: ["forward1@example.com", "forward2@example.com"],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsEmail({}, { each: true })
|
||||
forward?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: "URL to upload all messages to", example: "https://example.com/webhook" })
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
targetUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Maximum storage in bytes allowed for this user", example: 1073741824 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
quota?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: "Default retention time in milliseconds for mailboxes", example: 2592000000 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
retention?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: "Language code for the user", example: "en" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
language?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Maximum number of recipients allowed to send mail to in 24h", example: 2000 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
recipients?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: "Maximum number of forwarded emails in 24h", example: 2000 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
forwards?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: "Array of tags to associate with the user", example: ["vip", "customer"] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: "PGP public key for encryption" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
pubKey?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Whether to encrypt stored messages", default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
encryptMessages?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: "Whether to encrypt forwarded messages", default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
encryptForwarded?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: "Session identifier for logging" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sess?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "IP address the request was made from", example: "192.168.1.1" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ip?: string;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class CreateDKIMDto {
|
||||
@ApiProperty({ description: "Domain name", example: "example.com" })
|
||||
@IsString()
|
||||
domain: string;
|
||||
|
||||
@ApiProperty({ description: "DKIM selector", example: "default" })
|
||||
@IsString()
|
||||
selector: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Description for the DKIM key" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Session identifier for logging" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sess?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "IP address" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ip?: string;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsBoolean, IsEmail, IsNumber, IsOptional, IsString, IsUrl } from "class-validator";
|
||||
|
||||
export class CreateFilterDto {
|
||||
@ApiPropertyOptional({
|
||||
description: "Name of the filter",
|
||||
example: "Important emails",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "String to match against the From: header",
|
||||
example: "boss@company.com",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
query_from?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "String to match against the To:/Cc: headers",
|
||||
example: "team@company.com",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
query_to?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "String to match against the Subject: header",
|
||||
example: "urgent",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
query_subject?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "String to match against the message text",
|
||||
example: "important project",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
query_text?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Require message to have attachments (true) or not (false)",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
query_ha?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Message size requirement (positive for larger, negative for smaller)",
|
||||
example: 1000000,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
query_size?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Mark message as seen (true) or unseen (false)",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
action_seen?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Mark message as flagged (true) or not (false)",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
action_flag?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Delete message immediately (true)",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
action_delete?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Mark message as spam (true) or not spam (false)",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
action_spam?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Mailbox ID to move the message to",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
action_mailbox?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Email address to forward the message to",
|
||||
example: "forward@example.com",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
action_forward?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Web URL to upload the message to",
|
||||
example: "https://webhook.example.com/messages",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
action_targetUrl?: string;
|
||||
}
|
||||
|
||||
export class UpdateFilterDto extends CreateFilterDto {}
|
||||
@@ -0,0 +1,30 @@
|
||||
// User DTOs
|
||||
export * from "./create-user.dto";
|
||||
export * from "./update-user.dto";
|
||||
|
||||
// Message DTOs
|
||||
export * from "./message.dto";
|
||||
|
||||
// Mailbox DTOs
|
||||
export * from "./mailbox.dto";
|
||||
|
||||
// Address DTOs
|
||||
export * from "./address.dto";
|
||||
|
||||
// Authentication DTOs
|
||||
export * from "./authenticate.dto";
|
||||
|
||||
// Filter DTOs
|
||||
export * from "./filter.dto";
|
||||
|
||||
// Submission DTOs
|
||||
export * from "./submission.dto";
|
||||
|
||||
// DKIM DTOs
|
||||
export * from "./dkim.dto";
|
||||
|
||||
// Webhook DTOs
|
||||
export * from "./webhook.dto";
|
||||
|
||||
// Two-Factor Authentication DTOs
|
||||
export * from "./two-factor.dto";
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsBoolean, IsNumber, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class CreateMailboxDto {
|
||||
@ApiProperty({
|
||||
description: "Mailbox path with slashes as folder separators",
|
||||
example: "Important/Work",
|
||||
})
|
||||
@IsString()
|
||||
path: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Retention time in milliseconds for messages in this mailbox",
|
||||
example: 2592000000,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
retention?: number;
|
||||
}
|
||||
|
||||
export class UpdateMailboxDto {
|
||||
@ApiPropertyOptional({
|
||||
description: "New mailbox path if renaming",
|
||||
example: "Renamed Folder",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
path?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "New retention time in milliseconds",
|
||||
example: 5184000000,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
retention?: number;
|
||||
}
|
||||
|
||||
export class ListMailboxesQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: "Include message counters in results",
|
||||
default: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
counters?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsBoolean, IsDateString, IsNumber, IsOptional, IsString, Max, Min } from "class-validator";
|
||||
|
||||
export class ListMessagesQueryDto {
|
||||
@ApiPropertyOptional({ description: "Message ordering", enum: ["asc", "desc"], default: "desc" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
order?: "asc" | "desc";
|
||||
|
||||
@ApiPropertyOptional({ description: "Number of messages to return", minimum: 1, maximum: 250, default: 20 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(250)
|
||||
limit?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: "Page number for pagination", minimum: 1, default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: "Cursor for next page" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
next?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Cursor for previous page" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
previous?: string;
|
||||
}
|
||||
|
||||
export class UpdateMessageDto {
|
||||
@ApiPropertyOptional({ description: "ID of the destination mailbox if moving the message" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
moveTo?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Mark message as seen or unseen" })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
seen?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: "Mark message as flagged or not" })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
flagged?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: "Mark message as draft or not" })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
draft?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: "Mark message as deleted or not" })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
deleted?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: "Expiration date for auto-deletion or false to clear", example: "2024-12-31T23:59:59.000Z" })
|
||||
@IsOptional()
|
||||
expires?: string | false;
|
||||
}
|
||||
|
||||
export class SearchMessagesQueryDto {
|
||||
@ApiProperty({ description: "Query string to search for", example: "important meeting" })
|
||||
@IsString()
|
||||
query: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Message ordering", enum: ["asc", "desc"], default: "desc" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
order?: "asc" | "desc";
|
||||
|
||||
@ApiPropertyOptional({ description: "Number of results to return", minimum: 1, maximum: 250, default: 20 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(250)
|
||||
limit?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: "Page number for pagination", minimum: 1, default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
}
|
||||
|
||||
export class UploadMessageDto {
|
||||
@ApiProperty({
|
||||
description: "Raw message content in RFC822 format",
|
||||
example: "From: sender@example.com\nTo: recipient@example.com\nSubject: Test\n\nMessage body",
|
||||
})
|
||||
@IsString()
|
||||
raw: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Mark message as seen", default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
seen?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: "Mark message as flagged", default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
flagged?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: "Mark message as draft", default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
draft?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: "Message date", example: "2024-01-01T12:00:00.000Z" })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
date?: string;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsArray, IsEmail, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
export class EmailAddress {
|
||||
@ApiPropertyOptional({ description: "Display name", example: "John Doe" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@ApiProperty({ description: "Email address", example: "john@example.com" })
|
||||
@IsEmail()
|
||||
address: string;
|
||||
}
|
||||
|
||||
export class MessageAttachment {
|
||||
@ApiPropertyOptional({ description: "Filename", example: "document.pdf" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
filename?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Content type", example: "application/pdf" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
contentType?: string;
|
||||
|
||||
@ApiProperty({ description: "Base64 encoded content" })
|
||||
@IsString()
|
||||
content: string;
|
||||
}
|
||||
|
||||
export class SubmitMessageDto {
|
||||
@ApiProperty({ description: "From address" })
|
||||
@ValidateNested()
|
||||
@Type(() => EmailAddress)
|
||||
from: EmailAddress;
|
||||
|
||||
@ApiProperty({ description: "To addresses", type: [EmailAddress] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => EmailAddress)
|
||||
to: EmailAddress[];
|
||||
|
||||
@ApiPropertyOptional({ description: "CC addresses", type: [EmailAddress] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => EmailAddress)
|
||||
cc?: EmailAddress[];
|
||||
|
||||
@ApiPropertyOptional({ description: "BCC addresses", type: [EmailAddress] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => EmailAddress)
|
||||
bcc?: EmailAddress[];
|
||||
|
||||
@ApiProperty({ description: "Subject line", example: "Test email" })
|
||||
@IsString()
|
||||
subject: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Plain text content" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
text?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "HTML content" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
html?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Attachments", type: [MessageAttachment] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => MessageAttachment)
|
||||
attachments?: MessageAttachment[];
|
||||
|
||||
@ApiPropertyOptional({ description: "Custom headers" })
|
||||
@IsOptional()
|
||||
headers?: Record<string, string>;
|
||||
|
||||
@ApiPropertyOptional({ description: "Session identifier for logging" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sess?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "IP address" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ip?: string;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsBoolean, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class SetupTOTPDto {
|
||||
@ApiPropertyOptional({ description: "TOTP issuer name", example: "My Email Service" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
issuer?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Generate fresh secret", default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
fresh?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: "Session identifier for logging" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sess?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "IP address" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ip?: string;
|
||||
}
|
||||
|
||||
export class VerifyTOTPDto {
|
||||
@ApiProperty({ description: "TOTP token", example: "123456" })
|
||||
@IsString()
|
||||
token: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Session identifier for logging" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sess?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "IP address" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ip?: string;
|
||||
}
|
||||
|
||||
export class UseBackupCodeDto {
|
||||
@ApiProperty({ description: "Backup code", example: "abcd1234" })
|
||||
@IsString()
|
||||
code: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Session identifier for logging" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sess?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "IP address" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ip?: string;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsArray, IsBoolean, IsNumber, IsOptional, IsString, IsUrl } from "class-validator";
|
||||
|
||||
export class UpdateUserDto {
|
||||
@ApiPropertyOptional({
|
||||
description: "Updated display name for the user",
|
||||
example: "Updated User Name",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Updated password for the user",
|
||||
example: "newsecretpassword123",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
password?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Array of email addresses to forward all messages to",
|
||||
example: ["forward1@example.com", "forward2@example.com"],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
forward?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "URL to upload all messages to",
|
||||
example: "https://example.com/webhook",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
targetUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Maximum storage in bytes allowed for this user",
|
||||
example: 2147483648,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
quota?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Default retention time in milliseconds for mailboxes",
|
||||
example: 2592000000,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
retention?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Language code for the user",
|
||||
example: "en",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
language?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Maximum number of recipients allowed to send mail to in 24h",
|
||||
example: 3000,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
recipients?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Maximum number of forwarded emails in 24h",
|
||||
example: 3000,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
forwards?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Array of tags to associate with the user",
|
||||
example: ["premium", "customer"],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "PGP public key for encryption",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
pubKey?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Whether to encrypt stored messages",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
encryptMessages?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Whether to encrypt forwarded messages",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
encryptForwarded?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Current password for verification",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
existingPassword?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Session identifier for logging",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sess?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "IP address the request was made from",
|
||||
example: "192.168.1.1",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ip?: string;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsArray, IsOptional, IsString, IsUrl } from "class-validator";
|
||||
|
||||
export class CreateWebhookDto {
|
||||
@ApiProperty({
|
||||
description: "Event types to trigger webhook",
|
||||
example: ["messageNew", "messageDeleted"],
|
||||
type: [String],
|
||||
})
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
type: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: "User ID to limit webhook to specific user" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
user?: string;
|
||||
|
||||
@ApiProperty({ description: "Webhook URL", example: "https://example.com/webhook" })
|
||||
@IsUrl()
|
||||
url: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Session identifier for logging" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sess?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "IP address" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ip?: string;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export interface AddressInfo {
|
||||
id: string;
|
||||
address: string;
|
||||
main: boolean;
|
||||
user?: string;
|
||||
created: string;
|
||||
tags?: string[];
|
||||
metaData?: Record<string, any>;
|
||||
internalData?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface AddressListResponse {
|
||||
success: true;
|
||||
total: number;
|
||||
page: number;
|
||||
previousCursor: string | false;
|
||||
nextCursor: string | false;
|
||||
results: AddressInfo[];
|
||||
}
|
||||
|
||||
export interface AddressCreateResponse {
|
||||
success: true;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface AddressDetailsResponse extends AddressInfo {
|
||||
success: true;
|
||||
}
|
||||
|
||||
export interface AddressResolveResponse {
|
||||
success: true;
|
||||
id: string;
|
||||
address: string;
|
||||
user?: string;
|
||||
targets?: Array<{
|
||||
id: string;
|
||||
type: "relay" | "webhook" | "http";
|
||||
value: string;
|
||||
}>;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface ApplicationPassword {
|
||||
id: string;
|
||||
description: string;
|
||||
scopes: string[];
|
||||
lastUse?: {
|
||||
time: string;
|
||||
event: string;
|
||||
};
|
||||
created: string;
|
||||
}
|
||||
|
||||
export interface CreateApplicationPasswordData {
|
||||
description: string;
|
||||
scopes?: string[] | string;
|
||||
generateMobileconfig?: boolean;
|
||||
sess?: string;
|
||||
ip?: string;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
export interface ArchivedMessage {
|
||||
id: string;
|
||||
user: string;
|
||||
from: {
|
||||
address: string;
|
||||
name?: string;
|
||||
};
|
||||
to: Array<{
|
||||
address: string;
|
||||
name?: string;
|
||||
}>;
|
||||
subject: string;
|
||||
messageId: string;
|
||||
date: string;
|
||||
size: number;
|
||||
archived: string;
|
||||
expires?: string;
|
||||
}
|
||||
|
||||
export interface ArchiveListResponse {
|
||||
success: true;
|
||||
total: number;
|
||||
page: number;
|
||||
previousCursor: string | false;
|
||||
nextCursor: string | false;
|
||||
results: ArchivedMessage[];
|
||||
}
|
||||
|
||||
export interface ArchiveDetailsResponse extends ArchivedMessage {
|
||||
success: true;
|
||||
headers?: Record<string, string>;
|
||||
html?: string;
|
||||
text?: string;
|
||||
attachments?: Array<{
|
||||
id: string;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
size: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ArchiveRestoreResponse {
|
||||
success: true;
|
||||
id: string;
|
||||
message: string;
|
||||
restored: boolean;
|
||||
}
|
||||
|
||||
export interface ArchiveDeleteResponse {
|
||||
success: true;
|
||||
deleted: number;
|
||||
}
|
||||
|
||||
export interface ArchiveCreateResponse {
|
||||
success: true;
|
||||
id: string;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface ArchiveMessage {
|
||||
id: string;
|
||||
user: string;
|
||||
mailbox: string;
|
||||
message: number;
|
||||
archived: string;
|
||||
expires?: string;
|
||||
}
|
||||
|
||||
export interface ArchiveMessageData {
|
||||
expires?: string;
|
||||
sess?: string;
|
||||
ip?: string;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export interface AuditLogEntry {
|
||||
id: string;
|
||||
user?: string;
|
||||
action: string;
|
||||
result: "success" | "fail";
|
||||
sess?: string;
|
||||
ip: string;
|
||||
created: string;
|
||||
expires?: string;
|
||||
meta?: {
|
||||
protocol?: string;
|
||||
method?: string;
|
||||
source?: string;
|
||||
userAgent?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuditListResponse {
|
||||
success: true;
|
||||
total: number;
|
||||
page: number;
|
||||
previousCursor: string | false;
|
||||
nextCursor: string | false;
|
||||
results: AuditLogEntry[];
|
||||
}
|
||||
|
||||
export interface AuditDetailsResponse extends AuditLogEntry {
|
||||
success: true;
|
||||
}
|
||||
|
||||
export interface AuditExportResponse {
|
||||
success: true;
|
||||
id: string;
|
||||
url: string;
|
||||
expires: string;
|
||||
}
|
||||
|
||||
export interface AuditCreateResponse {
|
||||
success: true;
|
||||
id: string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface AuditEntry {
|
||||
id: string;
|
||||
user: string;
|
||||
action: string;
|
||||
result: string;
|
||||
sess: string;
|
||||
ip: string;
|
||||
created: string;
|
||||
expires: string;
|
||||
meta?: any;
|
||||
}
|
||||
|
||||
export interface CreateAuditEntryData {
|
||||
user: string;
|
||||
action: string;
|
||||
result: "success" | "fail";
|
||||
sess?: string;
|
||||
ip?: string;
|
||||
meta?: any;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export interface AuthenticateResponse {
|
||||
success: boolean;
|
||||
id: string;
|
||||
username: string;
|
||||
scope: string;
|
||||
require2fa: string[] | false;
|
||||
requirePasswordChange: boolean;
|
||||
u2fAuthRequest?: any;
|
||||
}
|
||||
|
||||
export interface Setup2FAResponse {
|
||||
success: true;
|
||||
qrcode: string;
|
||||
seed?: string;
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
export interface ApplicationPasswordInfo {
|
||||
id: string;
|
||||
description: string;
|
||||
scopes: string[];
|
||||
created: string;
|
||||
lastUse?: {
|
||||
time: string;
|
||||
event: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApplicationPasswordListResponse {
|
||||
success: true;
|
||||
results: ApplicationPasswordInfo[];
|
||||
}
|
||||
|
||||
export interface ApplicationPasswordCreateResponse {
|
||||
success: true;
|
||||
id: string;
|
||||
password: string;
|
||||
mobileconfig?: string;
|
||||
}
|
||||
|
||||
export interface ApplicationPasswordDetailsResponse extends ApplicationPasswordInfo {
|
||||
success: true;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface AutoreplyInfo {
|
||||
id: string;
|
||||
user: string;
|
||||
status: boolean;
|
||||
subject?: string;
|
||||
message?: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
created: string;
|
||||
updated?: string;
|
||||
}
|
||||
|
||||
export interface AutoreplyListResponse {
|
||||
success: true;
|
||||
results: AutoreplyInfo[];
|
||||
}
|
||||
|
||||
export interface AutoreplyCreateResponse {
|
||||
success: true;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface AutoreplyDetailsResponse extends AutoreplyInfo {
|
||||
success: true;
|
||||
}
|
||||
|
||||
export interface AutoreplyStatusResponse {
|
||||
success: true;
|
||||
status: boolean;
|
||||
subject?: string;
|
||||
message?: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface AutoreplyData {
|
||||
status?: boolean;
|
||||
subject?: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
sess?: string;
|
||||
ip?: string;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user