This commit is contained in:
2025-11-09 11:25:26 +03:30
commit 06f814b331
62 changed files with 31547 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
import dataBaseConfig from './config/mikro-orm.config';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { UserModule } from './modules/user/user.module';
// import { CacheModule } from '@nestjs/cache-manager';
// import { cacheConfig } from './config/cache.config';
import { UtilsModule } from './modules/utils/utils.module';
// import { HttpModule } from '@nestjs/axios';
// import { httpConfig } from './config/http.config';
import { AuthModule } from './modules/auth/auth.module';
import { JwtModule } from '@nestjs/jwt';
import { UploaderModule } from './modules/uploader/uploader.module';
import { AdminModule } from './modules/admin/admin.module';
// import { CacheService } from './modules/utils/cache.service';
import { ThrottlerModule } from '@nestjs/throttler';
import { SubmitionModule } from './modules/submition/submition.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, cache: true }),
MikroOrmModule.forRootAsync(dataBaseConfig),
// CacheModule.registerAsync(cacheConfig()),
JwtModule.registerAsync({
useFactory: (configService: ConfigService) => ({
global: true,
secret: configService.getOrThrow<string>('JWT_SECRET'),
signOptions: {
expiresIn: configService.getOrThrow<number>('JWT_EXPIRATION_TIME'),
},
}),
inject: [ConfigService],
}),
UserModule,
UtilsModule,
AuthModule,
UploaderModule,
AdminModule,
ThrottlerModule.forRoot([
{
ttl: 60, // time window in seconds
limit: 5, // max requests per window
},
]),
SubmitionModule
],
controllers: [],
// providers: [CacheService],
// exports: [CacheService],
})
export class AppModule {}
+22
View File
@@ -0,0 +1,22 @@
// export const AUTH_THROTTLE = "AUTH_THROTTLE";
export const AI_CONFIG = 'AI_CONFIG';
export const MIKRO_ORM_QUERY_LOGGER = 'MIKRO_ORM_QUERY_LOGGER';
export const NAJVA_CONFIG = 'NAJVA_CONFIG';
export const AUTH_THROTTLE_TTL = 1 * 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_POP3_SERVER = 'pop3.danakcorp.com';
export const DANAK_IMAPS_PORT = 993;
export const DANAK_POP3_PORT = 995;
export const DANAK_SMTPS_PORT = 465;
export const DANAK_IMAPS_ENCRYPTION = 'TLS /SSL';
export const DANAK_SMTPS_ENCRYPTION = 'TLS /SSL';
export const DANAK_POP3_ENCRYPTION = 'TLS /SSL';
+612
View File
@@ -0,0 +1,612 @@
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 = "حساب کاربری شما غیر فعال است",
TOKEN_MISSED_OR_EXPIRED = "توکن مفقود یا منقضی شده است",
OLD_PASSWORD_REQUIRED = "رمز عبور قبلی الزامی است",
PASSWORD_CHANGE_FAILED = "تغییر رمز عبور با مشکل روبرد شد",
}
export const enum UserMessage {
USER_NOT_FOUND = "کاربری با این مشخصات یافت نشد",
USER_EXISTS = "با این شماره قبلا ثبت نام شده است",
USER_REGISTER_SUCCESS = "ثبت نام موفقیت آمیز بود",
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 = "خطا در به روز رسانی حجم ایمیل",
USERNAME_REQUIRED = "نام کاربری الزامی است",
USERNAME_STRING = "نام کاربری باید یک رشته باشد",
USERNAME_MIN_LENGTH = "نام کاربری باید حداقل ۲ کاراکتر باشد",
USERNAME_MATCHES = "نام کاربری فقط میتواند شامل حروف، اعداد، نقطه، خط زیر و خط فاصله باشد",
PASSWORD_MATCHES = "رمز عبور باید حداقل ۸ کاراکتر باشد و شامل حروف بزرگ، حروف کوچک، اعداد و علائم خاص باشد",
PASSWORD_STRING = "رمز عبور باید یک رشته باشد",
PASSWORD_REQUIRED = "رمز عبور الزامی است",
PASSWORD_MIN_LENGTH = "رمز عبور باید حداقل ۸ کاراکتر باشد",
DOMAIN_ID_STRING = "شناسه دامنه باید یک رشته باشد",
DOMAIN_ID_REQUIRED = "شناسه دامنه الزامی است",
DOMAIN_ID_UUID = "شناسه دامنه باید یک یو یو آی دی باشد",
DISPLAY_NAME_STRING = "نام نمایشی باید یک رشته باشد",
DISPLAY_NAME_OPTIONAL = "نام نمایشی اختیاری است",
QUOTA_NUMBER = "حجم ایمیل باید یک عدد باشد",
QUOTA_OPTIONAL = "حجم ایمیل اختیاری است",
QUOTA_NOT_EMPTY = "حجم ایمیل نمیتواند خالی باشد",
ALIASES_ARRAY = "الیاس ها باید یک آرایه باشد",
ALIASES_STRING = "الیاس ها باید یک رشته باشد",
ALIASES_OPTIONAL = "الیاس ها اختیاری است",
TITLE_STRING = "عنوان باید یک رشته باشد",
TITLE_OPTIONAL = "عنوان اختیاری است",
TITLE_NOT_EMPTY = "عنوان نمیتواند خالی باشد",
EMAIL_USER_CREATED_SUCCESSFULLY = "ایمیل با موفقیت ایجاد شد",
EMAIL_USER_UPDATED_SUCCESSFULLY = "ایمیل کاربر با موفقیت به‌روزرسانی شد",
STATUS_UPDATED = "وضعیت کاربر با موفقیت تغییر کرد",
TEMPLATE_UUID = "شناسه قالب باید یک یو یو آی دی باشد",
TEMPLATE_NOT_EMPTY = "شناسه قالب نمیتواند خالی باشد",
ALIASES_EMAIL = "آدرس ایمیل الیاس باید معتبر باشد",
FORWARDERS_EMAIL_VALID = "آدرس ایمیل فوروارد باید معتبر باشد",
FORWARDERS_ARRAY = "آدرس های فوروارد باید یک آرایه باشد",
FORWARDERS_STRING = "آدرس های فوروارد باید یک رشته باشد",
PROFILE_PICTURE_URL = "آدرس تصویر پروفایل باید یک آدرس اینترنتی معتبر باشد",
USER_UPDATED_SUCCESSFULLY = "کاربر با موفقیت به روز رسانی شد",
PROFILE_PICTURE_NOT_EMPTY = "تصویر پروفایل نمیتواند خالی باشد",
PUSH_TOKEN_NOT_FOUND = "توکن پوش نامعتبر است",
PUSH_TOKEN_ADDED_SUCCESSFULLY = "توکن پوش با موفقیت اضافه شد",
NO_PUSH_TOKENS_FOUND = "توکن پوشی برای این کاربر یافت نشد",
PUSH_TOKEN_REMOVED_SUCCESSFULLY = "توکن پوش با موفقیت حذف شد",
}
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 = "فایل معتبر نیست",
UPLOAD_FILE_TOO_LARGE = "فایل بزرگتر از حد مجاز است . حداکثر حجم فایل [MAX_FILE_SIZE] مگابایت است",
UPLOAD_FILE_TYPE_NOT_SUPPORTED = "نوع فایل معتبر نیست . مجاز است : [MIME_TYPES]",
UPLOAD_FILE_SUCCESS = "فایل با موفقیت آپلود شد",
}
export const enum EmailMessage {
EMAIL_VERIFICATION = "تایید ایمیل",
EMAIL_SENDING_FAILED = "ارسال ایمیل با خطا مواجه شد",
LOGIN = "ورود به سیستم",
INVOICE = "فاکتور",
WALLET = "اعلان کیف پول",
TICKET = "تیکت پشتیبانی",
ANNOUNCEMENT = "اعلان",
PAYMENT_REMINDER = "یادآوری پرداخت",
PAYMENT_CANCELLATION = "لغو پرداخت",
EMAIL_SENDING_SUCCESS = "ارسال ایمیل با موفقیت انجام شد",
EMAIL_FORWARD_SUCCESS = "فوروارد ایمیل با موفقیت انجام شد",
EMAIL_STATUS_RETRIEVED_SUCCESSFULLY = "وضعیت ایمیل با موفقیت دریافت شد",
MESSAGES_SEARCHED_SUCCESSFULLY = "جستجوی ایمیل با موفقیت انجام شد",
MESSAGES_LISTED_SUCCESSFULLY = "لیست ایمیل ها با موفقیت دریافت شد",
MESSAGE_RETRIEVED_SUCCESSFULLY = "ایمیل با موفقیت دریافت شد",
MESSAGE_DELETED_SUCCESSFULLY = "ایمیل با موفقیت حذف شد",
USER_ID_REQUIRED = "شناسه کاربری مورد نیاز است",
USER_ID_MUST_BE_STRING = "شناسه کاربری باید یک رشته باشد",
MAILBOX_ID_REQUIRED = "شناسه میل باکس ایمیل مورد نیاز است",
MAILBOX_ID_MUST_BE_STRING = "شناسه میل باکس ایمیل باید یک رشته باشد",
QUEUE_ID_REQUIRED = "شناسه پیش‌نویس ایمیل مورد نیاز است",
QUEUE_ID_MUST_BE_STRING = "شناسه پیش‌نویس ایمیل باید یک رشته باشد",
MESSAGE_ID_MUST_BE_NUMBER = "شناسه ایمیل باید یک عدد باشد",
FROM_ADDRESS_REQUIRED = "آدرس از مورد نیاز است",
// Validation Messages for Email DTOs
FROM_ADDRESS_MUST_BE_EMAIL = "آدرس فرستنده باید یک ایمیل معتبر باشد",
// Query validation messages
SEARCH_QUERY_PARAMETER_MUST_BE_STRING = "پارامتر جستجو باید یک رشته باشد",
MESSAGE_IDS_MUST_BE_STRING = "شناسه‌های پیام باید یک رشته باشد",
THREAD_ID_MUST_BE_STRING = "شناسه رشته گفتگو باید یک رشته باشد",
OR_QUERY_MUST_BE_OBJECT = "پارامتر OR باید یک شیء باشد",
DATE_START_MUST_BE_DATE_STRING = "تاریخ شروع باید یک رشته تاریخ معتبر باشد",
DATE_END_MUST_BE_DATE_STRING = "تاریخ پایان باید یک رشته تاریخ معتبر باشد",
TO_ADDRESS_MUST_BE_STRING = "آدرس گیرنده باید یک رشته باشد",
MIN_SIZE_MUST_BE_NUMBER = "حداقل اندازه باید یک عدد باشد",
MIN_SIZE_MUST_BE_NON_NEGATIVE = "حداقل اندازه نمی‌تواند منفی باشد",
MAX_SIZE_MUST_BE_NUMBER = "حداکثر اندازه باید یک عدد باشد",
MAX_SIZE_MUST_BE_NON_NEGATIVE = "حداکثر اندازه نمی‌تواند منفی باشد",
ATTACHMENTS_FILTER_MUST_BE_BOOLEAN = "فیلتر پیوست‌ها باید بولین باشد",
FLAGGED_FILTER_MUST_BE_BOOLEAN = "فیلتر پیام‌های علامت‌دار باید بولین باشد",
UNSEEN_FILTER_MUST_BE_BOOLEAN = "فیلتر پیام‌های خوانده‌نشده باید بولین باشد",
INCLUDE_HEADERS_MUST_BE_STRING = "لیست هدرها باید یک رشته باشد",
SEARCHABLE_FILTER_MUST_BE_BOOLEAN = "فیلتر قابل جستجو باید بولین باشد",
THREAD_COUNTERS_MUST_BE_BOOLEAN = "شمارنده رشته گفتگو باید بولین باشد",
ORDER_MUST_BE_VALID = "ترتیب باید یکی از مقادیر asc یا desc باشد",
NEXT_CURSOR_MUST_BE_STRING = "نشانگر صفحه بعد باید یک رشته باشد",
PREVIOUS_CURSOR_MUST_BE_STRING = "نشانگر صفحه قبل باید یک رشته باشد",
RECIPIENT_NAME_MUST_BE_STRING = "نام گیرنده باید یک رشته باشد",
RECIPIENT_ADDRESS_REQUIRED = "آدرس گیرنده مورد نیاز است",
RECIPIENT_ADDRESS_MUST_BE_EMAIL = "آدرس گیرنده باید یک ایمیل معتبر باشد",
HEADER_KEY_REQUIRED = "کلید هدر مورد نیاز است",
HEADER_KEY_MUST_BE_STRING = "کلید هدر باید یک رشته باشد",
HEADER_VALUE_REQUIRED = "مقدار هدر مورد نیاز است",
HEADER_VALUE_MUST_BE_STRING = "مقدار هدر باید یک رشته باشد",
ATTACHMENT_FILENAME_MUST_BE_STRING = "نام فایل پیوست باید یک رشته باشد",
ATTACHMENT_CONTENT_TYPE_MUST_BE_STRING = "نوع محتوای پیوست باید یک رشته باشد",
ATTACHMENT_ENCODING_MUST_BE_STRING = "کدگذاری پیوست باید یک رشته باشد",
ATTACHMENT_CONTENT_TRANSFER_ENCODING_MUST_BE_STRING = "کدگذاری انتقال محتوای پیوست باید یک رشته باشد",
ATTACHMENT_CONTENT_DISPOSITION_INVALID = "نحوه نمایش پیوست باید inline یا attachment باشد",
ATTACHMENT_CONTENT_REQUIRED = "محتوای پیوست مورد نیاز است",
ATTACHMENT_CONTENT_MUST_BE_STRING = "محتوای پیوست باید یک رشته باشد",
ATTACHMENT_CID_MUST_BE_STRING = "شناسه محتوای پیوست باید یک رشته باشد",
DRAFT_MAILBOX_REQUIRED = "شناسه صندوق پیش‌نویس مورد نیاز است",
DRAFT_MAILBOX_MUST_BE_STRING = "شناسه صندوق پیش‌نویس باید یک رشته باشد",
DRAFT_ID_REQUIRED = "شناسه پیش‌نویس مورد نیاز است",
DRAFT_ID_MUST_BE_NUMBER = "شناسه پیش‌نویس باید یک عدد باشد",
ENVELOPE_FROM_REQUIRED = "آدرس فرستنده در envelope مورد نیاز است",
ENVELOPE_TO_REQUIRED = "آدرس گیرنده در envelope مورد نیاز است",
ENVELOPE_TO_MUST_BE_ARRAY = "آدرس گیرنده در envelope باید یک آرایه باشد",
MAILBOX_MUST_BE_STRING = "شناسه صندوق باید یک رشته باشد",
REPLY_TO_MUST_BE_VALID = "آدرس پاسخ باید معتبر باشد",
TO_ADDRESSES_REQUIRED = "آدرس‌های گیرنده مورد نیاز است",
TO_ADDRESSES_MUST_BE_ARRAY = "آدرس‌های گیرنده باید یک آرایه باشد",
CC_ADDRESSES_MUST_BE_ARRAY = "آدرس‌های کپی کربن باید یک آرایه باشد",
BCC_ADDRESSES_MUST_BE_ARRAY = "آدرس‌های کپی مخفی باید یک آرایه باشد",
HEADERS_MUST_BE_ARRAY = "هدرهای سفارشی باید یک آرایه باشد",
SUBJECT_MUST_BE_STRING = "موضوع ایمیل باید یک رشته باشد",
TEXT_CONTENT_MUST_BE_STRING = "محتوای متنی باید یک رشته باشد",
HTML_CONTENT_MUST_BE_STRING = "محتوای HTML باید یک رشته باشد",
ATTACHMENTS_MUST_BE_ARRAY = "پیوست‌ها باید یک آرایه باشد",
SESSION_ID_MUST_BE_STRING = "شناسه جلسه باید یک رشته باشد",
IP_ADDRESS_MUST_BE_STRING = "آدرس IP باید یک رشته باشد",
IS_DRAFT_MUST_BE_BOOLEAN = "وضعیت پیش‌نویس باید بولین باشد",
SEND_TIME_MUST_BE_DATE = "زمان ارسال باید یک تاریخ معتبر باشد",
UPLOAD_ONLY_MUST_BE_BOOLEAN = "حالت فقط آپلود باید بولین باشد",
ENVELOPE_MUST_BE_VALID = "envelope باید معتبر باشد",
// Message operations
MESSAGE_MARKED_AS_SEEN_SUCCESSFULLY = "پیام با موفقیت به عنوان خوانده شده علامت گذاری شد",
ALL_MESSAGES_MARKED_AS_READ_SUCCESSFULLY = "تمام پیام ها با موفقیت به عنوان خوانده شده علامت گذاری شدند",
MESSAGE_MARK_AS_SEEN_FAILED = "علامت گذاری پیام با خطا مواجه شد",
SENT_MESSAGES_RETRIEVED_SUCCESSFULLY = "پیام های ارسالی با موفقیت دریافت شد",
SENT_MESSAGES_RETRIEVAL_FAILED = "دریافت پیام های ارسالی با خطا مواجه شد",
TRASH_MESSAGES_RETRIEVED_SUCCESSFULLY = "پیام های حذف شده با موفقیت دریافت شد",
TRASH_MESSAGES_RETRIEVAL_FAILED = "دریافت پیام های حذف شده با خطا مواجه شد",
DRAFT_UPDATED_SUCCESSFULLY = "پیش نویس با موفقیت به روز رسانی شد",
DRAFT_SENT_SUCCESSFULLY = "پیش نویس با موفقیت ارسال شد",
DRAFT_DELETED_SUCCESSFULLY = "پیش نویس با موفقیت حذف شد",
MESSAGE_MOVED_TO_ARCHIVE_SUCCESSFULLY = "پیام با موفقیت به آرشیو منتقل شد",
MESSAGE_MOVED_TO_FAVORITE_SUCCESSFULLY = "پیام با موفقیت به علاقه مندی ها منتقل شد",
MESSAGE_MOVED_TO_TRASH_SUCCESSFULLY = "پیام با موفقیت به سطل زباله منتقل شد",
MESSAGE_MOVED_TO_JUNK_SUCCESSFULLY = "پیام با موفقیت به جانک منتقل شد",
MESSAGE_MOVED_TO_INBOX_FROM_ARCHIVE_SUCCESSFULLY = "پیام با موفقیت از آرشیو به صندوق ورودی منتقل شد",
MESSAGE_MOVED_TO_INBOX_FROM_FAVORITE_SUCCESSFULLY = "پیام با موفقیت از علاقه مندی ها به صندوق ورودی منتقل شد",
MESSAGE_MOVED_TO_INBOX_FROM_TRASH_SUCCESSFULLY = "پیام با موفقیت از سطل زباله به صندوق ورودی منتقل شد",
MESSAGE_MOVED_TO_INBOX_FROM_JUNK_SUCCESSFULLY = "پیام با موفقیت از جانک به صندوق ورودی منتقل شد",
MESSAGE_NOT_FOUND = "پیام یافت نشد",
// Bulk action messages
BULK_ACTION_COMPLETED_SUCCESSFULLY = "[count] پیام با موفقیت [action] شد",
BULK_ACTION_PARTIALLY_COMPLETED = "[successful] از [total] پیام [action] شد. [failed] پیام با خطا مواجه شد",
BULK_ACTION_FAILED = "هیچ پیامی [action] نشد. تمام [total] تلاش ناموفق بود",
// Bulk action display names
BULK_ACTION_SEEN = "خوانده شده علامت‌گذاری",
BULK_ACTION_DELETE = "حذف",
BULK_ACTION_ARCHIVE = "آرشیو",
BULK_ACTION_UNARCHIVE = "از آرشیو خارج",
BULK_ACTION_FAVORITE = "به علاقه مندی ها منتقل",
BULK_ACTION_UNFAVORITE = "از علاقه مندی ها حذف",
BULK_ACTION_JUNK = "به جانک منتقل",
BULK_ACTION_NOTJUNK = "از جانک از حذف",
BULK_ACTION_TRASH = "به سطل زباله منتقل",
BULK_ACTION_RESTORE = "از سطل زباله بازیابی",
BULK_ACTION_PROCESSED = "پردازش",
USER_NOT_FOUND = "کاربر یافت نشد",
PRIORITY_MUST_BE_ONE_OF_HIGH_NORMAL_LOW = "اولویت باید یکی از مقادیر high, normal, low باشد",
SUBJECT_REQUIRED = "موضوع ایمیل مورد نیاز است",
ALL_MESSAGES_DELETED_SUCCESSFULLY = "همه پیام ها با موفقیت حذف شدند",
MAILBOX_ID_MUST_BE_MONGO_ID = "شناسه میل باکس ایمیل باید یک آی دی معتبر باشد",
MESSAGE_MARKED_AS_UNSEEN_SUCCESSFULLY = "پیام با موفقیت به عنوان خوانده نشده علامت گذاری شد",
MESSAGE_MARK_AS_UNSEEN_FAILED = "علامت گذاری پیام به عنوان خوانده نشده با خطا مواجه شد",
ATTACHMENT_ID_MUST_BE_STRING = "شناسه پیوست باید یک رشته باشد",
IS_FORWARD_MUST_BE_BOOLEAN = "IS_FORWARD_MUST_BE_BOOLEAN",
// Favorite/Star messages (Gmail-like behavior)
MESSAGE_MARKED_AS_FAVORITE_SUCCESSFULLY = "پیام با موفقیت به عنوان مورد علاقه علامت گذاری شد",
MESSAGE_REMOVED_FROM_FAVORITE_SUCCESSFULLY = "پیام با موفقیت از مورد علاقه حذف شد",
TRASH_EMPTYED_SUCCESSFULLY = "سطل زباله با موفقیت خالی شد",
SPAM_EMPTYED_SUCCESSFULLY = "همه پیام های اسپم با موفقیت حذف شدند",
SAVE_DRAFT_SUCCESS = "پیش نویس با موفقیت ذخیره شد",
HTML_CONTENT_REQUIRED = "محتوای HTML مورد نیاز است",
TEXT_CONTENT_REQUIRED = "TEXT_CONTENT_REQUIRED",
}
export const enum WebSocketMessage {
// Connection Messages
WS_CONNECTION_ESTABLISHED = "اتصال وب سوکت با موفقیت برقرار شد",
WS_CONNECTION_FAILED = "اتصال وب سوکت ناموفق بود",
WS_AUTHENTICATION_REQUIRED = "احراز هویت وب سوکت مورد نیاز است",
WS_AUTHENTICATION_FAILED = "احراز هویت وب سوکت ناموفق بود",
WS_AUTHENTICATION_SUCCESS = "احراز هویت وب سوکت با موفقیت انجام شد",
WS_INVALID_TOKEN = "توکن وب سوکت نامعتبر است",
WS_TOKEN_EXPIRED = "توکن وب سوکت منقضی شده است",
// Room Management Messages
JOINED_ROOM_SUCCESS = "با موفقیت به اتاق متصل شدید",
LEFT_ROOM_SUCCESS = "با موفقیت از اتاق خارج شدید",
ROOM_NOT_FOUND = "اتاق یافت نشد",
UNAUTHORIZED_ROOM_ACCESS = "دسترسی غیرمجاز به اتاق",
// Session Management Messages
WS_SESSION_CREATED = "جلسه وب سوکت ایجاد شد",
WS_SESSION_JOINED = "به جلسه وب سوکت پیوستید",
WS_SESSION_LEFT = "از جلسه وب سوکت خارج شدید",
WS_SESSION_EXPIRED = "جلسه وب سوکت منقضی شده است",
WS_SESSION_NOT_FOUND = "جلسه وب سوکت یافت نشد",
// Error Messages
WS_CONNECTION_ERROR = "خطا در اتصال وب سوکت",
INVALID_EVENT = "رویداد نامعتبر",
INVALID_DATA = "داده نامعتبر",
WS_RATE_LIMIT_EXCEEDED = "تعداد درخواست‌های وب سوکت از حد مجاز بیشتر است",
INTERNAL_ERROR = "خطای داخلی سرور",
// Notification Messages
NEW_MESSAGE_NOTIFICATION = "پیام جدید دریافت شد",
MESSAGE_STATUS_UPDATED = "وضعیت پیام به‌روزرسانی شد",
MESSAGE_MOVED_NOTIFICATION = "پیام جابجا شد",
MESSAGE_DELETED_NOTIFICATION = "پیام حذف شد",
// General Messages
OPERATION_SUCCESS = "عملیات با موفقیت انجام شد",
OPERATION_FAILED = "عملیات ناموفق بود",
PERMISSION_DENIED = "مجوز دسترسی ندارید",
RESOURCE_NOT_FOUND = "منبع یافت نشد",
// Authentication errors
AUTHENTICATION_REQUIRED = "احراز هویت ضروری است",
INVALID_TOKEN = "توکن احراز هویت نامعتبر است",
TOKEN_EXPIRED = "توکن منقضی شده است",
UNAUTHORIZED_ACCESS = "دسترسی غیرمجاز",
USER_NOT_AUTHENTICATED = "کاربر احراز هویت نشده است",
// Connection errors
CONNECTION_FAILED = "اتصال برقرار نشد",
WEBSOCKET_ERROR = "خطا در ارتباط WebSocket",
CONNECTION_TIMEOUT = "زمان اتصال به پایان رسید",
CONNECTION_REFUSED = "اتصال رد شد",
// Session errors
SESSION_NOT_FOUND = "جلسه چت یافت نشد",
SESSION_CREATION_FAILED = "ایجاد جلسه چت با شکست مواجه شد",
SESSION_ACCESS_DENIED = "دسترسی به جلسه چت مجاز نیست",
SESSION_EXPIRED = "جلسه چت منقضی شده است",
SESSION_ALREADY_EXISTS = "جلسه چت قبلاً وجود دارد",
// Message errors
MESSAGE_TOO_LONG = "پیام از حداکثر طول مجاز تجاوز کرده است",
MESSAGE_EMPTY = "پیام نمی‌تواند خالی باشد",
MESSAGE_SEND_FAILED = "ارسال پیام با شکست مواجه شد",
INVALID_MESSAGE_FORMAT = "فرمت پیام نامعتبر است",
// Rate limiting
RATE_LIMIT_EXCEEDED = "تعداد پیام‌های ارسالی بیش از حد مجاز است",
TOO_MANY_CONNECTIONS = "تعداد اتصالات بیش از حد مجاز است",
// Service errors
LLM_SERVICE_ERROR = "سرویس هوش مصنوعی موقتاً در دسترس نیست",
SERVICE_UNAVAILABLE = "سرویس موقتاً در دسترس نیست",
INTERNAL_SERVER_ERROR = ".خطای داخلی سرور",
// Validation errors
INVALID_SESSION_ID = "شناسه جلسه نامعتبر است",
INVALID_USER_ID = "شناسه کاربر نامعتبر است",
INVALID_MESSAGE_ID = "شناسه پیام نامعتبر است",
REQUIRED_FIELD_MISSING = "فیلد ضروری موجود نیست",
// Success messages
AUTHENTICATED = "احراز هویت موفقیت‌آمیز",
SESSION_CREATED = "جلسه چت با موفقیت ایجاد شد",
CHAT_JOINED = "با موفقیت به چت متصل شدید",
MESSAGE_SENT = "پیام ارسال شد",
CONNECTION_ESTABLISHED = "اتصال برقرار شد",
}
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 = "توکن تایید دامنه مورد نیاز است",
INSUFFICIENT_QUOTA = "حجم شما برای ساخت ایمیل کافی نمی باشد، لطفا حجم را افزایش دهید",
QUOTA_DOWNGRADE_NOT_ALLOWED = "کاهش حجم ایمیل مجاز نیست، فقط می‌توانید حجم را افزایش دهید",
QUOTA_AMOUNT_REQUIRED = "مقدار حجم مورد نیاز است",
QUOTA_AMOUNT_MUST_BE_NUMBER = "مقدار حجم باید یک عدد باشد",
QUOTA_AMOUNT_MUST_BE_POSITIVE = "مقدار حجم باید مثبت باشد",
CALLBACK_URL_MUST_BE_STRING = "آدرس بازگشت باید یک رشته باشد",
CALLBACK_URL_INVALID = "آدرس بازگشت معتبر نیست",
PURCHASE_DESCRIPTION_MUST_BE_STRING = "توضیحات خرید باید یک رشته باشد",
QUOTA_PURCHASE_SUCCESS = "درخواست خرید حجم با موفقیت ثبت شد",
QUOTA_PURCHASE_FAILED = "خرید حجم با خطا مواجه شد",
PAYMENT_GATEWAY_ERROR = "خطا در اتصال به درگاه پرداخت",
QUOTA_UPDATED = "حجم ایمیل با موفقیت به روز شد",
USER_DOES_NOT_HAVE_ACCESS_TO_SERVICE = "کاربر دسترسی به این سرویس را ندارد",
}
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 = "این کسب و کار قبلا دامنه ای دارد",
CAN_NOT_USE_THIS_DOMAIN = "این دامنه نمیتواند ثبت شود",
}
export const enum MailServerMessage {
FAILED_TO_CREATE_ACCOUNT = "خطا در ایجاد ایمیل",
FAILED_TO_CREATE_DKIM_KEY = "خطا در ایجاد کلید DKIM",
}
export const enum DnsRecordMessage {
DNS_RECORD_NOT_FOUND = "رکورد DNS یافت نشد",
DNS_RECORD_DELETED = "رکورد DNS با موفقیت حذف شد",
}
export const enum RoleMessage {
NOT_FOUND = "نقش یافت نشد",
}
export const enum SignatureMessage {
NAME_NOT_EMPTY = "نام امضاء نمیتواند خالی باشد",
NAME_MAX_LENGTH = "نام امضاء نمیتواند بیشتر از حداکثر طول مجاز باشد",
NAME_STRING = "نام امضاء باید یک رشته باشد",
TEXT_CONTENT_NOT_EMPTY = "محتوای متنی امضاء نمیتواند خالی باشد",
TEXT_CONTENT_STRING = "محتوای متنی امضاء باید یک رشته باشد",
TEXT_CONTENT_MAX_LENGTH = "محتوای متنی امضاء نمیتواند بیشتر از حداکثر طول مجاز باشد",
IS_ACTIVE_NOT_EMPTY = "وضعیت فعال بودن نمیتواند خالی باشد",
IS_ACTIVE_BOOLEAN = "وضعیت فعال بودن باید بولین باشد",
IS_DEFAULT_NOT_EMPTY = "وضعیت پیش فرض نمیتواند خالی باشد",
IS_DEFAULT_BOOLEAN = "وضعیت پیش فرض باید بولین باشد",
CREATED = "امضاء با موفقیت ایجاد شد",
NOT_FOUND = "امضاء یافت نشد",
DELETED = "امضاء با موفقیت حذف شد",
TOGGLE_ACTIVE = "وضعیت امضاء با موفقیت تغییر کرد",
}
export const enum TemplateMessage {
TEMPLATE_NOT_FOUND = "قالب یافت نشد",
TEMPLATE_CREATED_SUCCESSFULLY = "قالب با موفقیت ایجاد شد",
TEMPLATE_UPDATED_SUCCESSFULLY = "قالب با موفقیت به روزرسانی شد",
TEMPLATE_DELETED_SUCCESSFULLY = "قالب با موفقیت حذف شد",
TEMPLATE_RETRIEVED_SUCCESSFULLY = "قالب با موفقیت دریافت شد",
TEMPLATES_LISTED_SUCCESSFULLY = "لیست قالب ها با موفقیت دریافت شد",
TEMPLATE_NAME_REQUIRED = "نام قالب الزامی است",
TEMPLATE_NAME_STRING = "نام قالب باید یک رشته باشد",
TEMPLATE_NAME_MAX_LENGTH = "نام قالب نمیتواند بیشتر از ۲۵۵ کاراکتر باشد",
TEMPLATE_STRUCTURE_REQUIRED = "ساختار قالب الزامی است",
TEMPLATE_STRUCTURE_VALID = "ساختار قالب باید معتبر باشد",
TEMPLATE_RAW_HTML_STRING = "HTML خام باید یک رشته باشد",
TEMPLATE_BUSINESS_ID_REQUIRED = "شناسه کسب و کار الزامی است",
TEMPLATE_BUSINESS_ID_UUID = "شناسه کسب و کار باید یک UUID معتبر باشد",
TEMPLATE_ID_REQUIRED = "شناسه قالب الزامی است",
TEMPLATE_ID_UUID = "شناسه قالب باید یک UUID معتبر باشد",
TEMPLATE_ACCESS_DENIED = "شما دسترسی به این قالب را ندارید",
TEMPLATE_BUSINESS_NOT_FOUND = "کسب و کار مالک قالب یافت نشد",
SEARCH_STRING = "نام برای جستجو باید یک رشته باشد",
TEMPLATE_SELECTED_SUCCESSFULLY = "وضعیت قالب با موفقیت تغییر کرد",
TEMPLATE_THUMBNAIL_URL_VALID = "آدرس تصویر قالب باید یک آدرس معتبر باشد",
TEMPLATE_RAW_HTML_REQUIRED = "HTML خام قالب الزامی است",
}
export const enum NotificationMessage {
TITLE_IS_REQUIRED = "عنوان اعلان الزامی است",
TITLE_STRING = "عنوان اعلان باید یک رشته باشد",
MESSAGE_IS_REQUIRED = "متن اعلان الزامی است",
MESSAGE_STRING = "متن اعلان باید یک رشته باشد",
USERID_IS_REQUIRED = "شناسه کاربر الزامی است",
USERID_IS_STRING = "شناسه کاربر باید یک رشته باشد",
USERID_IS_UUID = "شناسه کاربر باید یک UUID معتبر باشد",
TYPE_IS_REQUIRED = "نوع اعلان الزامی است",
TYPE_ENUM = "نوع اعلان باید یک enum معتبر باشد",
NOTIFICATION_NOT_FOUND_OR_ACCESS_DENIED = "اعلان یافت نشد یا دسترسی آن را ندارید",
LOGIN_MESSAGE = "ورود با موفقیت انجام شد در تاریخ [loginDate]",
LOGIN = "ورود",
NEW_EMAIL = "ایمیل جدید",
NEW_EMAIL_MESSAGE = "یک ایمیل جدید با موضوع [subject] دریافت شد",
CHANGE_PASSWORD_MESSAGE = "رمز عبور شما با موفقیت تغییر کرد",
CHANGE_PASSWORD = "تغییر رمز عبور",
}
export const enum SettingMessageEnum {
SMS_MUST_BE_BOOLEAN = "تنظیمات ارسال پیامک باید یک boolean باشد",
EMAIL_MUST_BE_BOOLEAN = "تنظیمات ارسال ایمیل باید یک boolean باشد",
NOTIFICATION_NOT_FOUND_OR_ACCESS_DENIED = "اعلان یافت نشد یا دسترسی آن را ندارید",
}
export const enum AiMessage {
NO_RESPONSE_FROM_AI_MODEL = "خطا در دریافت پاسخ از مدل AI",
MESSAGE_NOT_FOUND_OR_ACCESS_DENIED = "ایمیل یافت نشد یا دسترسی آن را ندارید",
EMAIL_CONTENT_IS_EMPTY_OR_COULD_NOT_BE_EXTRACTED = "محتوای ایمیل خالی است یا قابل استخراج نیست",
DESCRIPTION_IS_REQUIRED = "شرح و هدف قالب الزامی است",
DESCRIPTION_MUST_BE_A_STRING = "شرح قالب باید یک رشته باشد",
DESCRIPTION_MUST_BE_AT_LEAST_10_CHARACTERS = "شرح قالب باید حداقل ۱۰ کاراکتر باشد",
DESCRIPTION_MUST_BE_LESS_THAN_500_CHARACTERS = "شرح قالب نمی‌تواند بیشتر از ۵۰۰ کاراکتر باشد",
THEME_MUST_BE_A_VALID_VALUE = "تم قالب باید یکی از مقادیر مجاز باشد",
STYLE_MUST_BE_A_VALID_VALUE = "سبک قالب باید یکی از مقادیر مجاز باشد",
PURPOSE_MUST_BE_A_VALID_VALUE = "هدف قالب باید یکی از مقادیر مجاز باشد",
TEMPLATE_NAME_MUST_BE_A_STRING = "نام قالب باید یک رشته باشد",
TEMPLATE_NAME_MUST_BE_LESS_THAN_100_CHARACTERS = "نام قالب نمی‌تواند بیشتر از ۱۰۰ کاراکتر باشد",
PREFERRED_COLORS_MUST_BE_A_STRING = "رنگ‌های ترجیحی باید یک رشته باشد",
PREFERRED_COLORS_MUST_BE_LESS_THAN_200_CHARACTERS = "رنگ‌های ترجیحی نمی‌تواند بیشتر از ۲۰۰ کاراکتر باشد",
PREFERRED_COLORS_IS_REQUIRED = "رنگ‌های ترجیحی الزامی است",
}
export const enum TwoFactorMessage {
TWO_FACTOR_SETUP_INITIATED = "راه‌اندازی احراز هویت دو مرحله‌ای با موفقیت آغاز شد",
TWO_FACTOR_ENABLED_SUCCESSFULLY = "احراز هویت دو مرحله‌ای با موفقیت فعال شد",
TWO_FACTOR_DISABLED_SUCCESSFULLY = "احراز هویت دو مرحله‌ای با موفقیت غیرفعال شد",
TWO_FACTOR_SETUP_FAILED = "راه‌اندازی احراز هویت دو مرحله‌ای ناموفق بود",
TWO_FACTOR_ENABLE_FAILED = "فعال‌سازی احراز هویت دو مرحله‌ای ناموفق بود",
TWO_FACTOR_TOKEN_VERIFIED_SUCCESSFULLY = "کد احراز هویت دو مرحله‌ای با موفقیت تأیید شد",
TWO_FACTOR_TOKEN_VERIFIED_FAILED = "کد احراز هویت دو مرحله‌ای نامعتبر می‌باشد",
TWO_FACTOR_NOT_ENABLED = "احراز هویت دو مرحله‌ای فعال نیست",
TWO_FACTOR_BACKUP_CODES_GENERATED_SUCCESSFULLY = "کدهای پشتیبان با موفقیت تولید شدند",
TWO_FACTOR_BACKUP_CODE_VERIFIED_SUCCESSFULLY = "کد پشتیبان با موفقیت تأیید شد",
TWO_FACTOR_BACKUP_CODE_VERIFIED_FAILED = "کد پشتیبان نامعتبر است",
TWO_FACTOR_TOKEN_REQUIRED = "کد احراز هویت دو مرحله‌ای الزامی است",
TWO_FACTOR_TOKEN_INVALID = "کد احراز هویت دو مرحله‌ای نامعتبر است",
}
+50
View File
@@ -0,0 +1,50 @@
// guards/jwt-auth.guard.ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { Request } from 'express';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { AuthEntityType, AuthRequest, JwtPayload } from './auth.guard';
@Injectable()
export class AdminAuthGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<AuthRequest>();
try {
const secret = this.configService.get<string>('JWT_SECRET');
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException();
}
try {
const payload = await this.jwtService.verifyAsync<JwtPayload>(token, {
secret,
});
if (payload.type !== AuthEntityType.ADMIN) {
throw new UnauthorizedException('Access denied. Admin rights required.');
}
// 💡 We're assigning the payload to the request object here
// so that we can access it in our route handlers
request['user'] = payload;
} catch {
throw new UnauthorizedException();
}
return true;
} catch (err) {
console.log('error in AuthGuard', err);
throw new UnauthorizedException('Invalid or expired token');
}
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
+61
View File
@@ -0,0 +1,61 @@
// guards/jwt-auth.guard.ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { Request } from 'express';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
export enum AuthEntityType {
USER = 'user',
ADMIN = 'admin',
}
export interface AuthRequest extends Request {
user: {
sub: string; // userId
};
}
export interface JwtPayload {
sub: string; // The userId
type: AuthEntityType;
}
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<AuthRequest>();
try {
const secret = this.configService.get<string>('JWT_SECRET');
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException();
}
try {
const payload = await this.jwtService.verifyAsync<JwtPayload>(token, {
secret,
});
if (payload.type !== AuthEntityType.USER) {
throw new UnauthorizedException('Access denied. User rights required.');
}
// 💡 We're assigning the payload to the request object here
// so that we can access it in our route handlers
request['user'] = payload;
} catch {
throw new UnauthorizedException();
}
return true;
} catch (err) {
console.log('error in AuthGuard', err);
throw new UnauthorizedException('Invalid or expired token');
}
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
@@ -0,0 +1,9 @@
export interface PaginatedResult<T> {
data: T[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
@@ -0,0 +1,33 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
import { Request, Response } from 'express';
@Catch(HttpException)
export class HttpErrorFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
const exceptionResponse = exception.getResponse();
let messages: (string | object)[] = [];
if (typeof exceptionResponse === 'string') {
messages = [exceptionResponse];
} else if (typeof exceptionResponse === 'object' && exceptionResponse !== null && 'message' in exceptionResponse) {
messages = Array.isArray(exceptionResponse.message)
? (exceptionResponse.message as string[])
: [exceptionResponse.message as string];
} else messages = ['Unexpected error'];
const errorResponse = {
success: false,
timestamp: new Date().toISOString(),
path: request.url,
messages,
};
response.status(status).json(errorResponse);
}
}
@@ -0,0 +1,15 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { map, Observable } from 'rxjs';
@Injectable()
export class ResponseInterceptor<T> implements NestInterceptor<T, any> {
intercept(context: ExecutionContext, next: CallHandler<T>): Observable<any> {
return next.handle().pipe(
map(data => ({
success: true,
data,
timestamp: new Date().toISOString(),
})),
);
}
}
@@ -0,0 +1,9 @@
import type { ValueProvider } from '@nestjs/common';
import { Logger } from '@nestjs/common';
import { MIKRO_ORM_QUERY_LOGGER } from '../constants';
export const mikroOrmQueryLoggerProvider: ValueProvider = {
provide: MIKRO_ORM_QUERY_LOGGER, //
useValue: new Logger('mikro-orm'),
};
+43
View File
@@ -0,0 +1,43 @@
import KeyvRedis from '@keyv/redis';
import Keyv from 'keyv';
import type { CacheModuleAsyncOptions } from '@nestjs/cache-manager';
import { ConfigService } from '@nestjs/config';
import { Logger } from '@nestjs/common'; // 1. Import Logger
export function cacheConfig(): CacheModuleAsyncOptions {
// 2. Create a logger instance
const logger = new Logger('CacheConfig');
return {
isGlobal: true,
inject: [ConfigService],
useFactory: (configService: ConfigService) => {
const ttl = configService.get<number>('CACHE_TTL', 120);
const namespace = configService.get<string>('CACHE_NAMESPACE', 'app-cache');
const redisUri = configService.getOrThrow<string>('REDIS_URI');
// 3. Create the KeyvRedis store first
const keyvRedisStore = new KeyvRedis(redisUri);
// 4. Attach event listeners
keyvRedisStore.client.on('connect', () => {
logger.log('✅ Redis connected successfully.');
});
keyvRedisStore.client.on('error', (error) => {
logger.error('❌ Redis connection error:', error);
});
// 5. Pass the store to Keyv
const store = new Keyv({
store: keyvRedisStore, // Use the instance
namespace,
ttl: ttl * 1000,
});
return {
store,
ttl,
};
},
};
}
+16
View File
@@ -0,0 +1,16 @@
import type { HttpModuleAsyncOptions } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
export function httpConfig(): HttpModuleAsyncOptions {
return {
inject: [ConfigService],
global: true,
useFactory: (configService: ConfigService) => {
return {
timeout: configService.getOrThrow<number>('AXIOS_TIMEOUT'),
headers: { 'Content-Type': 'application/json' },
global: true,
};
},
};
}
+96
View File
@@ -0,0 +1,96 @@
import { defineConfig, PostgreSqlDriver } from '@mikro-orm/postgresql';
import * as dotenv from 'dotenv';
// 1. Load environment variables from your .env file
dotenv.config();
// 2. Read variables directly from process.env
const DB_PASS = process.env.DB_PASS;
const DB_USER = process.env.DB_USER;
const DB_HOST = process.env.DB_HOST;
const DB_PORT = process.env.DB_PORT ? +process.env.DB_PORT : 5432;
const DB_NAME = process.env.DB_NAME;
const isProduction = process.env.NODE_ENV === 'production';
// 3. Add checks to ensure variables are loaded
if (!DB_PASS || !DB_USER || !DB_HOST || !DB_PORT || !DB_NAME) {
throw new Error('One or more database environment variables are not set.');
}
const encodedPassword = encodeURIComponent(DB_PASS);
// 4. Export the result of defineConfig as the default
export default defineConfig({
entities: ['dist/**/*.entity.js'],
entitiesTs: ['src/**/*.entity.ts'],
driver: PostgreSqlDriver,
dbName: DB_NAME,
debug: !isProduction,
clientUrl: `postgres://${DB_USER}:${encodedPassword}@${DB_HOST}:${DB_PORT}`,
ensureDatabase: isProduction
? false
: {
forceCheck: true,
create: true,
schema: 'update',
},
forceUtcTimezone: true,
pool: {
min: isProduction ? 5 : 2,
max: isProduction ? 20 : 10,
idleTimeoutMillis: 30000,
acquireTimeoutMillis: 30000,
reapIntervalMillis: 1000,
createTimeoutMillis: 15000,
destroyTimeoutMillis: 5000,
},
// 5. Use console for CLI logging, not the injected Nest logger
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
logger: isProduction ? console.log.bind(console) : console.debug.bind(console),
schemaGenerator: {
disableForeignKeys: false,
},
migrations: {
path: './database/migrations',
pathTs: './database/migrations',
tableName: 'migrations',
transactional: true,
allOrNothing: true,
dropTables: false,
safe: true,
emit: 'ts',
},
connect: true,
allowGlobalContext: true,
driverOptions: {
connection: {
keepAlive: true,
keepAliveInitialDelayMillis: 10000,
statement_timeout: 60000,
query_timeout: 60000,
idle_in_transaction_session_timeout: 60000,
connectionTimeoutMillis: 10000,
application_name: DB_NAME,
},
connectionRetries: 5,
connectionRetryDelay: 3000,
validateConnection: (connection: any) => {
try {
return !!(connection && !connection.closed);
} catch (e: unknown) {
// Use console.error directly
console.error(`Connection validation failed: ${(e as Error).message}`);
return false;
}
},
poolErrorHandler: (err: Error) => {
// Use console.error directly
console.error(`PostgreSQL pool error: ${err.message}`);
if (err.message.includes('ECONNRESET') || err.message.includes('Connection terminated unexpectedly')) {
console.warn('Connection reset detected, will attempt to reconnect');
return true;
}
return false;
},
},
});
+109
View File
@@ -0,0 +1,109 @@
import type { MikroOrmModuleAsyncOptions } from '@mikro-orm/nestjs';
import { ConfigService } from '@nestjs/config';
import { PostgreSqlDriver } from '@mikro-orm/postgresql';
// import { defineConfig } from '@mikro-orm/postgresql';
import type { Logger } from '@nestjs/common';
import { MIKRO_ORM_QUERY_LOGGER } from '../common/constants';
import { mikroOrmQueryLoggerProvider } from '../common/providers/mikro-orm-logger.provider';
const dataBaseConfig: MikroOrmModuleAsyncOptions = {
// entities: ['dist/**/*.entity.js'],
// entitiesTs: ['src/**/*.entity.ts'],
// dbName: 'your_db_name',
// type: 'postgresql',
// user: 'your_user',
// password: 'your_password',
// host: 'localhost',
// port: 5432,
// debug: true,
inject: [ConfigService, MIKRO_ORM_QUERY_LOGGER],
providers: [mikroOrmQueryLoggerProvider],
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 DB_NAME = configService.getOrThrow<string>('DB_NAME');
const encodedPassword = encodeURIComponent(DB_PASS);
const isProduction = configService.getOrThrow<string>('NODE_ENV') === 'production';
return ({
// entities: ['dist/**/*.entity.js'],
entitiesTs: ['src/**/*.entity.ts'],
driver: PostgreSqlDriver,
autoLoadEntities: true,
dbName: DB_NAME,
debug: !isProduction,
clientUrl: `postgres://${DB_USER}:${encodedPassword}@${DB_HOST}:${DB_PORT}`,
ensureDatabase: isProduction
? false
: {
forceCheck: true,
create: true,
schema: 'update',
},
forceUtcTimezone: true,
pool: {
min: isProduction ? 5 : 2,
max: isProduction ? 20 : 10,
idleTimeoutMillis: 30000,
acquireTimeoutMillis: 30000,
reapIntervalMillis: 1000,
createTimeoutMillis: 15000,
destroyTimeoutMillis: 5000,
},
logger: isProduction ? message => logger.log(message) : message => logger.debug(message),
schemaGenerator: {
// createForeignKey: !isProduction,
disableForeignKeys: false,
// createIndex: true,
},
migrations: {
path: './database/migrations',
pathTs: './database/migrations',
tableName: 'migrations',
transactional: true,
allOrNothing: true,
dropTables: false,
safe: true,
emit: 'ts',
},
connect: true,
allowGlobalContext: true,
driverOptions: {
connection: {
keepAlive: true,
keepAliveInitialDelayMillis: 10000,
statement_timeout: 60000,
query_timeout: 60000,
idle_in_transaction_session_timeout: 60000,
connectionTimeoutMillis: 10000,
application_name: configService.getOrThrow<string>('DB_NAME'),
},
connectionRetries: 5,
connectionRetryDelay: 3000,
validateConnection: (connection: any) => {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
return !!(connection && !connection.closed);
} catch (e: unknown) {
logger.error(`Connection validation failed: ${(e as Error).message}`);
return false;
}
},
poolErrorHandler: (err: Error) => {
logger.error(`PostgreSQL pool error: ${err.message}`);
if (err.message.includes('ECONNRESET') || err.message.includes('Connection terminated unexpectedly')) {
logger.warn('Connection reset detected, will attempt to reconnect');
return true;
}
return false;
},
},
});
},
};
export default dataBaseConfig;
+22
View File
@@ -0,0 +1,22 @@
import type { INestApplication } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
export function getSwaggerConfig(app: INestApplication) {
const config = new DocumentBuilder()
.setTitle('Tahavol API')
.setDescription('API documentation for Tahavol backend')
.setVersion('1.0')
.addBearerAuth() // optional: for JWT endpoints
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document, {
swaggerOptions: {
persistAuthorization: true,
displayRequestDuration: true,
filter: true,
showExtensions: true,
},
});
}
+49
View File
@@ -0,0 +1,49 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ConfigService } from '@nestjs/config';
import { Logger, ValidationPipe } from '@nestjs/common';
import { getSwaggerConfig } from './config/swagger.config';
import multipart from '@fastify/multipart';
// 👈 Import the Fastify application type
import { type NestFastifyApplication, FastifyAdapter } from '@nestjs/platform-fastify';
async function bootstrap() {
const logger = new Logger('APP');
// 1. Create the application using the Fastify adapter
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(), // Instantiate the FastifyAdapter
// Optional: Pass the custom logger (like your 'APP' logger) to NestFactory
{ logger: ['error', 'warn', 'log', 'debug', 'verbose'] },
);
await app.register(multipart);
app.useGlobalPipes(new ValidationPipe());
const configService = app.get<ConfigService>(ConfigService);
// Ensure the port is handled correctly, use 4000 as fallback if needed
const APP_PORT = configService.getOrThrow<number>('APP_PORT') ?? 4000;
// 2. Swagger Integration Note
// Since you are using platform-fastify, you must ensure your getSwaggerConfig
// function is configured to use the Fastify platform option if necessary.
getSwaggerConfig(app);
app.enableCors({
origin: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
credentials: true,
optionsSuccessStatus: 204,
});
// 3. Listen on port using Fastify's listen method
// '0.0.0.0' is recommended for Docker/containerized environments
await app.listen(APP_PORT, '0.0.0.0', () => {
logger.log(`Application is running on: http://localhost:${APP_PORT}`);
logger.log(`Swagger documentation: http://localhost:${APP_PORT}/docs`);
});
}
bootstrap();
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AdminService } from './admin.service';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Admin } from './entities/user.entity';
import { JwtModule } from '@nestjs/jwt';
@Module({
providers: [AdminService],
controllers: [],
imports: [MikroOrmModule.forFeature([Admin]), JwtModule],
exports: [AdminService],
})
export class AdminModule {}
+28
View File
@@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityRepository } from '@mikro-orm/core';
import { Admin } from './entities/user.entity';
import { EntityManager } from '@mikro-orm/postgresql';
@Injectable()
export class AdminService {
constructor(
@InjectRepository(Admin)
private readonly adminRepository: EntityRepository<Admin>,
private readonly em: EntityManager,
) {}
async findByPhone(phone: string): Promise<Admin | null> {
return this.adminRepository.findOne({ phone });
}
async findById(id: number): Promise<Admin | null> {
return this.adminRepository.findOne({ id });
}
async create(phone: string): Promise<Admin> {
const user = this.adminRepository.create({ phone });
await this.em.persistAndFlush(user);
return user;
}
}
+24
View File
@@ -0,0 +1,24 @@
import { Entity, PrimaryKey, Property, OptionalProps } from '@mikro-orm/core';
@Entity({ tableName: 'admns' })
export class Admin {
[OptionalProps]?: 'id' | 'createdAt' | 'updatedAt';
@PrimaryKey()
id!: number;
@Property({ nullable: true })
firstName?: string;
@Property({ nullable: true })
lastName?: string;
@Property({ unique: true })
phone!: string;
@Property({ defaultRaw: 'now()', columnType: 'timestamptz' })
createdAt: Date = new Date();
@Property({ onUpdate: () => new Date(), defaultRaw: 'now()', columnType: 'timestamptz' })
updatedAt: Date = new Date();
}
+49
View File
@@ -0,0 +1,49 @@
import { Controller, Post, Body } from '@nestjs/common';
import { AuthService } from './auth.service';
import { RequestOtpDto } from './dto/request-otp.dto';
import { ApiTags, ApiOperation, ApiBody, ApiResponse } from '@nestjs/swagger';
import { VerifyOtpDto } from './dto/verify-otp.dto copy';
import { Throttle } from '@nestjs/throttler';
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Throttle({ default: { limit: 3, ttl: 180_000 } })
@Post('otp/request')
@ApiOperation({ summary: 'Request OTP for login or signup' })
@ApiBody({ type: RequestOtpDto, description: 'Mobile number to receive OTP' })
@ApiResponse({ status: 201, description: 'OTP requested successfully' })
@ApiResponse({ status: 400, description: 'Invalid mobile number' })
otpRequest(@Body() dto: RequestOtpDto) {
return this.authService.requestOtp(dto);
}
@Post('otp/verify')
@ApiOperation({ summary: 'Verify OTP code' })
@ApiBody({ type: VerifyOtpDto, description: 'Mobile number and OTP code' })
@ApiResponse({ status: 200, description: 'OTP verified successfully' })
@ApiResponse({ status: 400, description: 'Invalid OTP or expired' })
otpVerify(@Body() dto: VerifyOtpDto) {
return this.authService.verifyOtp(dto.phone, dto.otp); // assuming dto has `otp` property
}
@Post('admin/otp/request')
@ApiOperation({ summary: 'Request OTP for login or signup' })
@ApiBody({ type: RequestOtpDto, description: 'Mobile number to receive OTP' })
@ApiResponse({ status: 201, description: 'OTP requested successfully' })
@ApiResponse({ status: 400, description: 'Invalid mobile number' })
adminOtpRequest(@Body() dto: RequestOtpDto) {
return this.authService.requestOtpAdmin(dto);
}
@Post('admin/otp/verify')
@ApiOperation({ summary: 'Verify OTP code' })
@ApiBody({ type: VerifyOtpDto, description: 'Mobile number and OTP code' })
@ApiResponse({ status: 200, description: 'OTP verified successfully' })
@ApiResponse({ status: 400, description: 'Invalid OTP or expired' })
adminOtpVerify(@Body() dto: VerifyOtpDto) {
return this.authService.verifyOtpAdmin(dto.phone, dto.otp); // assuming dto has `otp` property
}
}
+29
View File
@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UtilsModule } from '../utils/utils.module';
import { UserModule } from '../user/user.module';
import { JwtModule } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { AdminModule } from '../admin/admin.module';
@Module({
imports: [
UtilsModule,
UserModule,
JwtModule.registerAsync({
useFactory: (configService: ConfigService) => ({
global: true,
secret: configService.getOrThrow<string>('JWT_SECRET'),
signOptions: {
expiresIn: configService.getOrThrow<number>('JWT_EXPIRATION_TIME'),
},
}),
inject: [ConfigService],
}),
AdminModule,
],
controllers: [AuthController],
providers: [AuthService],
})
export class AuthModule {}
+91
View File
@@ -0,0 +1,91 @@
import { Injectable, BadRequestException, UnauthorizedException, NotFoundException } from '@nestjs/common';
import { RequestOtpDto } from './dto/request-otp.dto';
import { CacheService } from '../utils/cache.service';
import { SmsService } from '../utils/sms.service';
import { UserService } from '../user/user.service';
import { randomInt } from 'crypto';
import { JwtService } from '@nestjs/jwt';
import { AdminService } from '../admin/admin.service';
import { AuthEntityType } from 'src/common/guards/auth.guard';
@Injectable()
export class AuthService {
constructor(
private readonly cacheService: CacheService,
private readonly smsService: SmsService,
private readonly userService: UserService,
private readonly jwtService: JwtService,
private readonly adminService: AdminService,
) {}
async requestOtp(dto: RequestOtpDto) {
const { phone } = dto;
const code = this.generateOtpCode();
await this.cacheService.set(`otp:${phone}`, code, 160);
await this.smsService.sendOtp(phone, code);
return { success: true, message: ' OTP sent successfully' };
}
async requestOtpAdmin(dto: RequestOtpDto) {
const { phone } = dto;
const admin = await this.adminService.findByPhone(phone);
if (!admin) {
throw new NotFoundException();
}
const code = this.generateOtpCode();
await this.cacheService.set(`otp-admin:${phone}`, code, 160);
await this.smsService.sendOtp(phone, code);
return { success: true, message: ' OTP sent successfully' };
}
async verifyOtp(phone: string, code: string) {
const cachedCode = await this.cacheService.get(`otp:${phone}`);
if (!cachedCode) throw new BadRequestException('OTP expired or not found');
if (cachedCode !== code) throw new BadRequestException('Invalid OTP');
await this.cacheService.del(`otp:${phone}`);
const user = await this.userService.findOrCreateByPhone(phone);
const accessToken = await this.issueToken(user.id.toString(), AuthEntityType.USER);
return { success: true, message: 'User registered successfully!', accessToken };
}
async verifyOtpAdmin(phone: string, code: string) {
const cachedCode = await this.cacheService.get(`otp-admin:${phone}`);
if (!cachedCode) throw new BadRequestException('OTP expired or not found');
if (cachedCode !== code) throw new BadRequestException('Invalid OTP');
await this.cacheService.del(`otp:${phone}`);
const admin = await this.adminService.findByPhone(phone);
if (!admin) {
throw new UnauthorizedException();
}
const accessToken = await this.issueToken(admin.id.toString(), AuthEntityType.ADMIN);
return { success: true, message: 'successfully!', accessToken };
}
private generateOtpCode(): string {
const code = randomInt(10000, 100000);
return code.toString();
}
private issueToken(id: string, entityType: AuthEntityType) {
const payload = {
sub: id,
type: entityType,
};
return this.jwtService.signAsync(payload);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { IsNotEmpty, IsString, Matches } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RequestOtpDto {
@IsNotEmpty()
@IsString()
@ApiProperty({ example: '09362532122', description: 'Mobile number' })
@Matches(/^09\d{9}$/, {
message: 'Mobile number must be a valid Iranian phone number (e.g., 09123456789)',
})
// @IsMobilePhone('fa-IR')
phone: string;
}
@@ -0,0 +1,19 @@
import { IsNotEmpty, IsString, Length, Matches } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class VerifyOtpDto {
@IsNotEmpty()
@IsString()
@ApiProperty({ example: '09362532122', description: 'Mobile number' })
@Matches(/^09\d{9}$/, {
message: 'Mobile number must be a valid Iranian phone number (e.g., 09123456789)',
})
// @IsMobilePhone('fa-IR')
phone: string;
@IsNotEmpty()
@IsString()
@ApiProperty({ example: '12345', description: 'Otp' })
@Length(5)
otp: string;
}
@@ -0,0 +1,29 @@
import { IsString, IsNotEmpty, IsDate, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class CreateSubmissioncDto {
@ApiPropertyOptional({ example: 'صداو سیما', description: 'دستگاه همکار : مثلا صدا و سیما' })
@IsOptional()
@IsString()
cooperator: string;
@ApiPropertyOptional({ example: 'ملی', description: 'ملی|استانی|' })
@IsOptional()
@IsString()
cooperationLevel: string;
@ApiProperty({
example: '2025-11-03T12:30:00.000Z',
description: 'The date and time of the submission (ISO 8601 format).',
})
@IsNotEmpty({ message: 'Submission Date is required.' })
@IsDate({ message: 'Submission Date must be a valid ISO 8601 date.' })
@Type(() => Date)
submissionDate: Date;
@ApiProperty({ example: 'مقاله', description: ' ' })
@IsNotEmpty()
@IsString()
submissionType: string;
}
@@ -0,0 +1,109 @@
import { IsOptional, IsString, IsNumber, Min, IsEnum, IsIn } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Submition, SubmitionStatus } from '../entities/submition.entity';
const sortOrderOptions = ['asc', 'desc'] as const;
type SortOrder = (typeof sortOrderOptions)[number];
export class FindSubmitionsDto {
/**
* The page number to retrieve.
* @default 1
*/
@ApiPropertyOptional({
description: 'The page number to retrieve.',
type: Number,
default: 1,
minimum: 1,
})
@IsOptional()
@IsNumber()
@Min(1)
@Type(() => Number)
page?: number = 1;
/**
* The number of items per page.
* @default 10
*/
@ApiPropertyOptional({
description: 'The number of items per page.',
type: Number,
default: 10,
minimum: 1,
})
@IsOptional()
@IsNumber()
@Min(1)
@Type(() => Number)
limit?: number = 10;
/**
* A general search term to filter by.
* Searches firstName, lastName, phone, and nationalCode.
*/
@ApiPropertyOptional({
description: 'A general search term (firstName, lastName, phone, nationalCode).',
type: String,
example: 'John Doe',
})
@IsOptional()
@IsString()
search?: string;
/**
* Filter by a specific user status.
*/
@ApiPropertyOptional({
description: 'Filter by a specific user status.',
enum: SubmitionStatus, // Use the actual enum
example: SubmitionStatus.finished,
})
@IsOptional()
@IsEnum(SubmitionStatus)
status?: SubmitionStatus;
@ApiPropertyOptional({
description: 'Filter by a cooperationLevel.',
example: 'ملی',
})
@IsOptional()
@IsString()
cooperationLevel?: string;
@ApiPropertyOptional({
description: 'Filter by a submissionType.',
example: 'مقاله',
})
@IsOptional()
@IsString()
submissionType?: string;
/**
* The field to sort the results by.
* @default "user"
*/
@ApiPropertyOptional({
description: 'The field to sort the results by.',
type: String,
default: 'user',
example: 'user',
})
@IsOptional()
@IsString()
orderBy?: keyof Submition = 'user';
/**
* The direction to sort the results.
* @default "desc"
*/
@ApiPropertyOptional({
description: 'The direction to sort the results.',
enum: sortOrderOptions, // Use the constant array
default: 'desc',
})
@IsOptional()
@IsIn(sortOrderOptions)
order?: SortOrder = 'desc';
}
@@ -0,0 +1,45 @@
import {
IsString,
IsNotEmpty,
IsArray,
ArrayMinSize,
Validate,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
@ValidatorConstraint({ name: 'MaxWordCount', async: false })
export class MaxWordCount implements ValidatorConstraintInterface {
validate(text: string) {
if (typeof text !== 'string') return false;
const wordCount = text.trim().split(/\s+/).filter(Boolean).length;
return wordCount <= 10000;
}
defaultMessage() {
return 'Submission description must not exceed 1000 words.';
}
}
export class UpdateDocsDto {
@ApiProperty({
example: 'توضیحاتی در مورد مدارک ارسالی و هدف از ارسال.',
description: 'Detailed description or notes regarding the documents being submitted (maximum 1000 words).',
})
@IsNotEmpty({ message: 'Submission description is required.' })
@IsString({ message: 'Submission description must be a string.' })
@Validate(MaxWordCount)
submissionDesc: string;
@ApiProperty({
example: ['https://example.com/doc1.pdf', 'https://example.com/doc2.jpg'],
description: 'An array of URLs pointing to the submitted documents.',
type: [String],
})
@IsNotEmpty({ message: 'Document URLs are required.' })
@IsArray({ message: 'Document URLs must be an array.' })
@ArrayMinSize(1, { message: 'At least one document URL is required.' })
@IsString({ each: true, message: 'Each document URL must be a valid string.' })
docsUrl: string[];
}
@@ -0,0 +1,29 @@
import { IsString, IsNotEmpty, IsDate, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class UpdateSubmissioncDto {
@ApiPropertyOptional({ example: 'صداو سیما', description: 'دستگاه همکار : مثلا صدا و سیما' })
@IsOptional()
@IsString()
cooperator: string;
@ApiPropertyOptional({ example: 'ملی', description: 'ملی|استانی|' })
@IsOptional()
@IsString()
cooperationLevel: string;
@ApiProperty({
example: '2025-11-03T12:30:00.000Z',
description: 'The date and time of the submission (ISO 8601 format).',
})
@IsNotEmpty({ message: 'Submission Date is required.' })
@IsDate({ message: 'Submission Date must be a valid ISO 8601 date.' })
@Type(() => Date)
submissionDate: Date;
@ApiProperty({ example: 'مقاله', description: ' ' })
@IsNotEmpty()
@IsString()
submissionType: string;
}
@@ -0,0 +1,48 @@
import { Entity, Enum, PrimaryKey, Property, OptionalProps, ManyToOne } from '@mikro-orm/core';
import { User } from '../../user/entities/user.entity';
export enum SubmitionStatus {
finished = 'تکمیل',
pending = 'ناقص',
}
@Entity({ tableName: 'submition' })
export class Submition {
[OptionalProps]?: 'id' | 'createdAt' | 'updatedAt';
@ManyToOne(() => User)
user!: User;
@PrimaryKey()
id!: number;
@Enum({ items: () => SubmitionStatus, nullable: true, default: SubmitionStatus.pending })
status?: SubmitionStatus;
@Property({ nullable: true })
submissionType?: string;
@Property({ nullable: true })
cooperator?: string;
@Property({ nullable: true })
cooperationLevel?: string;
@Property({ nullable: true, columnType: 'text' })
submissionDesc?: string;
@Property({ type: 'text[]', nullable: true })
docsUrl?: string[];
@Property({ columnType: 'timestamptz', nullable: true })
submissionDate?: Date;
@Property({ defaultRaw: 'now()', columnType: 'timestamptz' })
createdAt: Date = new Date();
@Property({ onUpdate: () => new Date(), defaultRaw: 'now()', columnType: 'timestamptz' })
updatedAt: Date = new Date();
@Property({ nullable: true, columnType: 'timestamptz' })
deletedAt?: Date;
}
@@ -0,0 +1,135 @@
import {
Controller,
Get,
Patch,
Body,
Req,
UseGuards,
Query,
ValidationPipe,
Post,
Param,
ParseIntPipe,
Delete,
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiBody, ApiParam } from '@nestjs/swagger';
import { AuthGuard, type AuthRequest } from 'src/common/guards/auth.guard';
import { SubmitionService } from './submition.service';
import { UpdateSubmissioncDto } from './dto/update-submission.dto';
import { UpdateDocsDto } from './dto/update-docs.dto';
import { AdminAuthGuard } from 'src/common/guards/auth.admin.guard';
import { FindSubmitionsDto } from './dto/find-submitions.dto';
import { CreateSubmissioncDto } from './dto/create-submission.dto';
import { SubmitionStatus } from './entities/submition.entity';
@ApiTags('submition')
@Controller('submition')
export class SubmitionController {
constructor(private readonly submitionService: SubmitionService) {}
@UseGuards(AuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get User submissions' })
@Get('/')
async getUserSbmitions(@Req() req: AuthRequest) {
const userId = req.user.sub;
const submitions = await this.submitionService.findByUserId(+userId);
return {
message: `User submissions `,
submitions,
};
}
@UseGuards(AuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get single submission' })
@ApiParam({ name: 'id', description: 'The ID of the submission ', type: Number })
@Get(':id')
async getSbmition(@Req() req: AuthRequest, @Param('id', ParseIntPipe) id: number) {
const userId = req.user.sub;
const submition = await this.submitionService.findById(id, +userId);
return {
message: `Success `,
submition,
};
}
@UseGuards(AuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Create submission :first step of creation' })
@ApiBody({ type: CreateSubmissioncDto })
@Post('/')
async createSubmition(@Req() req: AuthRequest, @Body() dto: CreateSubmissioncDto) {
const userId = req.user.sub;
const submition = await this.submitionService.create(+userId, dto);
return {
message: `Created submission `,
userId,
submition,
};
}
@UseGuards(AuthGuard)
@ApiBearerAuth()
@ApiParam({ name: 'id', description: 'The ID of the submission to update', type: Number })
@ApiOperation({ summary: 'Update the submission : first step of update' })
@ApiBody({ type: UpdateSubmissioncDto })
@Patch(':id')
async updateUserSubmissionType(
@Req() req: AuthRequest,
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateSubmissioncDto,
) {
const userId = req.user.sub;
const submition = await this.submitionService.updateSubmition(id, +userId, dto);
return {
message: `PATCH request: Updating submission type for user done`,
userId,
submition,
};
}
@UseGuards(AuthGuard)
@ApiBearerAuth()
@ApiParam({ name: 'id', description: 'The ID of the submission to update', type: Number })
@ApiOperation({ summary: 'Update the user document/file reference' })
@ApiBody({ type: UpdateDocsDto })
@Patch(':id/doc')
async updateUserDoc(@Req() req: AuthRequest, @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateDocsDto) {
const userId = req.user.sub;
const submition = await this.submitionService.updateSubmition(id, +userId, {
...dto,
status: SubmitionStatus.finished,
});
return {
message: `PATCH request: Updating doc field for user ${userId} `,
userId,
submition,
};
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'submissions' })
@Get('/admin/submissions')
async findAll(
@Query(new ValidationPipe({ transform: true, whitelist: true }))
query: FindSubmitionsDto,
) {
return this.submitionService.findAll(query);
}
@UseGuards(AuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Delete submission' })
@ApiParam({ name: 'id', description: 'The ID of the submission ', type: Number })
@Delete(':id')
async deleteSbmition(@Req() req: AuthRequest, @Param('id', ParseIntPipe) id: number) {
const userId = req.user.sub;
const submition = await this.submitionService.deleteSubmition(id, +userId);
return {
message: `submition deleted successfully `,
submition,
};
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { SubmitionService } from './submition.service';
import { SubmitionController } from './submition.controller';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { JwtModule } from '@nestjs/jwt';
import { Submition } from './entities/submition.entity';
// import { AuthModule } from '../auth/auth.module';
@Module({
providers: [SubmitionService],
controllers: [SubmitionController],
imports: [MikroOrmModule.forFeature([Submition]), JwtModule],
exports: [SubmitionService],
})
export class SubmitionModule {}
+140
View File
@@ -0,0 +1,140 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityRepository, FilterQuery } from '@mikro-orm/core';
import { SubmitionStatus, Submition } from './entities/submition.entity';
import { EntityManager } from '@mikro-orm/postgresql';
import { UpdateSubmissioncDto } from './dto/update-submission.dto';
import { UpdateDocsDto } from './dto/update-docs.dto';
import { FindSubmitionsDto } from './dto/find-submitions.dto';
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
import { CreateSubmissioncDto } from './dto/create-submission.dto';
@Injectable()
export class SubmitionService {
constructor(
@InjectRepository(Submition)
private readonly submitionRepository: EntityRepository<Submition>,
private readonly em: EntityManager,
) {}
async updateSubmition(
submitionId: number,
userId: number,
dto: UpdateSubmissioncDto | (UpdateDocsDto & { status: SubmitionStatus }),
): Promise<Submition> {
const sub = await this.em.findOneOrFail(Submition, submitionId, { populate: ['user'] }).catch(() => {
throw new NotFoundException(`submition with ID ${submitionId} not found.`);
});
if (sub.user.id !== userId) {
throw new ForbiddenException(`User is not the owner of submition ID ${submitionId}.`);
}
this.em.assign(sub, dto);
await this.em.flush();
return sub;
}
async findById(submitionId: number, userId: number): Promise<Submition | null> {
return this.submitionRepository.findOne({ id: submitionId, user: { id: userId }, deletedAt: null });
}
async findByUserId(userId: number): Promise<Submition[] | null> {
return this.submitionRepository.find({ user: userId, deletedAt: null });
}
async create(userId: number, dto: CreateSubmissioncDto): Promise<Submition> {
const submition = this.submitionRepository.create({ ...dto, user: userId });
await this.em.persistAndFlush(submition);
return submition;
}
async findAll(dto: FindSubmitionsDto): Promise<PaginatedResult<Submition>> {
const {
page = 1,
limit = 10,
search,
status,
cooperationLevel,
submissionType,
orderBy = 'id',
order = 'desc',
} = dto;
// 1. Calculate pagination
const offset = (page - 1) * limit;
// 2. Build the 'where' filter query
const where: FilterQuery<Submition> = {
deletedAt: null,
};
// 3. Add exact-match filters (if provided)
if (status) {
where.status = status;
}
if (submissionType) {
where.submissionType = submissionType;
}
if (cooperationLevel) {
where.cooperationLevel = cooperationLevel;
}
// 4. Add 'search' logic (case-insensitive)
if (search) {
const searchPattern = `%${search}%`;
where.$or = [
{ submissionDesc: { $ilike: searchPattern } },
{ cooperator: { $ilike: searchPattern } },
{ user: { firstName: { $ilike: searchPattern } } },
{ user: { lastName: { $ilike: searchPattern } } },
{ user: { nationalCode: { $ilike: searchPattern } } },
{ user: { personalCode: { $ilike: searchPattern } } },
{ user: { phone: { $ilike: searchPattern } } },
];
}
// 5. Execute the query using findAndCount
const [sumitions, total] = await this.submitionRepository.findAndCount(where, {
limit,
offset,
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
populate: ['user'],
});
// 6. Calculate total pages
const totalPages = Math.ceil(total / limit);
// 7. Return the paginated result
return {
data: sumitions,
meta: {
total,
page,
limit,
totalPages,
},
};
}
async deleteSubmition(submitionId: number, userId: number): Promise<Submition> {
const submission = await this.em.findOneOrFail(Submition, submitionId, { populate: ['user'] }).catch(() => {
throw new NotFoundException(`Submition with ID ${submitionId} not found.`);
});
// Ownership Check (same as before)
if (submission.user.id !== userId) {
throw new ForbiddenException(`User with ID ${userId} is not authorized to delete submition ID ${submitionId}.`);
}
// Execute the Soft Delete: Update the timestamp
submission.deletedAt = new Date();
await this.em.flush();
return submission;
}
}
+19
View File
@@ -0,0 +1,19 @@
import type { File } from '@nest-lab/fastify-multer';
import { ApiProperty } from '@nestjs/swagger';
export class UploadSingleFileDto {
@ApiProperty({ type: 'string', format: 'binary', nullable: false })
file: File;
}
export class UploadMultipleFileDto {
@ApiProperty({
required: true,
type: 'array',
items: {
type: 'string',
format: 'binary',
},
})
files: File[];
}
@@ -0,0 +1,41 @@
import { File } from "@nest-lab/fastify-multer";
export interface FileUploadResult {
file: File;
url: string;
}
export interface FileStreamResult {
stream: NodeJS.ReadableStream;
file: File;
}
export interface FileMetadata {
width?: number;
height?: number;
duration?: number;
bitrate?: number;
codec?: string;
language?: string;
}
export type FileType = "video" | "audio" | "image" | "document" | "text" | "unknown";
export interface FileValidationOptions {
maxFileSize: number;
allowedMimeTypes: string[];
}
export interface FileUploadOptions {
roomId?: string;
metadata?: Record<string, string>;
}
// File processing status
export type FileProcessingStatus = "pending" | "processing" | "completed" | "failed";
export interface FileProcessingResult {
status: FileProcessingStatus;
metadata?: FileMetadata;
error?: string;
}
@@ -0,0 +1,37 @@
export interface S3UploadResult {
key: string;
url: string;
bucket: string;
}
export interface S3UploadOptions {
contentType: string;
metadata?: Record<string, string>;
acl?: "private" | "public-read" | "public-read-write";
}
export interface S3DownloadOptions {
expiresIn?: number;
responseContentType?: string;
responseContentDisposition?: string;
}
export interface S3FileInfo {
key: string;
bucket: string;
size: number;
lastModified: Date;
contentType: string;
etag: string;
}
export interface S3DeleteResult {
deleted: boolean;
key: string;
}
export interface S3ListResult {
files: S3FileInfo[];
continuationToken?: string;
isTruncated: boolean;
}
@@ -0,0 +1,173 @@
import { randomInt } from 'node:crypto';
import { Readable } from 'node:stream';
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { FileType } from '../interfaces/file.interface';
import { S3UploadResult } from '../interfaces/s3.interface';
@Injectable()
export class S3Service {
private readonly logger = new Logger(S3Service.name);
private readonly s3Client: S3Client;
private readonly bucketName: string;
private readonly region: string; // Region is often 'us-east-1' or similar for Liara, but depends on your config
private readonly endpointUrl: string; // Renamed 'url' to 'endpointUrl' for clarity
constructor(private readonly configService: ConfigService) {
// --- Configuration for Liara S3 ---
this.bucketName = this.configService.getOrThrow<string>('BUCKET_NAME');
// Liara S3 usually requires a specific endpoint URL
this.endpointUrl = this.configService.getOrThrow<string>('LIARA_S3_ENDPOINT');
// The region for Liara S3 is often 'us-east-1' or just a placeholder,
// but we'll keep it configurable for consistency.
this.region = this.configService.getOrThrow<string>('BUCKET_REGION');
this.s3Client = new S3Client({
region: this.region, // Use the configured region
endpoint: this.endpointUrl, // This is the crucial change for Liara S3
forcePathStyle: true, // Often required when using custom S3 endpoints like Liara
credentials: {
accessKeyId: this.configService.getOrThrow<string>('BUCKET_ACCESS_KEY'),
secretAccessKey: this.configService.getOrThrow<string>('BUCKET_SECRET_KEY'),
},
});
// ------------------------------------
}
/**
* Upload file to S3
*/
async uploadFile(
buffer: Buffer,
key: string,
contentType: string,
metadata?: Record<string, string>,
): Promise<S3UploadResult> {
try {
const sanitizedMetadata = metadata
? Object.entries(metadata).reduce(
(acc, [key, value]) => {
// Encode all values to be ASCII-safe
acc[key] = encodeURIComponent(value);
return acc;
},
{} as Record<string, string>,
)
: undefined;
// NOTE: 'ACL: public-read' might not be supported or necessary for Liara S3,
// depending on your bucket configuration on Liara.
// If uploads fail, try removing this line.
const command = new PutObjectCommand({
Bucket: this.bucketName,
Key: key,
Body: buffer,
// ACL: 'public-read',
ContentType: contentType,
Metadata: sanitizedMetadata,
});
await this.s3Client.send(command);
// --- URL for Liara S3 ---
// The public URL for Liara S3 files is typically: ${endpoint}/${bucketName}/${key}
const url = `${this.endpointUrl}/${this.bucketName}/${key}`;
this.logger.log(`File uploaded to S3: ${key}`);
return {
key,
url,
bucket: this.bucketName,
};
} catch (error: unknown) {
this.logger.error(`Failed to upload file to S3: ${(error as Error).message}`, (error as Error).stack);
throw error;
}
}
// --- Other methods remain the same as they use the S3Client which is now configured for Liara ---
/**
* Get file stream from S3
*/
async getFileStream(key: string): Promise<Readable> {
try {
const command = new GetObjectCommand({
Bucket: this.bucketName,
Key: key,
});
const response = await this.s3Client.send(command);
// ✅ VALIDATION
if (response.Body instanceof Readable) {
return response.Body;
}
// If it's not a Readable stream, throw a specific error
throw new Error(`File body is not a readable stream for key: ${key}`);
} catch (error: unknown) {
this.logger.error(`Failed to get file from S3: ${(error as Error).message}`, (error as Error).stack);
throw error;
}
}
/**
* Generate presigned URL for file download
*/
async getPresignedUrl(key: string, expiresIn: number = 3600): Promise<string> {
try {
const command = new GetObjectCommand({
Bucket: this.bucketName,
Key: key,
});
return await getSignedUrl(this.s3Client, command, { expiresIn });
} catch (error: unknown) {
this.logger.error(`Failed to generate presigned URL: ${(error as Error).message}`, (error as Error).stack);
throw error;
}
}
/**
* Delete file from S3
*/
async deleteFile(key: string): Promise<void> {
try {
const command = new DeleteObjectCommand({
Bucket: this.bucketName,
Key: key,
});
await this.s3Client.send(command);
this.logger.log(`File deleted from S3: ${key}`);
} catch (error: unknown) {
this.logger.error(`Failed to delete file from S3: ${(error as Error).message}`, (error as Error).stack);
throw error;
}
}
/**
* Generate S3 key for file
*/
generateFileKey(originalName: string, fileType: FileType): string {
const timestamp = Date.now();
const randomSuffix = randomInt(1000000, 9999999);
const extension = originalName.split('.').pop();
const basePathMap: Record<FileType, string> = {
image: 'images',
video: 'videos',
audio: 'audio',
document: 'documents',
text: 'texts',
unknown: 'unknowns',
};
const basePath = basePathMap[fileType];
return `${basePath}/${timestamp}-${randomSuffix}.${extension}`;
}
}
@@ -0,0 +1,133 @@
import { File } from '@nest-lab/fastify-multer';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { S3Service } from './s3.service';
import { UploaderMessage } from '../../../common/enums/message.enum';
import { FileType } from '../interfaces/file.interface';
@Injectable()
export class UploaderService {
private readonly logger = new Logger(UploaderService.name);
private readonly maxFileSize: number;
private readonly allowedMimeTypes: string[];
constructor(
private readonly configService: ConfigService,
private readonly s3Service: S3Service,
) {
this.maxFileSize = this.configService.get('MAX_FILE_SIZE', 1024 * 1024 * 100); // 100MB
this.allowedMimeTypes = [
'video/mp4',
'video/webm',
'video/ogg',
'video/avi',
'video/mov',
'video/mkv',
'audio/mp3',
'audio/wav',
'audio/ogg',
'audio/webm',
'audio/m4a',
'audio/aac',
'audio/flac',
'audio/m4a',
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
'image/tiff',
'image/bmp',
'application/pdf',
'text/plain',
'text/csv',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.presentationml.slideshow',
'application/vnd.ms-excel',
'application/vnd.ms-powerpoint',
'application/vnd.ms-excel.sheet.macroEnabled.12',
'application/vnd.ms-powerpoint.presentation.macroEnabled.12',
'application/vnd.ms-excel.template.macroEnabled.12',
'application/vnd.ms-powerpoint.template.macroEnabled.12',
'application/vnd.ms-excel.addin.macroEnabled.12',
'application/vnd.ms-powerpoint.addin.macroEnabled.12',
'application/vnd.ms-excel.sheet.binary.macroEnabled.12',
'application/vnd.ms-powerpoint.presentation.binary.macroEnabled.12',
'application/vnd.ms-excel.template.binary.macroEnabled.12',
'application/vnd.ms-powerpoint.template.binary.macroEnabled.12',
'application/vnd.ms-excel.addin.binary.macroEnabled.12',
];
}
async uploadMultiple(files: File[]) {
return await Promise.all(files.map(file => this.uploadFile(file)));
}
/**
* Handle file upload
*/
async uploadFile(file: File) {
if (!file || !file.buffer || !file.size) {
throw new BadRequestException(UploaderMessage.UPLOAD_FILE_INVALID);
}
this.logger.log(`Uploading file: ${file.originalname}`);
this.validateFile(file);
const fileType = await this.determineFileType(file.mimetype);
const s3Key = this.s3Service.generateFileKey(file.originalname, fileType);
// Upload to S3
const s3Result = await this.s3Service.uploadFile(file.buffer, s3Key, file.mimetype, {
originalName: file.originalname,
uploadedBy: 'admin',
});
this.logger.log(`File uploaded successfully: `);
return {
file: s3Result,
url: s3Result.url,
};
}
/**
* Validate uploaded file
*/
private validateFile(file: File): void {
if (!file) throw new BadRequestException(UploaderMessage.UPLOAD_FILE_INVALID);
if (file.size && file.size > this.maxFileSize)
throw new BadRequestException(
UploaderMessage.UPLOAD_FILE_TOO_LARGE.replace('[MAX_FILE_SIZE]', this.maxFileSize.toString()),
);
if (!this.allowedMimeTypes.includes(file.mimetype))
throw new BadRequestException(
UploaderMessage.UPLOAD_FILE_TYPE_NOT_SUPPORTED.replace('[MIME_TYPES]', file.mimetype),
);
}
/**
* Determine file type based on mimetype
*/
private async determineFileType(mimetype: string): Promise<FileType> {
if (mimetype.startsWith('video/')) {
return 'video';
} else if (mimetype.startsWith('audio/')) {
return 'audio';
} else if (mimetype.startsWith('image/')) {
return 'image';
} else if (mimetype.startsWith('text/')) {
return 'text';
} else if (mimetype.startsWith('application/')) {
return 'document';
}
return 'unknown';
}
}
@@ -0,0 +1,35 @@
import { type File, FileInterceptor, FilesInterceptor } from '@nest-lab/fastify-multer';
import { Controller, Post, UploadedFile, UploadedFiles, UseGuards, UseInterceptors } from '@nestjs/common';
import { ApiBody, ApiConsumes, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { UploadMultipleFileDto, UploadSingleFileDto } from './DTO/upload-file.dto';
import { UploaderService } from './providers/uploader.service';
import { AuthGuard } from 'src/common/guards/auth.guard';
@Controller('uploader')
export class UploaderController {
constructor(private readonly uploaderService: UploaderService) {}
@ApiOperation({ summary: 'Upload a file (admin)' })
@ApiConsumes('multipart/form-data')
@ApiBearerAuth()
@UseInterceptors(FileInterceptor('file'))
@ApiBody({ type: UploadSingleFileDto })
@Post('single-file')
@UseGuards(AuthGuard)
uploadFile(@UploadedFile() file: File) {
return this.uploaderService.uploadFile(file);
}
@ApiOperation({ summary: 'Uploads multiple files' })
@ApiConsumes('multipart/form-data')
@UseInterceptors(FilesInterceptor('files'))
@ApiBearerAuth()
@ApiBody({ type: UploadMultipleFileDto })
@Post('multi-file')
@UseGuards(AuthGuard)
@UseGuards(AuthGuard)
uploadFiles(@UploadedFiles() files: File[]) {
return this.uploaderService.uploadMultiple(files);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { S3Service } from './providers/s3.service';
import { UploaderService } from './providers/uploader.service';
import { UploaderController } from './uploader.controller';
import { JwtModule } from '@nestjs/jwt';
@Module({
controllers: [UploaderController],
providers: [UploaderService, S3Service],
exports: [UploaderService, S3Service],
imports: [JwtModule],
})
export class UploaderModule {}
+96
View File
@@ -0,0 +1,96 @@
import { IsOptional, IsString, IsNumber, Min, IsEnum, IsIn } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { EducationLevel, User } from '../entities/user.entity';
// Define the valid sort directions
const sortOrderOptions = ['asc', 'desc'] as const;
type SortOrder = (typeof sortOrderOptions)[number];
export class FindUsersDto {
/**
* The page number to retrieve.
* @default 1
*/
@ApiPropertyOptional({
description: 'The page number to retrieve.',
type: Number,
default: 1,
minimum: 1,
})
@IsOptional()
@IsNumber()
@Min(1)
@Type(() => Number)
page?: number = 1;
/**
* The number of items per page.
* @default 10
*/
@ApiPropertyOptional({
description: 'The number of items per page.',
type: Number,
default: 10,
minimum: 1,
})
@IsOptional()
@IsNumber()
@Min(1)
@Type(() => Number)
limit?: number = 10;
/**
* A general search term to filter by.
* Searches firstName, lastName, phone, and nationalCode.
*/
@ApiPropertyOptional({
description: 'A general search term (firstName, lastName, phone, nationalCode).',
type: String,
example: 'John Doe',
})
@IsOptional()
@IsString()
search?: string;
/**
* Filter by a specific education level.
*/
@ApiPropertyOptional({
description: 'Filter by a specific education level.',
enum: EducationLevel, // Use the actual enum
example: EducationLevel.bachelor,
})
@IsOptional()
@IsEnum(EducationLevel)
education?: EducationLevel;
/**
* The field to sort the results by.
* @default "createdAt"
*/
@ApiPropertyOptional({
description: 'The field to sort the results by.',
type: String,
default: 'createdAt',
// You can provide a list of valid keys for better documentation
// You must ensure 'keyof User' is correctly exported/available here
example: 'lastName',
})
@IsOptional()
@IsString()
orderBy?: keyof User = 'createdAt';
/**
* The direction to sort the results.
* @default "desc"
*/
@ApiPropertyOptional({
description: 'The direction to sort the results.',
enum: sortOrderOptions, // Use the constant array
default: 'desc',
})
@IsOptional()
@IsIn(sortOrderOptions)
order?: SortOrder = 'desc';
}
+63
View File
@@ -0,0 +1,63 @@
import { IsString, IsNotEmpty, IsBoolean, IsNumber, IsEnum, MinLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { EducationLevel } from '../entities/user.entity';
export class UpdateUserDto {
@ApiProperty({ example: ' ', description: "User's first name" })
@IsNotEmpty()
@IsString()
firstName: string;
@ApiProperty({ example: ' ', description: "User's last name" })
@IsNotEmpty()
@IsString()
lastName: string;
@ApiProperty({ example: ' ', description: "User's father's name" })
@IsNotEmpty()
@IsString()
fatherName: string;
// --- Personal Details ---
@ApiPropertyOptional({ example: true, description: 'Gender: true for male, false for female' })
@IsNotEmpty()
@IsBoolean()
isMale: boolean;
@ApiPropertyOptional({ example: '1234567890', description: 'Unique national code' })
@IsNotEmpty()
@MinLength(10)
nationalCode: string;
@ApiPropertyOptional({ example: '1001', description: 'Employee personal code' })
@IsNotEmpty()
@IsString()
personalCode: string;
// --- Employment Details ---
@ApiPropertyOptional({ example: 'قراردادی', description: 'Type of hire (e.g., permanent, temporary)' })
@IsNotEmpty()
@IsString()
hireType: string;
@ApiPropertyOptional({ example: 'اراک', description: 'Primary work location' })
@IsNotEmpty()
@IsString()
workLocation: string;
@ApiPropertyOptional({ example: 5, description: 'Years of work experience' })
@IsNotEmpty()
@IsNumber()
workExperienceYear: number;
@ApiPropertyOptional({
enum: EducationLevel,
example: EducationLevel.master,
description: 'Highest level of education achieved',
})
@IsNotEmpty()
@IsEnum(EducationLevel)
education: EducationLevel;
}
+68
View File
@@ -0,0 +1,68 @@
import { Entity, Enum, PrimaryKey, Property, OptionalProps, OneToMany, Collection } from '@mikro-orm/core';
import { Submition } from '../../submition/entities/submition.entity';
export enum EducationLevel {
diploma = 'دیپلم',
associate = 'کاردانی',
bachelor = 'کارشناسی',
master = 'کارشناسی ارشد',
phd = 'دکتری',
}
export enum UserStatus {
finished = 'finished',
pending = 'pending',
}
@Entity({ tableName: 'users' })
export class User {
[OptionalProps]?: 'id' | 'createdAt' | 'updatedAt';
@OneToMany(() => Submition, submition => submition.user)
submissions = new Collection<Submition>(this);
@PrimaryKey()
id!: number;
@Property({ nullable: true })
firstName?: string;
@Property({ nullable: true })
lastName?: string;
@Property({ nullable: true })
fatherName?: string;
@Property({ unique: true })
phone!: string;
@Property({ nullable: true })
isMale?: boolean;
@Property({ unique: true, nullable: true })
nationalCode?: string;
@Property({ nullable: true })
personalCode?: string;
@Property({ nullable: true })
hireType?: string;
@Property({ nullable: true })
workLocation?: string;
@Property({ default: 0, nullable: true })
workExperienceYear?: number;
@Enum({ items: () => EducationLevel, nullable: true })
education?: EducationLevel;
@Enum({ items: () => UserStatus, nullable: true, default: UserStatus.pending })
status?: UserStatus;
@Property({ defaultRaw: 'now()', columnType: 'timestamptz' })
createdAt: Date = new Date();
@Property({ onUpdate: () => new Date(), defaultRaw: 'now()', columnType: 'timestamptz' })
updatedAt: Date = new Date();
}
+54
View File
@@ -0,0 +1,54 @@
import { Controller, Get, Patch, Body, Req, UseGuards, Query, ValidationPipe } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiBody } from '@nestjs/swagger';
import { AuthGuard, type AuthRequest } from 'src/common/guards/auth.guard';
import { UserService } from './user.service';
import { UpdateUserDto } from './dto/update-user.dto';
import { FindUsersDto } from './dto/find-user.dto';
import { AdminAuthGuard } from 'src/common/guards/auth.admin.guard';
@ApiTags('User')
@Controller('user')
export class UserController {
constructor(private readonly userService: UserService) {}
@UseGuards(AuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get the current authenticated user profile' })
@Get('/')
async getUser(@Req() req: AuthRequest) {
const userId = req.user.sub;
const user = await this.userService.findById(+userId);
return {
message: `GET request: Retrieving profile for authenticated user `,
user,
};
}
// 2. Update User Specification (PATCH /user/spec)
@UseGuards(AuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Update the user specification' })
@ApiBody({ type: UpdateUserDto })
@Patch('/')
async updateUserSpec(@Req() req: AuthRequest, @Body() dto: UpdateUserDto) {
const userId = req.user.sub;
const user = await this.userService.updateUser(+userId, dto);
return {
message: `PATCH request: Updating specification for user ${userId} `,
userId,
user,
};
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Users : Admin Route' })
@Get('/admin/users')
async findAll(
// Use ValidationPipe to validate and transform the DTO
@Query(new ValidationPipe({ transform: true, whitelist: true }))
query: FindUsersDto,
) {
return this.userService.findAll(query);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserController } from './user.controller';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { User } from './entities/user.entity';
import { JwtModule } from '@nestjs/jwt';
@Module({
providers: [UserService],
controllers: [UserController],
imports: [MikroOrmModule.forFeature([User]), JwtModule],
exports: [UserService],
})
export class UserModule {}
+104
View File
@@ -0,0 +1,104 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityRepository, FilterQuery } from '@mikro-orm/core';
import { User, UserStatus } from './entities/user.entity';
import { EntityManager } from '@mikro-orm/postgresql';
import { UpdateUserDto } from './dto/update-user.dto';
import { FindUsersDto } from './dto/find-user.dto';
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private readonly userRepository: EntityRepository<User>,
private readonly em: EntityManager,
) {}
async findOrCreateByPhone(phone: string): Promise<User> {
let user = await this.userRepository.findOne({ phone });
if (!user) {
user = this.userRepository.create({
phone,
});
await this.em.persistAndFlush(user);
}
return user;
}
async updateUser(userId: number, dto: UpdateUserDto): Promise<User> {
const user = await this.em.findOneOrFail(User, userId, {}).catch(() => {
throw new NotFoundException(`User with ID ${userId} not found.`);
});
this.em.assign(user, { ...dto, status: UserStatus.finished });
await this.em.flush();
return user;
}
async findByPhone(phone: string): Promise<User | null> {
return this.userRepository.findOne({ phone });
}
async findById(id: number): Promise<User | null> {
return this.userRepository.findOne({ id });
}
async create(phone: string): Promise<User> {
const user = this.userRepository.create({ phone });
await this.em.persistAndFlush(user);
return user;
}
async findAll(dto: FindUsersDto): Promise<PaginatedResult<User>> {
const { page = 1, limit = 10, search, education, orderBy = 'createdAt', order = 'desc' } = dto;
// 1. Calculate pagination
const offset = (page - 1) * limit;
// 2. Build the 'where' filter query
const where: FilterQuery<User> = {};
if (education) {
where.education = education;
}
// 4. Add 'search' logic (case-insensitive)
if (search) {
const searchPattern = `%${search}%`;
// $ilike is case-insensitive (PostgreSQL). Use $like for MySQL (often CI by default)
where.$or = [
{ firstName: { $ilike: searchPattern } },
{ lastName: { $ilike: searchPattern } },
{ phone: { $ilike: searchPattern } },
{ nationalCode: { $ilike: searchPattern } },
{ personalCode: { $ilike: searchPattern } },
];
}
// 5. Execute the query using findAndCount
const [users, total] = await this.userRepository.findAndCount(where, {
limit,
offset,
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
});
// 6. Calculate total pages
const totalPages = Math.ceil(total / limit);
// 7. Return the paginated result
return {
data: users,
meta: {
total,
page,
limit,
totalPages,
},
};
}
}
+150
View File
@@ -0,0 +1,150 @@
import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';
@Injectable()
export class CacheService implements OnModuleInit {
private readonly logger = new Logger(CacheService.name);
async onModuleInit() {
this.logger.log('Pinging Redis to check connection...');
try {
// 3. Try to set and get a test key
await this.cacheManager.set('ping', 'pong', 10); // 10-second TTL
const result = await this.cacheManager.get('ping');
if (result === 'pong') {
this.logger.log('✅ Redis connection successful (set/get test passed).');
} else {
this.logger.error(`❌ Redis connection test failed. Expected 'pong', got '${result}'.`);
}
// 4. Clean up the test key
await this.cacheManager.del('ping');
} catch (error) {
this.logger.error('❌ Failed to connect to Redis during ping test:', error.message);
}
}
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
async get<T>(key: string) {
return await this.cacheManager.get<T>(key);
}
async set(key: string, value: string, ttl: number = 160) {
await this.cacheManager.set(key, value, ttl * 1000);
}
async del(key: string) {
await this.cacheManager.del(key);
}
async getTtl(key: string) {
return await this.cacheManager.ttl(key);
}
}
// // src/modules/cache/cache.service.ts
// import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager';
// import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';
// @Injectable()
// export class CacheService implements OnModuleInit {
// private readonly logger = new Logger(CacheService.name);
// constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
// /**
// * On-init lifecycle hook to test the cache connection.
// */
// async onModuleInit() {
// this.logger.log('Pinging Redis to check connection...');
// const testKey = 'ping';
// const testValue = 'pong';
// try {
// // 1. Set test key with a 10-second TTL
// // Note: We use the wrapper to ensure consistent TTL logic (seconds -> ms)
// await this.set(testKey, testValue, 10);
// // 2. Get test key
// const result = await this.get(testKey);
// if (result === testValue) {
// this.logger.log('✅ Redis connection successful (set/get test passed).');
// } else {
// this.logger.error(
// `❌ Redis connection test failed. Expected '${testValue}', got '${result}'.`,
// );
// }
// // 3. Clean up the test key
// await this.del(testKey);
// } catch (error) {
// this.logger.error(
// '❌ Failed to connect to Redis during ping test:',
// error.message,
// );
// }
// }
// /**
// * Gets a value from the cache.
// * @param key The cache key.
// * @returns The cached value or undefined.
// */
// async get<T>(key: string): Promise<T | undefined> {
// try {
// return await this.cacheManager.get<T>(key);
// } catch (error) {
// this.logger.error(`Error getting from cache (key: ${key}):`, error.message);
// return undefined; // Fail gracefully
// }
// }
// /**
// * Sets a value in the cache.
// * @param key The cache key.
// * @param value The value to store (can be any serializable type).
// * @param ttlInSeconds The time-to-live in **seconds**.
// */
// async set(key: string, value: unknown, ttlInSeconds: number = 160): Promise<void> {
// try {
// // Convert seconds to milliseconds for cache-manager
// await this.cacheManager.set(key, value, ttlInSeconds * 1000);
// } catch (error) {
// this.logger.error(`Error setting cache (key: ${key}):`, error.message);
// }
// }
// /**
// * Deletes a value from the cache.
// * @param key The cache key to delete.
// */
// async del(key: string): Promise<void> {
// try {
// await this.cacheManager.del(key);
// } catch (error)
// {
// this.logger.error(`Error deleting from cache (key: ${key}):`, error.message);
// }
// }
// /**
// * Gets the remaining TTL for a key in seconds.
// * Note: This implementation is specific to redis-store.
// * @param key The cache key.
// * @returns The remaining TTL in seconds, or -1 (no expiry) / -2 (key doesn't exist).
// */
// async getTtl(key: string): Promise<number | undefined> {
// try {
// // The `ttl` method from cache-manager-redis-store returns TTL in seconds
// return await this.cacheManager.ttl(key);
// } catch (error) {
// this.logger.error(`Error getting TTL (key: ${key}):`, error.message);
// return undefined;
// }
// }
// }
+63
View File
@@ -0,0 +1,63 @@
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
// import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
// import { lastValueFrom } from 'rxjs';
import axios from 'axios';
export interface IParameterArray {
name: 'otp';
value: string;
}
export interface ISmsResponse {
status: number;
message: string;
}
export interface ISmsVerifyBody {
Parameters: IParameterArray[];
Mobile: string;
TemplateId: string;
}
@Injectable()
export class SmsService {
private readonly baseURL: string;
private readonly OTP: string;
private readonly apiKey: string;
constructor(
// private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {
this.baseURL = this.configService.getOrThrow<string>('SMS_BASE_URL');
this.OTP = this.configService.getOrThrow<string>('SMS_PATTERN_OTP');
this.apiKey = this.configService.getOrThrow<string>('SMS_API_KEY');
}
async sendOtp(mobile: string, otp: string) {
const smsData: ISmsVerifyBody = {
Parameters: [{ name: 'otp', value: otp }],
Mobile: mobile,
TemplateId: this.OTP,
};
const url = `${this.baseURL}/send/verify`;
try {
const { data } = await axios.post<ISmsResponse>(url, smsData, {
headers: {
'Content-Type': 'application/json',
'X-API-KEY': this.apiKey,
},
});
if (data.status !== 1) {
throw new HttpException('The SMS was not sent. Please try again later.', HttpStatus.BAD_GATEWAY);
}
return data;
} catch (error) {
console.error('SMS sending failed:', JSON.stringify(error));
throw new HttpException('The SMS was not sent. Please try again later.', HttpStatus.BAD_GATEWAY);
}
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { CacheService } from './cache.service';
import { SmsService } from './sms.service';
import { CacheModule } from '@nestjs/cache-manager';
import { cacheConfig } from '../../config/cache.config';
@Module({
imports: [CacheModule.registerAsync(cacheConfig())],
controllers: [],
providers: [CacheService, SmsService],
exports: [CacheService, SmsService],
})
export class UtilsModule {}